diff options
Diffstat (limited to 'packages/app/src/components')
43 files changed, 2872 insertions, 2508 deletions
diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index 65e322b43..4d24b2315 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -10,7 +10,6 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { Spinner } from "@opencode-ai/ui/spinner" import { TextField } from "@opencode-ai/ui/text-field" import { showToast } from "@opencode-ai/ui/toast" -import { iife } from "@opencode-ai/util/iife" import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js" import { createStore, produce } from "solid-js/store" import { Link } from "@/components/link" @@ -55,6 +54,47 @@ export function DialogConnectProvider(props: { provider: string }) { error: undefined as string | undefined, }) + type Action = + | { type: "method.select"; index: number } + | { type: "method.reset" } + | { type: "auth.pending" } + | { type: "auth.complete"; authorization: ProviderAuthAuthorization } + | { type: "auth.error"; error: string } + + function dispatch(action: Action) { + setStore( + produce((draft) => { + if (action.type === "method.select") { + draft.methodIndex = action.index + draft.authorization = undefined + draft.state = undefined + draft.error = undefined + return + } + if (action.type === "method.reset") { + draft.methodIndex = undefined + draft.authorization = undefined + draft.state = undefined + draft.error = undefined + return + } + if (action.type === "auth.pending") { + draft.state = "pending" + draft.error = undefined + return + } + if (action.type === "auth.complete") { + draft.state = "complete" + draft.authorization = action.authorization + draft.error = undefined + return + } + draft.state = "error" + draft.error = action.error + }), + ) + } + const method = createMemo(() => (store.methodIndex !== undefined ? methods().at(store.methodIndex!) : undefined)) const methodLabel = (value?: { type?: string; label?: string }) => { @@ -70,17 +110,10 @@ export function DialogConnectProvider(props: { provider: string }) { } const method = methods()[index] - setStore( - produce((draft) => { - draft.methodIndex = index - draft.authorization = undefined - draft.state = undefined - draft.error = undefined - }), - ) + dispatch({ type: "method.select", index }) if (method.type === "oauth") { - setStore("state", "pending") + dispatch({ type: "auth.pending" }) const start = Date.now() await globalSDK.client.provider.oauth .authorize( @@ -100,18 +133,15 @@ export function DialogConnectProvider(props: { provider: string }) { timer.current = setTimeout(() => { timer.current = undefined if (!alive.value) return - setStore("state", "complete") - setStore("authorization", x.data!) + dispatch({ type: "auth.complete", authorization: x.data! }) }, delay) return } - setStore("state", "complete") - setStore("authorization", x.data!) + dispatch({ type: "auth.complete", authorization: x.data! }) }) .catch((e) => { if (!alive.value) return - setStore("state", "error") - setStore("error", String(e)) + dispatch({ type: "auth.error", error: String(e) }) }) } } @@ -129,10 +159,6 @@ export function DialogConnectProvider(props: { provider: string }) { if (methods().length === 1) { selectMethod(0) } - document.addEventListener("keydown", handleKey) - onCleanup(() => { - document.removeEventListener("keydown", handleKey) - }) }) async function complete() { @@ -152,17 +178,244 @@ export function DialogConnectProvider(props: { provider: string }) { return } if (store.authorization) { - setStore("authorization", undefined) - setStore("methodIndex", undefined) + dispatch({ type: "method.reset" }) return } - if (store.methodIndex) { - setStore("methodIndex", undefined) + if (store.methodIndex !== undefined) { + dispatch({ type: "method.reset" }) return } dialog.show(() => <DialogSelectProvider />) } + function MethodSelection() { + return ( + <> + <div class="text-14-regular text-text-base"> + {language.t("provider.connect.selectMethod", { provider: provider().name })} + </div> + <div> + <List + ref={(ref) => { + listRef = ref + }} + items={methods} + key={(m) => m?.label} + onSelect={async (selected, index) => { + if (!selected) return + selectMethod(index) + }} + > + {(i) => ( + <div class="w-full flex items-center gap-x-2"> + <div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center"> + <div class="w-2.5 h-0.5 ml-0 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" /> + </div> + <span>{methodLabel(i)}</span> + </div> + )} + </List> + </div> + </> + ) + } + + function ApiAuthView() { + const [formStore, setFormStore] = createStore({ + value: "", + error: undefined as string | undefined, + }) + + async function handleSubmit(e: SubmitEvent) { + e.preventDefault() + + const form = e.currentTarget as HTMLFormElement + const formData = new FormData(form) + const apiKey = formData.get("apiKey") as string + + if (!apiKey?.trim()) { + setFormStore("error", language.t("provider.connect.apiKey.required")) + return + } + + setFormStore("error", undefined) + await globalSDK.client.auth.set({ + providerID: props.provider, + auth: { + type: "api", + key: apiKey, + }, + }) + await complete() + } + + return ( + <div class="flex flex-col gap-6"> + <Switch> + <Match when={provider().id === "opencode"}> + <div class="flex flex-col gap-4"> + <div class="text-14-regular text-text-base">{language.t("provider.connect.opencodeZen.line1")}</div> + <div class="text-14-regular text-text-base">{language.t("provider.connect.opencodeZen.line2")}</div> + <div class="text-14-regular text-text-base"> + {language.t("provider.connect.opencodeZen.visit.prefix")} + <Link href="https://opencode.ai/zen" tabIndex={-1}> + {language.t("provider.connect.opencodeZen.visit.link")} + </Link> + {language.t("provider.connect.opencodeZen.visit.suffix")} + </div> + </div> + </Match> + <Match when={true}> + <div class="text-14-regular text-text-base"> + {language.t("provider.connect.apiKey.description", { provider: provider().name })} + </div> + </Match> + </Switch> + <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4"> + <TextField + autofocus + type="text" + label={language.t("provider.connect.apiKey.label", { provider: provider().name })} + placeholder={language.t("provider.connect.apiKey.placeholder")} + name="apiKey" + value={formStore.value} + onChange={(v) => setFormStore("value", v)} + validationState={formStore.error ? "invalid" : undefined} + error={formStore.error} + /> + <Button class="w-auto" type="submit" size="large" variant="primary"> + {language.t("common.submit")} + </Button> + </form> + </div> + ) + } + + function OAuthCodeView() { + const [formStore, setFormStore] = createStore({ + value: "", + error: undefined as string | undefined, + }) + + onMount(() => { + if (store.authorization?.method === "code" && store.authorization?.url) { + platform.openLink(store.authorization.url) + } + }) + + async function handleSubmit(e: SubmitEvent) { + e.preventDefault() + + const form = e.currentTarget as HTMLFormElement + const formData = new FormData(form) + const code = formData.get("code") as string + + if (!code?.trim()) { + setFormStore("error", language.t("provider.connect.oauth.code.required")) + return + } + + setFormStore("error", undefined) + const result = await globalSDK.client.provider.oauth + .callback({ + providerID: props.provider, + method: store.methodIndex, + code, + }) + .then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const })) + .catch((error) => ({ ok: false as const, error })) + if (result.ok) { + await complete() + return + } + const message = result.error instanceof Error ? result.error.message : String(result.error) + setFormStore("error", message || language.t("provider.connect.oauth.code.invalid")) + } + + return ( + <div class="flex flex-col gap-6"> + <div class="text-14-regular text-text-base"> + {language.t("provider.connect.oauth.code.visit.prefix")} + <Link href={store.authorization!.url}>{language.t("provider.connect.oauth.code.visit.link")}</Link> + {language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })} + </div> + <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4"> + <TextField + autofocus + type="text" + label={language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })} + placeholder={language.t("provider.connect.oauth.code.placeholder")} + name="code" + value={formStore.value} + onChange={(v) => setFormStore("value", v)} + validationState={formStore.error ? "invalid" : undefined} + error={formStore.error} + /> + <Button class="w-auto" type="submit" size="large" variant="primary"> + {language.t("common.submit")} + </Button> + </form> + </div> + ) + } + + function OAuthAutoView() { + const code = createMemo(() => { + const instructions = store.authorization?.instructions + if (instructions?.includes(":")) { + return instructions.split(":")[1]?.trim() + } + return instructions + }) + + onMount(() => { + void (async () => { + if (store.authorization?.url) { + platform.openLink(store.authorization.url) + } + + const result = await globalSDK.client.provider.oauth + .callback({ + providerID: props.provider, + method: store.methodIndex, + }) + .then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const })) + .catch((error) => ({ ok: false as const, error })) + + if (!alive.value) return + + if (!result.ok) { + const message = result.error instanceof Error ? result.error.message : String(result.error) + dispatch({ type: "auth.error", error: message }) + return + } + + await complete() + })() + }) + + return ( + <div class="flex flex-col gap-6"> + <div class="text-14-regular text-text-base"> + {language.t("provider.connect.oauth.auto.visit.prefix")} + <Link href={store.authorization!.url}>{language.t("provider.connect.oauth.auto.visit.link")}</Link> + {language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })} + </div> + <TextField + label={language.t("provider.connect.oauth.auto.confirmationCode")} + class="font-mono" + value={code()} + readOnly + copyable + /> + <div class="text-14-regular text-text-base flex items-center gap-4"> + <Spinner /> + <span>{language.t("provider.connect.status.waiting")}</span> + </div> + </div> + ) + } + return ( <Dialog title={ @@ -188,267 +441,42 @@ export function DialogConnectProvider(props: { provider: string }) { </div> </div> <div class="px-2.5 pb-10 flex flex-col gap-6"> - <Switch> - <Match when={store.methodIndex === undefined}> - <div class="text-14-regular text-text-base"> - {language.t("provider.connect.selectMethod", { provider: provider().name })} - </div> - <div class=""> - <List - ref={(ref) => { - listRef = ref - }} - items={methods} - key={(m) => m?.label} - onSelect={async (method, index) => { - if (!method) return - selectMethod(index) - }} - > - {(i) => ( - <div class="w-full flex items-center gap-x-2"> - <div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center"> - <div class="w-2.5 h-0.5 ml-0 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" /> - </div> - <span>{methodLabel(i)}</span> - </div> - )} - </List> - </div> - </Match> - <Match when={store.state === "pending"}> - <div class="text-14-regular text-text-base"> - <div class="flex items-center gap-x-2"> - <Spinner /> - <span>{language.t("provider.connect.status.inProgress")}</span> - </div> - </div> - </Match> - <Match when={store.state === "error"}> - <div class="text-14-regular text-text-base"> - <div class="flex items-center gap-x-2"> - <Icon name="circle-ban-sign" class="text-icon-critical-base" /> - <span>{language.t("provider.connect.status.failed", { error: store.error ?? "" })}</span> + <div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}> + <Switch> + <Match when={store.methodIndex === undefined}> + <MethodSelection /> + </Match> + <Match when={store.state === "pending"}> + <div class="text-14-regular text-text-base"> + <div class="flex items-center gap-x-2"> + <Spinner /> + <span>{language.t("provider.connect.status.inProgress")}</span> + </div> </div> - </div> - </Match> - <Match when={method()?.type === "api"}> - {iife(() => { - const [formStore, setFormStore] = createStore({ - value: "", - error: undefined as string | undefined, - }) - - async function handleSubmit(e: SubmitEvent) { - e.preventDefault() - - const form = e.currentTarget as HTMLFormElement - const formData = new FormData(form) - const apiKey = formData.get("apiKey") as string - - if (!apiKey?.trim()) { - setFormStore("error", language.t("provider.connect.apiKey.required")) - return - } - - setFormStore("error", undefined) - await globalSDK.client.auth.set({ - providerID: props.provider, - auth: { - type: "api", - key: apiKey, - }, - }) - await complete() - } - - return ( - <div class="flex flex-col gap-6"> - <Switch> - <Match when={provider().id === "opencode"}> - <div class="flex flex-col gap-4"> - <div class="text-14-regular text-text-base"> - {language.t("provider.connect.opencodeZen.line1")} - </div> - <div class="text-14-regular text-text-base"> - {language.t("provider.connect.opencodeZen.line2")} - </div> - <div class="text-14-regular text-text-base"> - {language.t("provider.connect.opencodeZen.visit.prefix")} - <Link href="https://opencode.ai/zen" tabIndex={-1}> - {language.t("provider.connect.opencodeZen.visit.link")} - </Link> - {language.t("provider.connect.opencodeZen.visit.suffix")} - </div> - </div> - </Match> - <Match when={true}> - <div class="text-14-regular text-text-base"> - {language.t("provider.connect.apiKey.description", { provider: provider().name })} - </div> - </Match> - </Switch> - <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4"> - <TextField - autofocus - type="text" - label={language.t("provider.connect.apiKey.label", { provider: provider().name })} - placeholder={language.t("provider.connect.apiKey.placeholder")} - name="apiKey" - value={formStore.value} - onChange={setFormStore.bind(null, "value")} - validationState={formStore.error ? "invalid" : undefined} - error={formStore.error} - /> - <Button class="w-auto" type="submit" size="large" variant="primary"> - {language.t("common.submit")} - </Button> - </form> + </Match> + <Match when={store.state === "error"}> + <div class="text-14-regular text-text-base"> + <div class="flex items-center gap-x-2"> + <Icon name="circle-ban-sign" class="text-icon-critical-base" /> + <span>{language.t("provider.connect.status.failed", { error: store.error ?? "" })}</span> </div> - ) - })} - </Match> - <Match when={method()?.type === "oauth"}> - <Switch> - <Match when={store.authorization?.method === "code"}> - {iife(() => { - const [formStore, setFormStore] = createStore({ - value: "", - error: undefined as string | undefined, - }) - - onMount(() => { - if (store.authorization?.method === "code" && store.authorization?.url) { - platform.openLink(store.authorization.url) - } - }) - - async function handleSubmit(e: SubmitEvent) { - e.preventDefault() - - const form = e.currentTarget as HTMLFormElement - const formData = new FormData(form) - const code = formData.get("code") as string - - if (!code?.trim()) { - setFormStore("error", language.t("provider.connect.oauth.code.required")) - return - } - - setFormStore("error", undefined) - const result = await globalSDK.client.provider.oauth - .callback({ - providerID: props.provider, - method: store.methodIndex, - code, - }) - .then((value) => - value.error ? { ok: false as const, error: value.error } : { ok: true as const }, - ) - .catch((error) => ({ ok: false as const, error })) - if (result.ok) { - await complete() - return - } - const message = result.error instanceof Error ? result.error.message : String(result.error) - setFormStore("error", message || language.t("provider.connect.oauth.code.invalid")) - } - - return ( - <div class="flex flex-col gap-6"> - <div class="text-14-regular text-text-base"> - {language.t("provider.connect.oauth.code.visit.prefix")} - <Link href={store.authorization!.url}> - {language.t("provider.connect.oauth.code.visit.link")} - </Link> - {language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })} - </div> - <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4"> - <TextField - autofocus - type="text" - label={language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })} - placeholder={language.t("provider.connect.oauth.code.placeholder")} - name="code" - value={formStore.value} - onChange={setFormStore.bind(null, "value")} - validationState={formStore.error ? "invalid" : undefined} - error={formStore.error} - /> - <Button class="w-auto" type="submit" size="large" variant="primary"> - {language.t("common.submit")} - </Button> - </form> - </div> - ) - })} - </Match> - <Match when={store.authorization?.method === "auto"}> - {iife(() => { - const code = createMemo(() => { - const instructions = store.authorization?.instructions - if (instructions?.includes(":")) { - return instructions?.split(":")[1]?.trim() - } - return instructions - }) - - onMount(() => { - void (async () => { - if (store.authorization?.url) { - platform.openLink(store.authorization.url) - } - - const result = await globalSDK.client.provider.oauth - .callback({ - providerID: props.provider, - method: store.methodIndex, - }) - .then((value) => - value.error ? { ok: false as const, error: value.error } : { ok: true as const }, - ) - .catch((error) => ({ ok: false as const, error })) - - if (!alive.value) return - - if (!result.ok) { - const message = result.error instanceof Error ? result.error.message : String(result.error) - setStore("state", "error") - setStore("error", message) - return - } - - await complete() - })() - }) - - return ( - <div class="flex flex-col gap-6"> - <div class="text-14-regular text-text-base"> - {language.t("provider.connect.oauth.auto.visit.prefix")} - <Link href={store.authorization!.url}> - {language.t("provider.connect.oauth.auto.visit.link")} - </Link> - {language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })} - </div> - <TextField - label={language.t("provider.connect.oauth.auto.confirmationCode")} - class="font-mono" - value={code()} - readOnly - copyable - /> - <div class="text-14-regular text-text-base flex items-center gap-4"> - <Spinner /> - <span>{language.t("provider.connect.status.waiting")}</span> - </div> - </div> - ) - })} - </Match> - </Switch> - </Match> - </Switch> + </div> + </Match> + <Match when={method()?.type === "api"}> + <ApiAuthView /> + </Match> + <Match when={method()?.type === "oauth"}> + <Switch> + <Match when={store.authorization?.method === "code"}> + <OAuthCodeView /> + </Match> + <Match when={store.authorization?.method === "auto"}> + <OAuthAutoView /> + </Match> + </Switch> + </Match> + </Switch> + </div> </div> </div> </Dialog> diff --git a/packages/app/src/components/dialog-custom-provider.tsx b/packages/app/src/components/dialog-custom-provider.tsx index 53773ed9e..017b85a2c 100644 --- a/packages/app/src/components/dialog-custom-provider.tsx +++ b/packages/app/src/components/dialog-custom-provider.tsx @@ -6,7 +6,7 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { TextField } from "@opencode-ai/ui/text-field" import { showToast } from "@opencode-ai/ui/toast" import { For } from "solid-js" -import { createStore, produce } from "solid-js/store" +import { createStore } from "solid-js/store" import { Link } from "@/components/link" import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSync } from "@/context/global-sync" @@ -16,6 +16,147 @@ import { DialogSelectProvider } from "./dialog-select-provider" const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/ const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible" +type Translator = ReturnType<typeof useLanguage>["t"] + +type ModelRow = { + id: string + name: string +} + +type HeaderRow = { + key: string + value: string +} + +type FormState = { + providerID: string + name: string + baseURL: string + apiKey: string + models: ModelRow[] + headers: HeaderRow[] + saving: boolean +} + +type FormErrors = { + providerID: string | undefined + name: string | undefined + baseURL: string | undefined + models: Array<{ id?: string; name?: string }> + headers: Array<{ key?: string; value?: string }> +} + +type ValidateArgs = { + form: FormState + t: Translator + disabledProviders: string[] + existingProviderIDs: Set<string> +} + +function validateCustomProvider(input: ValidateArgs) { + const providerID = input.form.providerID.trim() + const name = input.form.name.trim() + const baseURL = input.form.baseURL.trim() + const apiKey = input.form.apiKey.trim() + + const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim() + const key = apiKey && !env ? apiKey : undefined + + const idError = !providerID + ? input.t("provider.custom.error.providerID.required") + : !PROVIDER_ID.test(providerID) + ? input.t("provider.custom.error.providerID.format") + : undefined + + const nameError = !name ? input.t("provider.custom.error.name.required") : undefined + const urlError = !baseURL + ? input.t("provider.custom.error.baseURL.required") + : !/^https?:\/\//.test(baseURL) + ? input.t("provider.custom.error.baseURL.format") + : undefined + + const disabled = input.disabledProviders.includes(providerID) + const existsError = idError + ? undefined + : input.existingProviderIDs.has(providerID) && !disabled + ? input.t("provider.custom.error.providerID.exists") + : undefined + + const seenModels = new Set<string>() + const modelErrors = input.form.models.map((m) => { + const id = m.id.trim() + const modelIdError = !id + ? input.t("provider.custom.error.required") + : seenModels.has(id) + ? input.t("provider.custom.error.duplicate") + : (() => { + seenModels.add(id) + return undefined + })() + const modelNameError = !m.name.trim() ? input.t("provider.custom.error.required") : undefined + return { id: modelIdError, name: modelNameError } + }) + const modelsValid = modelErrors.every((m) => !m.id && !m.name) + const models = Object.fromEntries(input.form.models.map((m) => [m.id.trim(), { name: m.name.trim() }])) + + const seenHeaders = new Set<string>() + const headerErrors = input.form.headers.map((h) => { + const key = h.key.trim() + const value = h.value.trim() + + if (!key && !value) return {} + const keyError = !key + ? input.t("provider.custom.error.required") + : seenHeaders.has(key.toLowerCase()) + ? input.t("provider.custom.error.duplicate") + : (() => { + seenHeaders.add(key.toLowerCase()) + return undefined + })() + const valueError = !value ? input.t("provider.custom.error.required") : undefined + return { key: keyError, value: valueError } + }) + const headersValid = headerErrors.every((h) => !h.key && !h.value) + const headers = Object.fromEntries( + input.form.headers + .map((h) => ({ key: h.key.trim(), value: h.value.trim() })) + .filter((h) => !!h.key && !!h.value) + .map((h) => [h.key, h.value]), + ) + + const errors: FormErrors = { + providerID: idError ?? existsError, + name: nameError, + baseURL: urlError, + models: modelErrors, + headers: headerErrors, + } + + const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid + if (!ok) return { errors } + + const options = { + baseURL, + ...(Object.keys(headers).length ? { headers } : {}), + } + + return { + errors, + result: { + providerID, + name, + key, + config: { + npm: OPENAI_COMPATIBLE, + name, + ...(env ? { env: [env] } : {}), + options, + models, + }, + }, + } +} + type Props = { back?: "providers" | "close" } @@ -26,7 +167,7 @@ export function DialogCustomProvider(props: Props) { const globalSDK = useGlobalSDK() const language = useLanguage() - const [form, setForm] = createStore({ + const [form, setForm] = createStore<FormState>({ providerID: "", name: "", baseURL: "", @@ -36,12 +177,12 @@ export function DialogCustomProvider(props: Props) { saving: false, }) - const [errors, setErrors] = createStore({ - providerID: undefined as string | undefined, - name: undefined as string | undefined, - baseURL: undefined as string | undefined, - models: [{} as { id?: string; name?: string }], - headers: [{} as { key?: string; value?: string }], + const [errors, setErrors] = createStore<FormErrors>({ + providerID: undefined, + name: undefined, + baseURL: undefined, + models: [{}], + headers: [{}], }) const goBack = () => { @@ -53,169 +194,36 @@ export function DialogCustomProvider(props: Props) { } const addModel = () => { - setForm( - "models", - produce((draft) => { - draft.push({ id: "", name: "" }) - }), - ) - setErrors( - "models", - produce((draft) => { - draft.push({}) - }), - ) + setForm("models", (v) => [...v, { id: "", name: "" }]) + setErrors("models", (v) => [...v, {}]) } const removeModel = (index: number) => { if (form.models.length <= 1) return - setForm( - "models", - produce((draft) => { - draft.splice(index, 1) - }), - ) - setErrors( - "models", - produce((draft) => { - draft.splice(index, 1) - }), - ) + setForm("models", (v) => v.filter((_, i) => i !== index)) + setErrors("models", (v) => v.filter((_, i) => i !== index)) } const addHeader = () => { - setForm( - "headers", - produce((draft) => { - draft.push({ key: "", value: "" }) - }), - ) - setErrors( - "headers", - produce((draft) => { - draft.push({}) - }), - ) + setForm("headers", (v) => [...v, { key: "", value: "" }]) + setErrors("headers", (v) => [...v, {}]) } const removeHeader = (index: number) => { if (form.headers.length <= 1) return - setForm( - "headers", - produce((draft) => { - draft.splice(index, 1) - }), - ) - setErrors( - "headers", - produce((draft) => { - draft.splice(index, 1) - }), - ) + setForm("headers", (v) => v.filter((_, i) => i !== index)) + setErrors("headers", (v) => v.filter((_, i) => i !== index)) } const validate = () => { - const providerID = form.providerID.trim() - const name = form.name.trim() - const baseURL = form.baseURL.trim() - const apiKey = form.apiKey.trim() - - const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim() - const key = apiKey && !env ? apiKey : undefined - - const idError = !providerID - ? language.t("provider.custom.error.providerID.required") - : !PROVIDER_ID.test(providerID) - ? language.t("provider.custom.error.providerID.format") - : undefined - - const nameError = !name ? language.t("provider.custom.error.name.required") : undefined - const urlError = !baseURL - ? language.t("provider.custom.error.baseURL.required") - : !/^https?:\/\//.test(baseURL) - ? language.t("provider.custom.error.baseURL.format") - : undefined - - const disabled = (globalSync.data.config.disabled_providers ?? []).includes(providerID) - const existingProvider = globalSync.data.provider.all.find((p) => p.id === providerID) - const existsError = idError - ? undefined - : existingProvider && !disabled - ? language.t("provider.custom.error.providerID.exists") - : undefined - - const seenModels = new Set<string>() - const modelErrors = form.models.map((m) => { - const id = m.id.trim() - const modelIdError = !id - ? language.t("provider.custom.error.required") - : seenModels.has(id) - ? language.t("provider.custom.error.duplicate") - : (() => { - seenModels.add(id) - return undefined - })() - const modelNameError = !m.name.trim() ? language.t("provider.custom.error.required") : undefined - return { id: modelIdError, name: modelNameError } + const output = validateCustomProvider({ + form, + t: language.t, + disabledProviders: globalSync.data.config.disabled_providers ?? [], + existingProviderIDs: new Set(globalSync.data.provider.all.map((p) => p.id)), }) - const modelsValid = modelErrors.every((m) => !m.id && !m.name) - const models = Object.fromEntries(form.models.map((m) => [m.id.trim(), { name: m.name.trim() }])) - - const seenHeaders = new Set<string>() - const headerErrors = form.headers.map((h) => { - const key = h.key.trim() - const value = h.value.trim() - - if (!key && !value) return {} - const keyError = !key - ? language.t("provider.custom.error.required") - : seenHeaders.has(key.toLowerCase()) - ? language.t("provider.custom.error.duplicate") - : (() => { - seenHeaders.add(key.toLowerCase()) - return undefined - })() - const valueError = !value ? language.t("provider.custom.error.required") : undefined - return { key: keyError, value: valueError } - }) - const headersValid = headerErrors.every((h) => !h.key && !h.value) - const headers = Object.fromEntries( - form.headers - .map((h) => ({ key: h.key.trim(), value: h.value.trim() })) - .filter((h) => !!h.key && !!h.value) - .map((h) => [h.key, h.value]), - ) - - setErrors( - produce((draft) => { - draft.providerID = idError ?? existsError - draft.name = nameError - draft.baseURL = urlError - draft.models = modelErrors - draft.headers = headerErrors - }), - ) - - const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid - if (!ok) return - - const options = { - baseURL, - ...(Object.keys(headers).length ? { headers } : {}), - } - - return { - providerID, - name, - key, - config: { - npm: OPENAI_COMPATIBLE, - name, - ...(env ? { env: [env] } : {}), - options, - models, - }, - } + setErrors(output.errors) + return output.result } const save = async (e: SubmitEvent) => { @@ -297,7 +305,7 @@ export function DialogCustomProvider(props: Props) { placeholder={language.t("provider.custom.field.providerID.placeholder")} description={language.t("provider.custom.field.providerID.description")} value={form.providerID} - onChange={setForm.bind(null, "providerID")} + onChange={(v) => setForm("providerID", v)} validationState={errors.providerID ? "invalid" : undefined} error={errors.providerID} /> @@ -305,7 +313,7 @@ export function DialogCustomProvider(props: Props) { label={language.t("provider.custom.field.name.label")} placeholder={language.t("provider.custom.field.name.placeholder")} value={form.name} - onChange={setForm.bind(null, "name")} + onChange={(v) => setForm("name", v)} validationState={errors.name ? "invalid" : undefined} error={errors.name} /> @@ -313,7 +321,7 @@ export function DialogCustomProvider(props: Props) { label={language.t("provider.custom.field.baseURL.label")} placeholder={language.t("provider.custom.field.baseURL.placeholder")} value={form.baseURL} - onChange={setForm.bind(null, "baseURL")} + onChange={(v) => setForm("baseURL", v)} validationState={errors.baseURL ? "invalid" : undefined} error={errors.baseURL} /> @@ -322,7 +330,7 @@ export function DialogCustomProvider(props: Props) { placeholder={language.t("provider.custom.field.apiKey.placeholder")} description={language.t("provider.custom.field.apiKey.description")} value={form.apiKey} - onChange={setForm.bind(null, "apiKey")} + onChange={(v) => setForm("apiKey", v)} /> </div> diff --git a/packages/app/src/components/dialog-edit-project.tsx b/packages/app/src/components/dialog-edit-project.tsx index dbad81798..ec0793c54 100644 --- a/packages/app/src/components/dialog-edit-project.tsx +++ b/packages/app/src/components/dialog-edit-project.tsx @@ -33,6 +33,8 @@ export function DialogEditProject(props: { project: LocalProject }) { iconHover: false, }) + let iconInput: HTMLInputElement | undefined + function handleFileSelect(file: File) { if (!file.type.startsWith("image/")) return const reader = new FileReader() @@ -72,31 +74,35 @@ export function DialogEditProject(props: { project: LocalProject }) { async function handleSubmit(e: SubmitEvent) { e.preventDefault() - setStore("saving", true) - const name = store.name.trim() === folderName() ? "" : store.name.trim() - const start = store.startup.trim() - - if (props.project.id && props.project.id !== "global") { - await globalSDK.client.project.update({ - projectID: props.project.id, - directory: props.project.worktree, - name, - icon: { color: store.color, override: store.iconUrl }, - commands: { start }, - }) - globalSync.project.icon(props.project.worktree, store.iconUrl || undefined) - setStore("saving", false) - dialog.close() - return - } + await Promise.resolve() + .then(async () => { + setStore("saving", true) + const name = store.name.trim() === folderName() ? "" : store.name.trim() + const start = store.startup.trim() - globalSync.project.meta(props.project.worktree, { - name, - icon: { color: store.color, override: store.iconUrl || undefined }, - commands: { start: start || undefined }, - }) - setStore("saving", false) - dialog.close() + if (props.project.id && props.project.id !== "global") { + await globalSDK.client.project.update({ + projectID: props.project.id, + directory: props.project.worktree, + name, + icon: { color: store.color, override: store.iconUrl }, + commands: { start }, + }) + globalSync.project.icon(props.project.worktree, store.iconUrl || undefined) + dialog.close() + return + } + + globalSync.project.meta(props.project.worktree, { + name, + icon: { color: store.color, override: store.iconUrl || undefined }, + commands: { start: start || undefined }, + }) + dialog.close() + }) + .finally(() => { + setStore("saving", false) + }) } return ( @@ -134,7 +140,7 @@ export function DialogEditProject(props: { project: LocalProject }) { if (store.iconUrl && store.iconHover) { clearIcon() } else { - document.getElementById("icon-upload")?.click() + iconInput?.click() } }} > @@ -176,7 +182,16 @@ export function DialogEditProject(props: { project: LocalProject }) { <Icon name="trash" size="large" class="text-icon-on-interactive-base drop-shadow-sm" /> </div> </div> - <input id="icon-upload" type="file" accept="image/*" class="hidden" onChange={handleInputChange} /> + <input + id="icon-upload" + ref={(el) => { + iconInput = el + }} + type="file" + accept="image/*" + class="hidden" + onChange={handleInputChange} + /> <div class="flex flex-col gap-1.5 text-12-regular text-text-weak self-center"> <span>{language.t("dialog.project.edit.icon.hint")}</span> <span>{language.t("dialog.project.edit.icon.recommended")}</span> diff --git a/packages/app/src/components/dialog-fork.tsx b/packages/app/src/components/dialog-fork.tsx index 09d62021f..8810955cc 100644 --- a/packages/app/src/components/dialog-fork.tsx +++ b/packages/app/src/components/dialog-fork.tsx @@ -6,6 +6,7 @@ import { usePrompt } from "@/context/prompt" import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" import { List } from "@opencode-ai/ui/list" +import { showToast } from "@opencode-ai/ui/toast" import { extractPromptFromParts } from "@/utils/prompt" import type { TextPart as SDKTextPart } from "@opencode-ai/sdk/v2/client" import { base64Encode } from "@opencode-ai/util/encode" @@ -66,15 +67,23 @@ export const DialogFork: Component = () => { attachmentName: language.t("common.attachment"), }) - dialog.close() - - sdk.client.session.fork({ sessionID, messageID: item.id }).then((forked) => { - if (!forked.data) return - navigate(`/${base64Encode(sdk.directory)}/session/${forked.data.id}`) - requestAnimationFrame(() => { - prompt.set(restored) + sdk.client.session + .fork({ sessionID, messageID: item.id }) + .then((forked) => { + if (!forked.data) { + showToast({ title: language.t("common.requestFailed") }) + return + } + dialog.close() + navigate(`/${base64Encode(sdk.directory)}/session/${forked.data.id}`) + requestAnimationFrame(() => { + prompt.set(restored) + }) + }) + .catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err) + showToast({ title: language.t("common.requestFailed"), description: message }) }) - }) } return ( diff --git a/packages/app/src/components/dialog-manage-models.tsx b/packages/app/src/components/dialog-manage-models.tsx index 9ee48736c..d4d4af0f1 100644 --- a/packages/app/src/components/dialog-manage-models.tsx +++ b/packages/app/src/components/dialog-manage-models.tsx @@ -17,6 +17,7 @@ export const DialogManageModels: Component = () => { const handleConnectProvider = () => { dialog.show(() => <DialogSelectProvider />) } + const providerRank = (id: string) => popularProviders.indexOf(id) return ( <Dialog @@ -37,19 +38,18 @@ export const DialogManageModels: Component = () => { sortBy={(a, b) => a.name.localeCompare(b.name)} groupBy={(x) => x.provider.name} sortGroupsBy={(a, b) => { - const aProvider = a.items[0].provider.id - const bProvider = b.items[0].provider.id - if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1 - if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1 - return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider) + const aRank = providerRank(a.items[0].provider.id) + const bRank = providerRank(b.items[0].provider.id) + const aPopular = aRank >= 0 + const bPopular = bRank >= 0 + if (aPopular && !bPopular) return -1 + if (!aPopular && bPopular) return 1 + return aRank - bRank }} onSelect={(x) => { if (!x) return - const visible = local.model.visible({ - modelID: x.id, - providerID: x.provider.id, - }) - local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, !visible) + const key = { modelID: x.id, providerID: x.provider.id } + local.model.setVisibility(key, !local.model.visible(key)) }} > {(i) => ( @@ -57,12 +57,7 @@ export const DialogManageModels: Component = () => { <span>{i.name}</span> <div onClick={(e) => e.stopPropagation()}> <Switch - checked={ - !!local.model.visible({ - modelID: i.id, - providerID: i.provider.id, - }) - } + checked={!!local.model.visible({ modelID: i.id, providerID: i.provider.id })} onChange={(checked) => { local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked) }} diff --git a/packages/app/src/components/dialog-release-notes.tsx b/packages/app/src/components/dialog-release-notes.tsx index c6f2f3930..2040009a8 100644 --- a/packages/app/src/components/dialog-release-notes.tsx +++ b/packages/app/src/components/dialog-release-notes.tsx @@ -1,4 +1,4 @@ -import { createSignal, createEffect, onMount, onCleanup } from "solid-js" +import { createSignal } from "solid-js" import { Dialog } from "@opencode-ai/ui/dialog" import { Button } from "@opencode-ai/ui/button" import { useDialog } from "@opencode-ai/ui/context/dialog" @@ -40,8 +40,6 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) { handleClose() } - let focusTrap: HTMLDivElement | undefined - function handleKeyDown(e: KeyboardEvent) { if (e.key === "Escape") { e.preventDefault() @@ -60,27 +58,13 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) { } } - onMount(() => { - focusTrap?.focus() - document.addEventListener("keydown", handleKeyDown) - onCleanup(() => document.removeEventListener("keydown", handleKeyDown)) - }) - - // Refocus the trap when index changes to ensure escape always works - createEffect(() => { - index() // track index - focusTrap?.focus() - }) - return ( <Dialog size="large" fit class="w-[min(calc(100vw-40px),720px)] h-[min(calc(100vh-40px),400px)] -mt-20 min-h-0 overflow-hidden" > - {/* Hidden element to capture initial focus and handle escape */} - <div ref={focusTrap} tabindex="0" class="absolute opacity-0 pointer-events-none" /> - <div class="flex flex-1 min-w-0 min-h-0"> + <div class="flex flex-1 min-w-0 min-h-0" tabIndex={0} autofocus onKeyDown={handleKeyDown}> {/* Left side - Text content */} <div class="flex flex-col flex-1 min-w-0 p-8"> {/* Top section - feature content (fixed position from top) */} diff --git a/packages/app/src/components/dialog-select-directory.tsx b/packages/app/src/components/dialog-select-directory.tsx index 6e7af3d90..515e640c9 100644 --- a/packages/app/src/components/dialog-select-directory.tsx +++ b/packages/app/src/components/dialog-select-directory.tsx @@ -2,13 +2,13 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" import { FileIcon } from "@opencode-ai/ui/file-icon" import { List } from "@opencode-ai/ui/list" +import type { ListRef } from "@opencode-ai/ui/list" import { getDirectory, getFilename } from "@opencode-ai/util/path" import fuzzysort from "fuzzysort" import { createMemo, createResource, createSignal } from "solid-js" import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSync } from "@/context/global-sync" import { useLanguage } from "@/context/language" -import type { ListRef } from "@opencode-ai/ui/list" interface DialogSelectDirectoryProps { title?: string @@ -21,157 +21,131 @@ type Row = { search: string } -export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { - const sync = useGlobalSync() - const sdk = useGlobalSDK() - const dialog = useDialog() - const language = useLanguage() - - const [filter, setFilter] = createSignal("") - - let list: ListRef | undefined - - const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory)) - - const [fallbackPath] = createResource( - () => (missingBase() ? true : undefined), - async () => { - return sdk.client.path - .get() - .then((x) => x.data) - .catch(() => undefined) - }, - { initialValue: undefined }, - ) - - const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "") - - const start = createMemo( - () => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory, - ) - - const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>() +function cleanInput(value: string) { + const first = (value ?? "").split(/\r?\n/)[0] ?? "" + return first.replace(/[\u0000-\u001F\u007F]/g, "").trim() +} - const clean = (value: string) => { - const first = (value ?? "").split(/\r?\n/)[0] ?? "" - return first.replace(/[\u0000-\u001F\u007F]/g, "").trim() - } +function normalizePath(input: string) { + const v = input.replaceAll("\\", "/") + if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/") + return v.replace(/\/+/g, "/") +} - function normalize(input: string) { - const v = input.replaceAll("\\", "/") - if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/") - return v.replace(/\/+/g, "/") - } +function normalizeDriveRoot(input: string) { + const v = normalizePath(input) + if (/^[A-Za-z]:$/.test(v)) return v + "/" + return v +} - function normalizeDriveRoot(input: string) { - const v = normalize(input) - if (/^[A-Za-z]:$/.test(v)) return v + "/" - return v - } +function trimTrailing(input: string) { + const v = normalizeDriveRoot(input) + if (v === "/") return v + if (v === "//") return v + if (/^[A-Za-z]:\/$/.test(v)) return v + return v.replace(/\/+$/, "") +} - function trimTrailing(input: string) { - const v = normalizeDriveRoot(input) - if (v === "/") return v - if (v === "//") return v - if (/^[A-Za-z]:\/$/.test(v)) return v - return v.replace(/\/+$/, "") - } +function joinPath(base: string | undefined, rel: string) { + const b = trimTrailing(base ?? "") + const r = trimTrailing(rel).replace(/^\/+/, "") + if (!b) return r + if (!r) return b + if (b.endsWith("/")) return b + r + return b + "/" + r +} - function join(base: string | undefined, rel: string) { - const b = trimTrailing(base ?? "") - const r = trimTrailing(rel).replace(/^\/+/, "") - if (!b) return r - if (!r) return b - if (b.endsWith("/")) return b + r - return b + "/" + r - } +function rootOf(input: string) { + const v = normalizeDriveRoot(input) + if (v.startsWith("//")) return "//" + if (v.startsWith("/")) return "/" + if (/^[A-Za-z]:\//.test(v)) return v.slice(0, 3) + return "" +} - function rootOf(input: string) { - const v = normalizeDriveRoot(input) - if (v.startsWith("//")) return "//" - if (v.startsWith("/")) return "/" - if (/^[A-Za-z]:\//.test(v)) return v.slice(0, 3) - return "" - } +function parentOf(input: string) { + const v = trimTrailing(input) + if (v === "/") return v + if (v === "//") return v + if (/^[A-Za-z]:\/$/.test(v)) return v - function parentOf(input: string) { - const v = trimTrailing(input) - if (v === "/") return v - if (v === "//") return v - if (/^[A-Za-z]:\/$/.test(v)) return v + const i = v.lastIndexOf("/") + if (i <= 0) return "/" + if (i === 2 && /^[A-Za-z]:/.test(v)) return v.slice(0, 3) + return v.slice(0, i) +} - const i = v.lastIndexOf("/") - if (i <= 0) return "/" - if (i === 2 && /^[A-Za-z]:/.test(v)) return v.slice(0, 3) - return v.slice(0, i) - } +function modeOf(input: string) { + const raw = normalizeDriveRoot(input.trim()) + if (!raw) return "relative" as const + if (raw.startsWith("~")) return "tilde" as const + if (rootOf(raw)) return "absolute" as const + return "relative" as const +} - function modeOf(input: string) { - const raw = normalizeDriveRoot(input.trim()) - if (!raw) return "relative" as const - if (raw.startsWith("~")) return "tilde" as const - if (rootOf(raw)) return "absolute" as const - return "relative" as const - } +function tildeOf(absolute: string, home: string) { + const full = trimTrailing(absolute) + if (!home) return "" - function display(path: string, input: string) { - const full = trimTrailing(path) - if (modeOf(input) === "absolute") return full + const hn = trimTrailing(home) + const lc = full.toLowerCase() + const hc = hn.toLowerCase() + if (lc === hc) return "~" + if (lc.startsWith(hc + "/")) return "~" + full.slice(hn.length) + return "" +} - return tildeOf(full) || full - } +function displayPath(path: string, input: string, home: string) { + const full = trimTrailing(path) + if (modeOf(input) === "absolute") return full + return tildeOf(full, home) || full +} - function tildeOf(absolute: string) { - const full = trimTrailing(absolute) - const h = home() - if (!h) return "" - - const hn = trimTrailing(h) - const lc = full.toLowerCase() - const hc = hn.toLowerCase() - if (lc === hc) return "~" - if (lc.startsWith(hc + "/")) return "~" + full.slice(hn.length) - return "" +function toRow(absolute: string, home: string): Row { + const full = trimTrailing(absolute) + const tilde = tildeOf(full, home) + const withSlash = (value: string) => { + if (!value) return "" + if (value.endsWith("/")) return value + return value + "/" } - function row(absolute: string): Row { - const full = trimTrailing(absolute) - const tilde = tildeOf(full) - - const withSlash = (value: string) => { - if (!value) return "" - if (value.endsWith("/")) return value - return value + "/" - } + const search = Array.from( + new Set([full, withSlash(full), tilde, withSlash(tilde), getFilename(full)].filter(Boolean)), + ).join("\n") + return { absolute: full, search } +} - const search = Array.from( - new Set([full, withSlash(full), tilde, withSlash(tilde), getFilename(full)].filter(Boolean)), - ).join("\n") - return { absolute: full, search } - } +function useDirectorySearch(args: { + sdk: ReturnType<typeof useGlobalSDK> + start: () => string | undefined + home: () => string +}) { + const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>() + let current = 0 - function scoped(value: string) { - const base = start() + const scoped = (value: string) => { + const base = args.start() if (!base) return const raw = normalizeDriveRoot(value) if (!raw) return { directory: trimTrailing(base), path: "" } - const h = home() - if (raw === "~") return { directory: trimTrailing(h ?? base), path: "" } - if (raw.startsWith("~/")) return { directory: trimTrailing(h ?? base), path: raw.slice(2) } + const h = args.home() + if (raw === "~") return { directory: trimTrailing(h || base), path: "" } + if (raw.startsWith("~/")) return { directory: trimTrailing(h || base), path: raw.slice(2) } const root = rootOf(raw) if (root) return { directory: trimTrailing(root), path: raw.slice(root.length) } return { directory: trimTrailing(base), path: raw } } - async function dirs(dir: string) { + const dirs = async (dir: string) => { const key = trimTrailing(dir) const existing = cache.get(key) if (existing) return existing - const request = sdk.client.file + const request = args.sdk.client.file .list({ directory: key, path: "" }) .then((x) => x.data ?? []) .catch(() => []) @@ -188,32 +162,34 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { return request } - async function match(dir: string, query: string, limit: number) { + const match = async (dir: string, query: string, limit: number) => { const items = await dirs(dir) if (!query) return items.slice(0, limit).map((x) => x.absolute) return fuzzysort.go(query, items, { key: "name", limit }).map((x) => x.obj.absolute) } - const directories = async (filter: string) => { - const value = clean(filter) + return async (filter: string) => { + const token = ++current + const active = () => token === current + + const value = cleanInput(filter) const scopedInput = scoped(value) if (!scopedInput) return [] as string[] const raw = normalizeDriveRoot(value) const isPath = raw.startsWith("~") || !!rootOf(raw) || raw.includes("/") - const query = normalizeDriveRoot(scopedInput.path) const find = () => - sdk.client.find + args.sdk.client.find .files({ directory: scopedInput.directory, query, type: "directory", limit: 50 }) .then((x) => x.data ?? []) .catch(() => []) if (!isPath) { const results = await find() - - return results.map((rel) => join(scopedInput.directory, rel)).slice(0, 50) + if (!active()) return [] + return results.map((rel) => joinPath(scopedInput.directory, rel)).slice(0, 50) } const segments = query.replace(/^\/+/, "").split("/") @@ -224,17 +200,20 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { const branch = 4 let paths = [scopedInput.directory] for (const part of head) { + if (!active()) return [] if (part === "..") { paths = paths.map(parentOf) continue } const next = (await Promise.all(paths.map((p) => match(p, part, branch)))).flat() + if (!active()) return [] paths = Array.from(new Set(next)).slice(0, cap) if (paths.length === 0) return [] as string[] } const out = (await Promise.all(paths.map((p) => match(p, tail, 50)))).flat() + if (!active()) return [] const deduped = Array.from(new Set(out)) const base = raw.startsWith("~") ? trimTrailing(scopedInput.directory) : "" const expand = !raw.endsWith("/") @@ -249,13 +228,47 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { if (!target) return deduped.slice(0, 50) const children = await match(target, "", 30) + if (!active()) return [] const items = Array.from(new Set([...deduped, ...children])) return (base ? Array.from(new Set([base, ...items])) : items).slice(0, 50) } +} + +export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { + const sync = useGlobalSync() + const sdk = useGlobalSDK() + const dialog = useDialog() + const language = useLanguage() + + const [filter, setFilter] = createSignal("") + let list: ListRef | undefined + + const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory)) + const [fallbackPath] = createResource( + () => (missingBase() ? true : undefined), + async () => { + return sdk.client.path + .get() + .then((x) => x.data) + .catch(() => undefined) + }, + { initialValue: undefined }, + ) + + const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "") + const start = createMemo( + () => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory, + ) + + const directories = useDirectorySearch({ + sdk, + home, + start, + }) const items = async (value: string) => { const results = await directories(value) - return results.map(row) + return results.map((absolute) => toRow(absolute, home())) } function resolve(absolute: string) { @@ -273,7 +286,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { key={(x) => x.absolute} filterKeys={["search"]} ref={(r) => (list = r)} - onFilter={(value) => setFilter(clean(value))} + onFilter={(value) => setFilter(cleanInput(value))} onKeyEvent={(e, item) => { if (e.key !== "Tab") return if (e.shiftKey) return @@ -282,7 +295,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { e.preventDefault() e.stopPropagation() - const value = display(item.absolute, filter()) + const value = displayPath(item.absolute, filter(), home()) list?.setFilter(value.endsWith("/") ? value : value + "/") }} onSelect={(path) => { @@ -291,7 +304,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { }} > {(item) => { - const path = display(item.absolute, filter()) + const path = displayPath(item.absolute, filter(), home()) if (path === "~") { return ( <div class="w-full flex items-center justify-between rounded-md"> diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index 8e221577b..f35d0564c 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -36,197 +36,200 @@ type Entry = { type DialogSelectFileMode = "all" | "files" -export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) { - const command = useCommand() - const language = useLanguage() - const layout = useLayout() - const file = useFile() - const dialog = useDialog() - const params = useParams() - const navigate = useNavigate() - const globalSDK = useGlobalSDK() - const globalSync = useGlobalSync() - const filesOnly = () => props.mode === "files" - const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) - const tabs = createMemo(() => layout.tabs(sessionKey)) - const view = createMemo(() => layout.view(sessionKey)) - const state = { cleanup: undefined as (() => void) | void, committed: false } - const [grouped, setGrouped] = createSignal(false) - const common = [ - "session.new", - "workspace.new", - "session.previous", - "session.next", - "terminal.toggle", - "review.toggle", - ] - const limit = 5 - - const allowed = createMemo(() => { - if (filesOnly()) return [] - return command.options.filter( - (option) => !option.disabled && !option.id.startsWith("suggested.") && option.id !== "file.open", - ) - }) - - const commandItem = (option: CommandOption): Entry => ({ - id: "command:" + option.id, - type: "command", - title: option.title, - description: option.description, - keybind: option.keybind, - category: language.t("palette.group.commands"), - option, - }) - - const fileItem = (path: string): Entry => ({ - id: "file:" + path, - type: "file", - title: path, - category: language.t("palette.group.files"), - path, - }) - - const projectDirectory = createMemo(() => decode64(params.dir) ?? "") - const project = createMemo(() => { - const directory = projectDirectory() - if (!directory) return - return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory)) - }) - const workspaces = createMemo(() => { - const directory = projectDirectory() - const current = project() - if (!current) return directory ? [directory] : [] - - const dirs = [current.worktree, ...(current.sandboxes ?? [])] - if (directory && !dirs.includes(directory)) return [...dirs, directory] - return dirs - }) - const homedir = createMemo(() => globalSync.data.path.home) - const label = (directory: string) => { - const current = project() - const kind = - current && directory === current.worktree - ? language.t("workspace.type.local") - : language.t("workspace.type.sandbox") - const [store] = globalSync.child(directory, { bootstrap: false }) - const home = homedir() - const path = home ? directory.replace(home, "~") : directory - const name = store.vcs?.branch ?? getFilename(directory) - return `${kind} : ${name || path}` +const ENTRY_LIMIT = 5 +const COMMON_COMMAND_IDS = [ + "session.new", + "workspace.new", + "session.previous", + "session.next", + "terminal.toggle", + "review.toggle", +] as const + +const uniqueEntries = (items: Entry[]) => { + const seen = new Set<string>() + const out: Entry[] = [] + for (const item of items) { + if (seen.has(item.id)) continue + seen.add(item.id) + out.push(item) } + return out +} - const sessionItem = (input: { +const createCommandEntry = (option: CommandOption, category: string): Entry => ({ + id: "command:" + option.id, + type: "command", + title: option.title, + description: option.description, + keybind: option.keybind, + category, + option, +}) + +const createFileEntry = (path: string, category: string): Entry => ({ + id: "file:" + path, + type: "file", + title: path, + category, + path, +}) + +const createSessionEntry = ( + input: { directory: string id: string title: string description: string archived?: number updated?: number - }): Entry => ({ - id: `session:${input.directory}:${input.id}`, - type: "session", - title: input.title, - description: input.description, - category: language.t("command.category.session"), - directory: input.directory, - sessionID: input.id, - archived: input.archived, - updated: input.updated, + }, + category: string, +): Entry => ({ + id: `session:${input.directory}:${input.id}`, + type: "session", + title: input.title, + description: input.description, + category, + directory: input.directory, + sessionID: input.id, + archived: input.archived, + updated: input.updated, +}) + +function createCommandEntries(props: { + filesOnly: () => boolean + command: ReturnType<typeof useCommand> + language: ReturnType<typeof useLanguage> +}) { + const allowed = createMemo(() => { + if (props.filesOnly()) return [] + return props.command.options.filter( + (option) => !option.disabled && !option.id.startsWith("suggested.") && option.id !== "file.open", + ) }) - const list = createMemo(() => allowed().map(commandItem)) + const list = createMemo(() => { + const category = props.language.t("palette.group.commands") + return allowed().map((option) => createCommandEntry(option, category)) + }) const picks = createMemo(() => { const all = allowed() - const order = new Map(common.map((id, index) => [id, index])) + const order = new Map<string, number>(COMMON_COMMAND_IDS.map((id, index) => [id, index])) const picked = all.filter((option) => order.has(option.id)) - const base = picked.length ? picked : all.slice(0, limit) + const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT) const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base - return sorted.map(commandItem) + const category = props.language.t("palette.group.commands") + return sorted.map((option) => createCommandEntry(option, category)) }) + return { allowed, list, picks } +} + +function createFileEntries(props: { + file: ReturnType<typeof useFile> + tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]> + language: ReturnType<typeof useLanguage> +}) { const recent = createMemo(() => { - const all = tabs().all() - const active = tabs().active() + const all = props.tabs().all() + const active = props.tabs().active() const order = active ? [active, ...all.filter((item) => item !== active)] : all const seen = new Set<string>() + const category = props.language.t("palette.group.files") const items: Entry[] = [] for (const item of order) { - const path = file.pathFromTab(item) + const path = props.file.pathFromTab(item) if (!path) continue if (seen.has(path)) continue seen.add(path) - items.push(fileItem(path)) + items.push(createFileEntry(path, category)) } - return items.slice(0, limit) + return items.slice(0, ENTRY_LIMIT) }) const root = createMemo(() => { - const nodes = file.tree.children("") + const category = props.language.t("palette.group.files") + const nodes = props.file.tree.children("") const paths = nodes .filter((node) => node.type === "file") .map((node) => node.path) .sort((a, b) => a.localeCompare(b)) - return paths.slice(0, limit).map(fileItem) + return paths.slice(0, ENTRY_LIMIT).map((path) => createFileEntry(path, category)) }) - const unique = (items: Entry[]) => { - const seen = new Set<string>() - const out: Entry[] = [] - for (const item of items) { - if (seen.has(item.id)) continue - seen.add(item.id) - out.push(item) - } - return out - } + return { recent, root } +} - const sessionToken = { value: 0 } - let sessionInflight: Promise<Entry[]> | undefined - let sessionAll: Entry[] | undefined +function createSessionEntries(props: { + workspaces: () => string[] + label: (directory: string) => string + globalSDK: ReturnType<typeof useGlobalSDK> + language: ReturnType<typeof useLanguage> +}) { + const state: { + token: number + inflight: Promise<Entry[]> | undefined + cached: Entry[] | undefined + } = { + token: 0, + inflight: undefined, + cached: undefined, + } const sessions = (text: string) => { const query = text.trim() if (!query) { - sessionToken.value += 1 - sessionInflight = undefined - sessionAll = undefined + state.token += 1 + state.inflight = undefined + state.cached = undefined return [] as Entry[] } - if (sessionAll) return sessionAll - if (sessionInflight) return sessionInflight + if (state.cached) return state.cached + if (state.inflight) return state.inflight - const current = sessionToken.value - const dirs = workspaces() + const current = state.token + const dirs = props.workspaces() if (dirs.length === 0) return [] as Entry[] - sessionInflight = Promise.all( + state.inflight = Promise.all( dirs.map((directory) => { - const description = label(directory) - return globalSDK.client.session + const description = props.label(directory) + return props.globalSDK.client.session .list({ directory, roots: true }) .then((x) => (x.data ?? []) .filter((s) => !!s?.id) .map((s) => ({ id: s.id, - title: s.title ?? language.t("command.session.new"), + title: s.title ?? props.language.t("command.session.new"), description, directory, archived: s.time?.archived, updated: s.time?.updated, })), ) - .catch(() => [] as { id: string; title: string; description: string; directory: string; archived?: number }[]) + .catch( + () => + [] as { + id: string + title: string + description: string + directory: string + archived?: number + updated?: number + }[], + ) }), ) .then((results) => { - if (sessionToken.value !== current) return [] as Entry[] + if (state.token !== current) return [] as Entry[] const seen = new Set<string>() + const category = props.language.t("command.category.session") const next = results .flat() .filter((item) => { @@ -235,18 +238,71 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil seen.add(key) return true }) - .map(sessionItem) - sessionAll = next + .map((item) => createSessionEntry(item, category)) + state.cached = next return next }) .catch(() => [] as Entry[]) .finally(() => { - sessionInflight = undefined + state.inflight = undefined }) - return sessionInflight + return state.inflight } + return { sessions } +} + +export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) { + const command = useCommand() + const language = useLanguage() + const layout = useLayout() + const file = useFile() + const dialog = useDialog() + const params = useParams() + const navigate = useNavigate() + const globalSDK = useGlobalSDK() + const globalSync = useGlobalSync() + const filesOnly = () => props.mode === "files" + const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) + const tabs = createMemo(() => layout.tabs(sessionKey)) + const view = createMemo(() => layout.view(sessionKey)) + const state = { cleanup: undefined as (() => void) | void, committed: false } + const [grouped, setGrouped] = createSignal(false) + const commandEntries = createCommandEntries({ filesOnly, command, language }) + const fileEntries = createFileEntries({ file, tabs, language }) + + const projectDirectory = createMemo(() => decode64(params.dir) ?? "") + const project = createMemo(() => { + const directory = projectDirectory() + if (!directory) return + return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory)) + }) + const workspaces = createMemo(() => { + const directory = projectDirectory() + const current = project() + if (!current) return directory ? [directory] : [] + + const dirs = [current.worktree, ...(current.sandboxes ?? [])] + if (directory && !dirs.includes(directory)) return [...dirs, directory] + return dirs + }) + const homedir = createMemo(() => globalSync.data.path.home) + const label = (directory: string) => { + const current = project() + const kind = + current && directory === current.worktree + ? language.t("workspace.type.local") + : language.t("workspace.type.sandbox") + const [store] = globalSync.child(directory, { bootstrap: false }) + const home = homedir() + const path = home ? directory.replace(home, "~") : directory + const name = store.vcs?.branch ?? getFilename(directory) + return `${kind} : ${name || path}` + } + + const { sessions } = createSessionEntries({ workspaces, label, globalSDK, language }) + const items = async (text: string) => { const query = text.trim() setGrouped(query.length > 0) @@ -254,7 +310,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil if (!query && filesOnly()) { const loaded = file.tree.state("")?.loaded const pending = loaded ? Promise.resolve() : file.tree.list("") - const next = unique([...recent(), ...root()]) + const next = uniqueEntries([...fileEntries.recent(), ...fileEntries.root()]) if (loaded || next.length > 0) { void pending @@ -262,19 +318,21 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil } await pending - return unique([...recent(), ...root()]) + return uniqueEntries([...fileEntries.recent(), ...fileEntries.root()]) } - if (!query) return [...picks(), ...recent()] + if (!query) return [...commandEntries.picks(), ...fileEntries.recent()] if (filesOnly()) { const files = await file.searchFiles(query) - return files.map(fileItem) + const category = language.t("palette.group.files") + return files.map((path) => createFileEntry(path, category)) } const [files, nextSessions] = await Promise.all([file.searchFiles(query), Promise.resolve(sessions(query))]) - const entries = files.map(fileItem) - return [...list(), ...nextSessions, ...entries] + const category = language.t("palette.group.files") + const entries = files.map((path) => createFileEntry(path, category)) + return [...commandEntries.list(), ...nextSessions, ...entries] } const handleMove = (item: Entry | undefined) => { diff --git a/packages/app/src/components/dialog-select-mcp.tsx b/packages/app/src/components/dialog-select-mcp.tsx index 8eb088789..f8913eee4 100644 --- a/packages/app/src/components/dialog-select-mcp.tsx +++ b/packages/app/src/components/dialog-select-mcp.tsx @@ -6,6 +6,13 @@ import { List } from "@opencode-ai/ui/list" import { Switch } from "@opencode-ai/ui/switch" import { useLanguage } from "@/context/language" +const statusLabels = { + connected: "mcp.status.connected", + failed: "mcp.status.failed", + needs_auth: "mcp.status.needs_auth", + disabled: "mcp.status.disabled", +} as const + export const DialogSelectMcp: Component = () => { const sync = useSync() const sdk = useSDK() @@ -21,15 +28,19 @@ export const DialogSelectMcp: Component = () => { const toggle = async (name: string) => { if (loading()) return setLoading(name) - const status = sync.data.mcp[name] - if (status?.status === "connected") { - await sdk.client.mcp.disconnect({ name }) - } else { - await sdk.client.mcp.connect({ name }) + try { + const status = sync.data.mcp[name] + if (status?.status === "connected") { + await sdk.client.mcp.disconnect({ name }) + } else { + await sdk.client.mcp.connect({ name }) + } + + const result = await sdk.client.mcp.status() + if (result.data) sync.set("mcp", result.data) + } finally { + setLoading(null) } - const result = await sdk.client.mcp.status() - if (result.data) sync.set("mcp", result.data) - setLoading(null) } const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length) @@ -54,6 +65,11 @@ export const DialogSelectMcp: Component = () => { {(i) => { const mcpStatus = () => sync.data.mcp[i.name] const status = () => mcpStatus()?.status + const statusLabel = () => { + const key = status() ? statusLabels[status() as keyof typeof statusLabels] : undefined + if (!key) return + return language.t(key) + } const error = () => { const s = mcpStatus() return s?.status === "failed" ? s.error : undefined @@ -64,17 +80,8 @@ export const DialogSelectMcp: Component = () => { <div class="flex flex-col gap-0.5 min-w-0"> <div class="flex items-center gap-2"> <span class="truncate">{i.name}</span> - <Show when={status() === "connected"}> - <span class="text-11-regular text-text-weaker">{language.t("mcp.status.connected")}</span> - </Show> - <Show when={status() === "failed"}> - <span class="text-11-regular text-text-weaker">{language.t("mcp.status.failed")}</span> - </Show> - <Show when={status() === "needs_auth"}> - <span class="text-11-regular text-text-weaker">{language.t("mcp.status.needs_auth")}</span> - </Show> - <Show when={status() === "disabled"}> - <span class="text-11-regular text-text-weaker">{language.t("mcp.status.disabled")}</span> + <Show when={statusLabel()}> + <span class="text-11-regular text-text-weaker">{statusLabel()}</span> </Show> <Show when={loading() === i.name}> <span class="text-11-regular text-text-weak">{language.t("common.loading.ellipsis")}</span> diff --git a/packages/app/src/components/dialog-select-model-unpaid.tsx b/packages/app/src/components/dialog-select-model-unpaid.tsx index 78c169777..af788d05b 100644 --- a/packages/app/src/components/dialog-select-model-unpaid.tsx +++ b/packages/app/src/components/dialog-select-model-unpaid.tsx @@ -6,7 +6,7 @@ import { List, type ListRef } from "@opencode-ai/ui/list" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { Tag } from "@opencode-ai/ui/tag" import { Tooltip } from "@opencode-ai/ui/tooltip" -import { type Component, onCleanup, onMount, Show } from "solid-js" +import { type Component, Show } from "solid-js" import { useLocal } from "@/context/local" import { popularProviders, useProviders } from "@/hooks/use-providers" import { DialogConnectProvider } from "./dialog-connect-provider" @@ -21,24 +21,17 @@ export const DialogSelectModelUnpaid: Component = () => { const language = useLanguage() let listRef: ListRef | undefined - const handleKey = (e: KeyboardEvent) => { + const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") return listRef?.onKeyDown(e) } - onMount(() => { - document.addEventListener("keydown", handleKey) - onCleanup(() => { - document.removeEventListener("keydown", handleKey) - }) - }) - return ( <Dialog title={language.t("dialog.model.select.title")} class="overflow-y-auto [&_[data-slot=dialog-body]]:overflow-visible [&_[data-slot=dialog-body]]:flex-none" > - <div class="flex flex-col gap-3 px-2.5"> + <div class="flex flex-col gap-3 px-2.5" onKeyDown={handleKeyDown}> <div class="text-14-medium text-text-base px-2.5">{language.t("dialog.model.unpaid.freeModels.title")}</div> <List class="[&_[data-slot=list-scroll]]:overflow-visible" diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index 26021f06a..a196db231 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -1,5 +1,5 @@ import { Popover as Kobalte } from "@kobalte/core/popover" -import { Component, ComponentProps, createEffect, createMemo, JSX, onCleanup, Show, ValidComponent } from "solid-js" +import { Component, ComponentProps, createMemo, JSX, Show, ValidComponent } from "solid-js" import { createStore } from "solid-js/store" import { useLocal } from "@/context/local" import { useDialog } from "@opencode-ai/ui/context/dialog" @@ -15,6 +15,9 @@ import { DialogManageModels } from "./dialog-manage-models" import { ModelTooltip } from "./model-tooltip" import { useLanguage } from "@/context/language" +const isFree = (provider: string, cost: { input: number } | undefined) => + provider === "opencode" && (!cost || cost.input === 0) + const ModelList: Component<{ provider?: string class?: string @@ -54,13 +57,7 @@ const ModelList: Component<{ class="w-full" placement="right-start" gutter={12} - value={ - <ModelTooltip - model={item} - latest={item.latest} - free={item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)} - /> - } + value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />} > {node} </Tooltip> @@ -75,7 +72,7 @@ const ModelList: Component<{ {(i) => ( <div class="w-full flex items-center gap-x-2 text-13-regular"> <span class="truncate">{i.name}</span> - <Show when={i.provider.id === "opencode" && (!i.cost || i.cost?.input === 0)}> + <Show when={isFree(i.provider.id, i.cost)}> <Tag>{language.t("model.tag.free")}</Tag> </Show> <Show when={i.latest}> @@ -98,13 +95,9 @@ export function ModelSelectorPopover(props: { const [store, setStore] = createStore<{ open: boolean dismiss: "escape" | "outside" | null - trigger?: HTMLElement - content?: HTMLElement }>({ open: false, dismiss: null, - trigger: undefined, - content: undefined, }) const dialog = useDialog() @@ -119,54 +112,6 @@ export function ModelSelectorPopover(props: { } const language = useLanguage() - createEffect(() => { - if (!store.open) return - - const inside = (node: Node | null | undefined) => { - if (!node) return false - const el = store.content - if (el && el.contains(node)) return true - const anchor = store.trigger - if (anchor && anchor.contains(node)) return true - return false - } - - const onKeyDown = (event: KeyboardEvent) => { - if (event.key !== "Escape") return - setStore("dismiss", "escape") - setStore("open", false) - event.preventDefault() - event.stopPropagation() - } - - const onPointerDown = (event: PointerEvent) => { - const target = event.target - if (!(target instanceof Node)) return - if (inside(target)) return - setStore("dismiss", "outside") - setStore("open", false) - } - - const onFocusIn = (event: FocusEvent) => { - if (!store.content) return - const target = event.target - if (!(target instanceof Node)) return - if (inside(target)) return - setStore("dismiss", "outside") - setStore("open", false) - } - - window.addEventListener("keydown", onKeyDown, true) - window.addEventListener("pointerdown", onPointerDown, true) - window.addEventListener("focusin", onFocusIn, true) - - onCleanup(() => { - window.removeEventListener("keydown", onKeyDown, true) - window.removeEventListener("pointerdown", onPointerDown, true) - window.removeEventListener("focusin", onFocusIn, true) - }) - }) - return ( <Kobalte open={store.open} @@ -178,12 +123,11 @@ export function ModelSelectorPopover(props: { placement="top-start" gutter={8} > - <Kobalte.Trigger ref={(el) => setStore("trigger", el)} as={props.triggerAs ?? "div"} {...props.triggerProps}> + <Kobalte.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}> {props.children} </Kobalte.Trigger> <Kobalte.Portal> <Kobalte.Content - ref={(el) => setStore("content", el)} class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden" onEscapeKeyDown={(event) => { setStore("dismiss", "escape") diff --git a/packages/app/src/components/dialog-select-provider.tsx b/packages/app/src/components/dialog-select-provider.tsx index f878e50e8..8bbd3054b 100644 --- a/packages/app/src/components/dialog-select-provider.tsx +++ b/packages/app/src/components/dialog-select-provider.tsx @@ -24,6 +24,12 @@ export const DialogSelectProvider: Component = () => { const popularGroup = () => language.t("dialog.provider.group.popular") const otherGroup = () => language.t("dialog.provider.group.other") + const customLabel = () => language.t("settings.providers.tag.custom") + const note = (id: string) => { + if (id === "anthropic") return language.t("dialog.provider.anthropic.note") + if (id === "openai") return language.t("dialog.provider.openai.note") + if (id.startsWith("github-copilot")) return language.t("dialog.provider.copilot.note") + } return ( <Dialog title={language.t("command.provider.connect")} transition> @@ -34,7 +40,7 @@ export const DialogSelectProvider: Component = () => { key={(x) => x?.id} items={() => { language.locale() - return [{ id: CUSTOM_ID, name: "Custom provider" }, ...providers.all()] + return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all()] }} filterKeys={["id", "name"]} groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())} @@ -70,15 +76,7 @@ export const DialogSelectProvider: Component = () => { <Show when={i.id === "opencode"}> <Tag>{language.t("dialog.provider.tag.recommended")}</Tag> </Show> - <Show when={i.id === "anthropic"}> - <div class="text-14-regular text-text-weak">{language.t("dialog.provider.anthropic.note")}</div> - </Show> - <Show when={i.id === "openai"}> - <div class="text-14-regular text-text-weak">{language.t("dialog.provider.openai.note")}</div> - </Show> - <Show when={i.id.startsWith("github-copilot")}> - <div class="text-14-regular text-text-weak">{language.t("dialog.provider.copilot.note")}</div> - </Show> + <Show when={note(i.id)}>{(value) => <div class="text-14-regular text-text-weak">{value()}</div>}</Show> </div> )} </List> diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index 65b679f70..4c3780636 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -38,6 +38,64 @@ interface EditRowProps { onBlur: () => void } +function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: err instanceof Error ? err.message : String(err), + }) +} + +function useDefaultServer(platform: ReturnType<typeof usePlatform>, language: ReturnType<typeof useLanguage>) { + const [defaultUrl, defaultUrlActions] = createResource( + async () => { + try { + const url = await platform.getDefaultServerUrl?.() + if (!url) return null + return normalizeServerUrl(url) ?? null + } catch (err) { + showRequestError(language, err) + return null + } + }, + { initialValue: null }, + ) + + const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl) + const setDefault = async (url: string | null) => { + try { + await platform.setDefaultServerUrl?.(url) + defaultUrlActions.mutate(url) + } catch (err) { + showRequestError(language, err) + } + } + + return { defaultUrl, canDefault, setDefault } +} + +function useServerPreview(fetcher: typeof fetch) { + const looksComplete = (value: string) => { + const normalized = normalizeServerUrl(value) + if (!normalized) return false + const host = normalized.replace(/^https?:\/\//, "").split("/")[0] + if (!host) return false + if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true + return host.includes(".") || host.includes(":") + } + + const previewStatus = async (value: string, setStatus: (value: boolean | undefined) => void) => { + setStatus(undefined) + if (!looksComplete(value)) return + const normalized = normalizeServerUrl(value) + if (!normalized) return + const result = await checkServerHealth(normalized, fetcher) + setStatus(result.healthy) + } + + return { previewStatus } +} + function AddRow(props: AddRowProps) { return ( <div class="flex items-center px-4 min-h-14 py-3 min-w-0 flex-1"> @@ -115,6 +173,10 @@ export function DialogSelectServer() { const platform = usePlatform() const globalSDK = useGlobalSDK() const language = useLanguage() + const fetcher = platform.fetch ?? globalThis.fetch + const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language) + const { previewStatus } = useServerPreview(fetcher) + let listRoot: HTMLDivElement | undefined const [store, setStore] = createStore({ status: {} as Record<string, ServerHealth | undefined>, addServer: { @@ -132,43 +194,6 @@ export function DialogSelectServer() { status: undefined as boolean | undefined, }, }) - const [defaultUrl, defaultUrlActions] = createResource( - async () => { - try { - const url = await platform.getDefaultServerUrl?.() - if (!url) return null - return normalizeServerUrl(url) ?? null - } catch (err) { - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: err instanceof Error ? err.message : String(err), - }) - return null - } - }, - { initialValue: null }, - ) - const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl) - const fetcher = platform.fetch ?? globalThis.fetch - - const looksComplete = (value: string) => { - const normalized = normalizeServerUrl(value) - if (!normalized) return false - const host = normalized.replace(/^https?:\/\//, "").split("/")[0] - if (!host) return false - if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true - return host.includes(".") || host.includes(":") - } - - const previewStatus = async (value: string, setStatus: (value: boolean | undefined) => void) => { - setStatus(undefined) - if (!looksComplete(value)) return - const normalized = normalizeServerUrl(value) - if (!normalized) return - const result = await checkServerHealth(normalized, fetcher) - setStatus(result.healthy) - } const resetAdd = () => { setStore("addServer", { @@ -263,7 +288,7 @@ export function DialogSelectServer() { } const scrollListToBottom = () => { - const scroll = document.querySelector<HTMLDivElement>('[data-component="list"] [data-slot="list-scroll"]') + const scroll = listRoot?.querySelector<HTMLDivElement>('[data-slot="list-scroll"]') if (!scroll) return requestAnimationFrame(() => { scroll.scrollTop = scroll.scrollHeight @@ -363,158 +388,134 @@ export function DialogSelectServer() { return ( <Dialog title={language.t("dialog.server.title")}> <div class="flex flex-col gap-2"> - <List - search={{ placeholder: language.t("dialog.server.search.placeholder"), autofocus: false }} - noInitialSelection - emptyMessage={language.t("dialog.server.empty")} - items={sortedItems} - key={(x) => x} - onSelect={(x) => { - if (x) select(x) - }} - onFilter={(value) => { - if (value && store.addServer.showForm && !store.addServer.adding) { - resetAdd() + <div ref={(el) => (listRoot = el)}> + <List + search={{ placeholder: language.t("dialog.server.search.placeholder"), autofocus: false }} + noInitialSelection + emptyMessage={language.t("dialog.server.empty")} + items={sortedItems} + key={(x) => x} + onSelect={(x) => { + if (x) select(x) + }} + onFilter={(value) => { + if (value && store.addServer.showForm && !store.addServer.adding) { + resetAdd() + } + }} + divider={true} + class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]:max-h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-raised-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent [&_[data-slot=list-item-add]]:px-0" + add={ + store.addServer.showForm + ? { + render: () => ( + <AddRow + value={store.addServer.url} + placeholder={language.t("dialog.server.add.placeholder")} + adding={store.addServer.adding} + error={store.addServer.error} + status={store.addServer.status} + onChange={handleAddChange} + onKeyDown={handleAddKey} + onBlur={blurAdd} + /> + ), + } + : undefined } - }} - divider={true} - class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]:max-h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-raised-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent [&_[data-slot=list-item-add]]:px-0" - add={ - store.addServer.showForm - ? { - render: () => ( - <AddRow - value={store.addServer.url} - placeholder={language.t("dialog.server.add.placeholder")} - adding={store.addServer.adding} - error={store.addServer.error} - status={store.addServer.status} - onChange={handleAddChange} - onKeyDown={handleAddKey} - onBlur={blurAdd} - /> - ), - } - : undefined - } - > - {(i) => { - return ( - <div class="flex items-center gap-3 min-w-0 flex-1 group/item"> - <Show - when={store.editServer.id !== i} - fallback={ - <EditRow - value={store.editServer.value} - placeholder={language.t("dialog.server.add.placeholder")} - busy={store.editServer.busy} - error={store.editServer.error} - status={store.editServer.status} - onChange={handleEditChange} - onKeyDown={(event) => handleEditKey(event, i)} - onBlur={() => handleEdit(i, store.editServer.value)} + > + {(i) => { + return ( + <div class="flex items-center gap-3 min-w-0 flex-1 group/item"> + <Show + when={store.editServer.id !== i} + fallback={ + <EditRow + value={store.editServer.value} + placeholder={language.t("dialog.server.add.placeholder")} + busy={store.editServer.busy} + error={store.editServer.error} + status={store.editServer.status} + onChange={handleEditChange} + onKeyDown={(event) => handleEditKey(event, i)} + onBlur={() => handleEdit(i, store.editServer.value)} + /> + } + > + <ServerRow + url={i} + status={store.status[i]} + dimmed={store.status[i]?.healthy === false} + class="flex items-center gap-3 px-4 min-w-0 flex-1" + badge={ + <Show when={defaultUrl() === i}> + <span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs"> + {language.t("dialog.server.status.default")} + </span> + </Show> + } /> - } - > - <ServerRow - url={i} - status={store.status[i]} - dimmed={store.status[i]?.healthy === false} - class="flex items-center gap-3 px-4 min-w-0 flex-1" - badge={ - <Show when={defaultUrl() === i}> - <span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs"> - {language.t("dialog.server.status.default")} - </span> + </Show> + <Show when={store.editServer.id !== i}> + <div class="flex items-center justify-center gap-5 pl-4"> + <Show when={current() === i}> + <p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p> </Show> - } - /> - </Show> - <Show when={store.editServer.id !== i}> - <div class="flex items-center justify-center gap-5 pl-4"> - <Show when={current() === i}> - <p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p> - </Show> - - <DropdownMenu> - <DropdownMenu.Trigger - as={IconButton} - icon="dot-grid" - variant="ghost" - class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active" - onClick={(e: MouseEvent) => e.stopPropagation()} - onPointerDown={(e: PointerEvent) => e.stopPropagation()} - /> - <DropdownMenu.Portal> - <DropdownMenu.Content class="mt-1"> - <DropdownMenu.Item - onSelect={() => { - setStore("editServer", { - id: i, - value: i, - error: "", - status: store.status[i]?.healthy, - }) - }} - > - <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel> - </DropdownMenu.Item> - <Show when={canDefault() && defaultUrl() !== i}> + + <DropdownMenu> + <DropdownMenu.Trigger + as={IconButton} + icon="dot-grid" + variant="ghost" + class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active" + onClick={(e: MouseEvent) => e.stopPropagation()} + onPointerDown={(e: PointerEvent) => e.stopPropagation()} + /> + <DropdownMenu.Portal> + <DropdownMenu.Content class="mt-1"> <DropdownMenu.Item - onSelect={async () => { - try { - await platform.setDefaultServerUrl?.(i) - defaultUrlActions.mutate(i) - } catch (err) { - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: err instanceof Error ? err.message : String(err), - }) - } + onSelect={() => { + setStore("editServer", { + id: i, + value: i, + error: "", + status: store.status[i]?.healthy, + }) }} > - <DropdownMenu.ItemLabel> - {language.t("dialog.server.menu.default")} - </DropdownMenu.ItemLabel> + <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel> </DropdownMenu.Item> - </Show> - <Show when={canDefault() && defaultUrl() === i}> + <Show when={canDefault() && defaultUrl() !== i}> + <DropdownMenu.Item onSelect={() => setDefault(i)}> + <DropdownMenu.ItemLabel> + {language.t("dialog.server.menu.default")} + </DropdownMenu.ItemLabel> + </DropdownMenu.Item> + </Show> + <Show when={canDefault() && defaultUrl() === i}> + <DropdownMenu.Item onSelect={() => setDefault(null)}> + <DropdownMenu.ItemLabel> + {language.t("dialog.server.menu.defaultRemove")} + </DropdownMenu.ItemLabel> + </DropdownMenu.Item> + </Show> + <DropdownMenu.Separator /> <DropdownMenu.Item - onSelect={async () => { - try { - await platform.setDefaultServerUrl?.(null) - defaultUrlActions.mutate(null) - } catch (err) { - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: err instanceof Error ? err.message : String(err), - }) - } - }} + onSelect={() => handleRemove(i)} + class="text-text-on-critical-base hover:bg-surface-critical-weak" > - <DropdownMenu.ItemLabel> - {language.t("dialog.server.menu.defaultRemove")} - </DropdownMenu.ItemLabel> + <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel> </DropdownMenu.Item> - </Show> - <DropdownMenu.Separator /> - <DropdownMenu.Item - onSelect={() => handleRemove(i)} - class="text-text-on-critical-base hover:bg-surface-critical-weak" - > - <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel> - </DropdownMenu.Item> - </DropdownMenu.Content> - </DropdownMenu.Portal> - </DropdownMenu> - </div> - </Show> - </div> - ) - }} - </List> + </DropdownMenu.Content> + </DropdownMenu.Portal> + </DropdownMenu> + </div> + </Show> + </div> + ) + }} + </List> + </div> <div class="px-5 pb-5"> <Button diff --git a/packages/app/src/components/dialog-settings.tsx b/packages/app/src/components/dialog-settings.tsx index f8892ebbd..83cea131f 100644 --- a/packages/app/src/components/dialog-settings.tsx +++ b/packages/app/src/components/dialog-settings.tsx @@ -67,15 +67,6 @@ export const DialogSettings: Component = () => { <Tabs.Content value="models" class="no-scrollbar"> <SettingsModels /> </Tabs.Content> - {/* <Tabs.Content value="agents" class="no-scrollbar"> */} - {/* <SettingsAgents /> */} - {/* </Tabs.Content> */} - {/* <Tabs.Content value="commands" class="no-scrollbar"> */} - {/* <SettingsCommands /> */} - {/* </Tabs.Content> */} - {/* <Tabs.Content value="mcp" class="no-scrollbar"> */} - {/* <SettingsMcp /> */} - {/* </Tabs.Content> */} </Tabs> </Dialog> ) diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx index d7b729973..5552cc90b 100644 --- a/packages/app/src/components/file-tree.tsx +++ b/packages/app/src/components/file-tree.tsx @@ -15,6 +15,7 @@ import { Switch, untrack, type ComponentProps, + type JSXElement, type ParentProps, } from "solid-js" import { Dynamic } from "solid-js/web" @@ -59,6 +60,189 @@ export function dirsToExpand(input: { return [...input.filter.dirs].filter((dir) => !input.expanded(dir)) } +const kindLabel = (kind: Kind) => { + if (kind === "add") return "A" + if (kind === "del") return "D" + return "M" +} + +const kindTextColor = (kind: Kind) => { + if (kind === "add") return "color: var(--icon-diff-add-base)" + if (kind === "del") return "color: var(--icon-diff-delete-base)" + return "color: var(--icon-warning-active)" +} + +const kindDotColor = (kind: Kind) => { + if (kind === "add") return "background-color: var(--icon-diff-add-base)" + if (kind === "del") return "background-color: var(--icon-diff-delete-base)" + return "background-color: var(--icon-warning-active)" +} + +const visibleKind = (node: FileNode, kinds?: ReadonlyMap<string, Kind>, marks?: Set<string>) => { + const kind = kinds?.get(node.path) + if (!kind) return + if (!marks?.has(node.path)) return + return kind +} + +const buildDragImage = (target: HTMLElement) => { + const icon = target.querySelector('[data-component="file-icon"]') ?? target.querySelector("svg") + const text = target.querySelector("span") + if (!icon || !text) return + + const image = document.createElement("div") + image.className = + "flex items-center gap-x-2 px-2 py-1 bg-surface-raised-base rounded-md border border-border-base text-12-regular text-text-strong" + image.style.position = "absolute" + image.style.top = "-1000px" + image.innerHTML = (icon as SVGElement).outerHTML + (text as HTMLSpanElement).outerHTML + return image +} + +const withFileDragImage = (event: DragEvent) => { + const image = buildDragImage(event.currentTarget as HTMLElement) + if (!image) return + document.body.appendChild(image) + event.dataTransfer?.setDragImage(image, 0, 12) + setTimeout(() => document.body.removeChild(image), 0) +} + +const FileTreeNode = ( + p: ParentProps & + ComponentProps<"div"> & + ComponentProps<"button"> & { + node: FileNode + level: number + active?: string + nodeClass?: string + draggable: boolean + kinds?: ReadonlyMap<string, Kind> + marks?: Set<string> + as?: "div" | "button" + }, +) => { + const [local, rest] = splitProps(p, [ + "node", + "level", + "active", + "nodeClass", + "draggable", + "kinds", + "marks", + "as", + "children", + "class", + "classList", + ]) + const kind = () => visibleKind(local.node, local.kinds, local.marks) + const active = () => !!kind() && !local.node.ignored + const color = () => { + const value = kind() + if (!value) return + return kindTextColor(value) + } + + return ( + <Dynamic + component={local.as ?? "div"} + classList={{ + "w-full min-w-0 h-6 flex items-center justify-start gap-x-1.5 rounded-md px-1.5 py-0 text-left hover:bg-surface-raised-base-hover active:bg-surface-base-active transition-colors cursor-pointer": true, + "bg-surface-base-active": local.node.path === local.active, + ...(local.classList ?? {}), + [local.class ?? ""]: !!local.class, + [local.nodeClass ?? ""]: !!local.nodeClass, + }} + style={`padding-left: ${Math.max(0, 8 + local.level * 12 - (local.node.type === "file" ? 24 : 4))}px`} + draggable={local.draggable} + onDragStart={(event: DragEvent) => { + if (!local.draggable) return + event.dataTransfer?.setData("text/plain", `file:${local.node.path}`) + event.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path)) + if (event.dataTransfer) event.dataTransfer.effectAllowed = "copy" + withFileDragImage(event) + }} + {...rest} + > + {local.children} + <span + classList={{ + "flex-1 min-w-0 text-12-medium whitespace-nowrap truncate": true, + "text-text-weaker": local.node.ignored, + "text-text-weak": !local.node.ignored && !active(), + }} + style={active() ? color() : undefined} + > + {local.node.name} + </span> + {(() => { + const value = kind() + if (!value) return null + if (local.node.type === "file") { + return ( + <span class="shrink-0 w-4 text-center text-12-medium" style={kindTextColor(value)}> + {kindLabel(value)} + </span> + ) + } + return <div class="shrink-0 size-1.5 mr-1.5 rounded-full" style={kindDotColor(value)} /> + })()} + </Dynamic> + ) +} + +const FileTreeNodeTooltip = (props: { enabled: boolean; node: FileNode; kind?: Kind; children: JSXElement }) => { + if (!props.enabled) return props.children + + const parts = props.node.path.split("/") + const leaf = parts[parts.length - 1] ?? props.node.path + const head = parts.slice(0, -1).join("/") + const prefix = head ? `${head}/` : "" + const label = + props.kind === "add" + ? "Additions" + : props.kind === "del" + ? "Deletions" + : props.kind === "mix" + ? "Modifications" + : undefined + + return ( + <Tooltip + openDelay={2000} + placement="bottom-start" + class="w-full" + contentStyle={{ "max-width": "480px", width: "fit-content" }} + value={ + <div class="flex items-center min-w-0 whitespace-nowrap text-12-regular"> + <span + class="min-w-0 truncate text-text-invert-base" + style={{ direction: "rtl", "unicode-bidi": "plaintext" }} + > + {prefix} + </span> + <span class="shrink-0 text-text-invert-strong">{leaf}</span> + <Show when={label}> + {(text) => ( + <> + <span class="mx-1 font-bold text-text-invert-strong">•</span> + <span class="shrink-0 text-text-invert-strong">{text()}</span> + </> + )} + </Show> + <Show when={props.node.type === "directory" && props.node.ignored}> + <> + <span class="mx-1 font-bold text-text-invert-strong">•</span> + <span class="shrink-0 text-text-invert-strong">Ignored</span> + </> + </Show> + </div> + } + > + {props.children} + </Tooltip> + ) +} + export default function FileTree(props: { path: string class?: string @@ -230,178 +414,13 @@ export default function FileTree(props: { return out }) - const Node = ( - p: ParentProps & - ComponentProps<"div"> & - ComponentProps<"button"> & { - node: FileNode - as?: "div" | "button" - }, - ) => { - const [local, rest] = splitProps(p, ["node", "as", "children", "class", "classList"]) - return ( - <Dynamic - component={local.as ?? "div"} - classList={{ - "w-full min-w-0 h-6 flex items-center justify-start gap-x-1.5 rounded-md px-1.5 py-0 text-left hover:bg-surface-raised-base-hover active:bg-surface-base-active transition-colors cursor-pointer": true, - "bg-surface-base-active": local.node.path === props.active, - ...(local.classList ?? {}), - [local.class ?? ""]: !!local.class, - [props.nodeClass ?? ""]: !!props.nodeClass, - }} - style={`padding-left: ${Math.max(0, 8 + level * 12 - (local.node.type === "file" ? 24 : 4))}px`} - draggable={draggable()} - onDragStart={(e: DragEvent) => { - if (!draggable()) return - e.dataTransfer?.setData("text/plain", `file:${local.node.path}`) - e.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path)) - if (e.dataTransfer) e.dataTransfer.effectAllowed = "copy" - - const dragImage = document.createElement("div") - dragImage.className = - "flex items-center gap-x-2 px-2 py-1 bg-surface-raised-base rounded-md border border-border-base text-12-regular text-text-strong" - dragImage.style.position = "absolute" - dragImage.style.top = "-1000px" - - const icon = - (e.currentTarget as HTMLElement).querySelector('[data-component="file-icon"]') ?? - (e.currentTarget as HTMLElement).querySelector("svg") - const text = (e.currentTarget as HTMLElement).querySelector("span") - if (icon && text) { - dragImage.innerHTML = (icon as SVGElement).outerHTML + (text as HTMLSpanElement).outerHTML - } - - document.body.appendChild(dragImage) - e.dataTransfer?.setDragImage(dragImage, 0, 12) - setTimeout(() => document.body.removeChild(dragImage), 0) - }} - {...rest} - > - {local.children} - {(() => { - const kind = kinds()?.get(local.node.path) - const marked = marks()?.has(local.node.path) ?? false - const active = !!kind && marked && !local.node.ignored - const color = - kind === "add" - ? "color: var(--icon-diff-add-base)" - : kind === "del" - ? "color: var(--icon-diff-delete-base)" - : kind === "mix" - ? "color: var(--icon-warning-active)" - : undefined - return ( - <span - classList={{ - "flex-1 min-w-0 text-12-medium whitespace-nowrap truncate": true, - "text-text-weaker": local.node.ignored, - "text-text-weak": !local.node.ignored && !active, - }} - style={active ? color : undefined} - > - {local.node.name} - </span> - ) - })()} - {(() => { - const kind = kinds()?.get(local.node.path) - if (!kind) return null - if (!marks()?.has(local.node.path)) return null - - if (local.node.type === "file") { - const text = kind === "add" ? "A" : kind === "del" ? "D" : "M" - const color = - kind === "add" - ? "color: var(--icon-diff-add-base)" - : kind === "del" - ? "color: var(--icon-diff-delete-base)" - : "color: var(--icon-warning-active)" - - return ( - <span class="shrink-0 w-4 text-center text-12-medium" style={color}> - {text} - </span> - ) - } - - if (local.node.type === "directory") { - const color = - kind === "add" - ? "background-color: var(--icon-diff-add-base)" - : kind === "del" - ? "background-color: var(--icon-diff-delete-base)" - : "background-color: var(--icon-warning-active)" - - return <div class="shrink-0 size-1.5 mr-1.5 rounded-full" style={color} /> - } - - return null - })()} - </Dynamic> - ) - } - return ( <div class={`flex flex-col gap-0.5 ${props.class ?? ""}`}> <For each={nodes()}> {(node) => { const expanded = () => file.tree.state(node.path)?.expanded ?? false const deep = () => deeps().get(node.path) ?? -1 - const Wrapper = (p: ParentProps) => { - if (!tooltip()) return p.children - - const parts = node.path.split("/") - const leaf = parts[parts.length - 1] ?? node.path - const head = parts.slice(0, -1).join("/") - const prefix = head ? `${head}/` : "" - - const kind = () => kinds()?.get(node.path) - const label = () => { - const k = kind() - if (!k) return - if (k === "add") return "Additions" - if (k === "del") return "Deletions" - return "Modifications" - } - - const ignored = () => node.type === "directory" && node.ignored - - return ( - <Tooltip - openDelay={2000} - placement="bottom-start" - class="w-full" - contentStyle={{ "max-width": "480px", width: "fit-content" }} - value={ - <div class="flex items-center min-w-0 whitespace-nowrap text-12-regular"> - <span - class="min-w-0 truncate text-text-invert-base" - style={{ direction: "rtl", "unicode-bidi": "plaintext" }} - > - {prefix} - </span> - <span class="shrink-0 text-text-invert-strong">{leaf}</span> - <Show when={label()}> - {(t: () => string) => ( - <> - <span class="mx-1 font-bold text-text-invert-strong">•</span> - <span class="shrink-0 text-text-invert-strong">{t()}</span> - </> - )} - </Show> - <Show when={ignored()}> - <> - <span class="mx-1 font-bold text-text-invert-strong">•</span> - <span class="shrink-0 text-text-invert-strong">Ignored</span> - </> - </Show> - </div> - } - > - {p.children} - </Tooltip> - ) - } + const kind = () => visibleKind(node, kinds(), marks()) return ( <Switch> @@ -415,13 +434,21 @@ export default function FileTree(props: { onOpenChange={(open) => (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))} > <Collapsible.Trigger> - <Wrapper> - <Node node={node}> + <FileTreeNodeTooltip enabled={tooltip()} node={node} kind={kind()}> + <FileTreeNode + node={node} + level={level} + active={props.active} + nodeClass={props.nodeClass} + draggable={draggable()} + kinds={kinds()} + marks={marks()} + > <div class="size-4 flex items-center justify-center text-icon-weak"> <Icon name={expanded() ? "chevron-down" : "chevron-right"} size="small" /> </div> - </Node> - </Wrapper> + </FileTreeNode> + </FileTreeNodeTooltip> </Collapsible.Trigger> <Collapsible.Content class="relative pt-0.5"> <div @@ -451,12 +478,23 @@ export default function FileTree(props: { </Collapsible> </Match> <Match when={node.type === "file"}> - <Wrapper> - <Node node={node} as="button" type="button" onClick={() => props.onFileClick?.(node)}> + <FileTreeNodeTooltip enabled={tooltip()} node={node} kind={kind()}> + <FileTreeNode + node={node} + level={level} + active={props.active} + nodeClass={props.nodeClass} + draggable={draggable()} + kinds={kinds()} + marks={marks()} + as="button" + type="button" + onClick={() => props.onFileClick?.(node)} + > <div class="w-4 shrink-0" /> <FileIcon node={node} class="text-icon-weak size-4" /> - </Node> - </Wrapper> + </FileTreeNode> + </FileTreeNodeTooltip> </Match> </Switch> ) diff --git a/packages/app/src/components/link.tsx b/packages/app/src/components/link.tsx index e13c31330..85f7efc53 100644 --- a/packages/app/src/components/link.tsx +++ b/packages/app/src/components/link.tsx @@ -1,17 +1,26 @@ import { ComponentProps, splitProps } from "solid-js" import { usePlatform } from "@/context/platform" -export interface LinkProps extends ComponentProps<"button"> { +export interface LinkProps extends Omit<ComponentProps<"a">, "href"> { href: string } export function Link(props: LinkProps) { const platform = usePlatform() - const [local, rest] = splitProps(props, ["href", "children"]) + const [local, rest] = splitProps(props, ["href", "children", "class"]) return ( - <button class="text-text-strong underline" onClick={() => platform.openLink(local.href)} {...rest}> + <a + href={local.href} + class={`text-text-strong underline ${local.class ?? ""}`} + onClick={(event) => { + if (!local.href) return + event.preventDefault() + platform.openLink(local.href) + }} + {...rest} + > {local.children} - </button> + </a> ) } diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 4f495d27d..d591b22c7 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -277,6 +277,47 @@ export const PromptInput: Component<PromptInputProps> = (props) => { const isFocused = createFocusSignal(() => editorRef) + const closePopover = () => setStore("popover", null) + + const resetHistoryNavigation = (force = false) => { + if (!force && (store.historyIndex < 0 || store.applyingHistory)) return + setStore("historyIndex", -1) + setStore("savedPrompt", null) + } + + const clearEditor = () => { + editorRef.innerHTML = "" + } + + const setEditorText = (text: string) => { + clearEditor() + editorRef.textContent = text + } + + const focusEditorEnd = () => { + requestAnimationFrame(() => { + editorRef.focus() + const range = document.createRange() + const selection = window.getSelection() + range.selectNodeContents(editorRef) + range.collapse(false) + selection?.removeAllRanges() + selection?.addRange(range) + }) + } + + const currentCursor = () => { + const selection = window.getSelection() + if (!selection || selection.rangeCount === 0 || !editorRef.contains(selection.anchorNode)) return null + return getCursorPosition(editorRef) + } + + const renderEditorWithCursor = (parts: Prompt) => { + const cursor = currentCursor() + renderEditor(parts) + if (cursor !== null) setCursorPosition(editorRef, cursor) + } + createEffect(() => { params.id if (params.id) return @@ -290,7 +331,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229 createEffect(() => { - if (!isFocused()) setStore("popover", null) + if (!isFocused()) closePopover() }) // Safety: reset composing state on focus change to prevent stuck state @@ -381,26 +422,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => { const handleSlashSelect = (cmd: SlashCommand | undefined) => { if (!cmd) return - setStore("popover", null) + closePopover() if (cmd.type === "custom") { const text = `/${cmd.trigger} ` - editorRef.innerHTML = "" - editorRef.textContent = text + setEditorText(text) prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length) - requestAnimationFrame(() => { - editorRef.focus() - const range = document.createRange() - const sel = window.getSelection() - range.selectNodeContents(editorRef) - range.collapse(false) - sel?.removeAllRanges() - sel?.addRange(range) - }) + focusEditorEnd() return } - editorRef.innerHTML = "" + clearEditor() prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) command.trigger(cmd.id, "slash") } @@ -454,7 +486,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { }) const renderEditor = (parts: Prompt) => { - editorRef.innerHTML = "" + clearEditor() for (const part of parts) { if (part.type === "text") { editorRef.appendChild(createTextFragment(part.content)) @@ -514,34 +546,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => { mirror.input = false if (isNormalizedEditor()) return - const selection = window.getSelection() - let cursorPosition: number | null = null - if (selection && selection.rangeCount > 0 && editorRef.contains(selection.anchorNode)) { - cursorPosition = getCursorPosition(editorRef) - } - - renderEditor(inputParts) - - if (cursorPosition !== null) { - setCursorPosition(editorRef, cursorPosition) - } + renderEditorWithCursor(inputParts) return } const domParts = parseFromDOM() if (isNormalizedEditor() && isPromptEqual(inputParts, domParts)) return - const selection = window.getSelection() - let cursorPosition: number | null = null - if (selection && selection.rangeCount > 0 && editorRef.contains(selection.anchorNode)) { - cursorPosition = getCursorPosition(editorRef) - } - - renderEditor(inputParts) - - if (cursorPosition !== null) { - setCursorPosition(editorRef, cursorPosition) - } + renderEditorWithCursor(inputParts) }, ), ) @@ -636,11 +648,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => { const shouldReset = trimmed.length === 0 && !hasNonText && images.length === 0 if (shouldReset) { - setStore("popover", null) - if (store.historyIndex >= 0 && !store.applyingHistory) { - setStore("historyIndex", -1) - setStore("savedPrompt", null) - } + closePopover() + resetHistoryNavigation() if (prompt.dirty()) { mirror.input = true prompt.set(DEFAULT_PROMPT, 0) @@ -662,16 +671,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => { slashOnInput(slashMatch[1]) setStore("popover", "slash") } else { - setStore("popover", null) + closePopover() } } else { - setStore("popover", null) + closePopover() } - if (store.historyIndex >= 0 && !store.applyingHistory) { - setStore("historyIndex", -1) - setStore("savedPrompt", null) - } + resetHistoryNavigation() mirror.input = true prompt.set([...rawParts, ...images], cursorPosition) @@ -732,7 +738,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { } handleInput() - setStore("popover", null) + closePopover() } const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => { @@ -782,8 +788,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { promptLength, addToHistory, resetHistoryNavigation: () => { - setStore("historyIndex", -1) - setStore("savedPrompt", null) + resetHistoryNavigation(true) }, setMode: (mode) => setStore("mode", mode), setPopover: (popover) => setStore("popover", popover), @@ -872,7 +877,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { if (ctrl && event.code === "KeyG") { if (store.popover) { - setStore("popover", null) + closePopover() event.preventDefault() return } @@ -923,7 +928,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { } if (event.key === "Escape") { if (store.popover) { - setStore("popover", null) + closePopover() } else if (working()) { abort() } diff --git a/packages/app/src/components/prompt-input/context-items.tsx b/packages/app/src/components/prompt-input/context-items.tsx index a843e109d..b575c3961 100644 --- a/packages/app/src/components/prompt-input/context-items.tsx +++ b/packages/app/src/components/prompt-input/context-items.tsx @@ -20,61 +20,68 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => { <Show when={props.items.length > 0}> <div class="flex flex-nowrap items-start gap-2 p-2 overflow-x-auto no-scrollbar"> <For each={props.items}> - {(item) => ( - <Tooltip - value={ - <span class="flex max-w-[300px]"> - <span class="text-text-invert-base truncate-start [unicode-bidi:plaintext] min-w-0"> - {getDirectory(item.path)} + {(item) => { + const directory = getDirectory(item.path) + const filename = getFilename(item.path) + const label = getFilenameTruncated(item.path, 14) + const selected = props.active(item) + + return ( + <Tooltip + value={ + <span class="flex max-w-[300px]"> + <span class="text-text-invert-base truncate-start [unicode-bidi:plaintext] min-w-0"> + {directory} + </span> + <span class="shrink-0">{filename}</span> </span> - <span class="shrink-0">{getFilename(item.path)}</span> - </span> - } - placement="top" - openDelay={2000} - > - <div - classList={{ - "group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true, - "cursor-pointer hover:bg-surface-interactive-weak": !!item.commentID && !props.active(item), - "cursor-pointer bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover": - props.active(item), - "bg-background-stronger": !props.active(item), - }} - onClick={() => props.openComment(item)} + } + placement="top" + openDelay={2000} > - <div class="flex items-center gap-1.5"> - <FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" /> - <div class="flex items-center text-11-regular min-w-0 font-medium"> - <span class="text-text-strong whitespace-nowrap">{getFilenameTruncated(item.path, 14)}</span> - <Show when={item.selection}> - {(sel) => ( - <span class="text-text-weak whitespace-nowrap shrink-0"> - {sel().startLine === sel().endLine - ? `:${sel().startLine}` - : `:${sel().startLine}-${sel().endLine}`} - </span> - )} - </Show> + <div + classList={{ + "group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true, + "cursor-pointer hover:bg-surface-interactive-weak": !!item.commentID && !selected, + "cursor-pointer bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover": + selected, + "bg-background-stronger": !selected, + }} + onClick={() => props.openComment(item)} + > + <div class="flex items-center gap-1.5"> + <FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" /> + <div class="flex items-center text-11-regular min-w-0 font-medium"> + <span class="text-text-strong whitespace-nowrap">{label}</span> + <Show when={item.selection}> + {(sel) => ( + <span class="text-text-weak whitespace-nowrap shrink-0"> + {sel().startLine === sel().endLine + ? `:${sel().startLine}` + : `:${sel().startLine}-${sel().endLine}`} + </span> + )} + </Show> + </div> + <IconButton + type="button" + icon="close-small" + variant="ghost" + class="ml-auto size-3.5 text-text-weak hover:text-text-strong transition-all" + onClick={(e) => { + e.stopPropagation() + props.remove(item) + }} + aria-label={props.t("prompt.context.removeFile")} + /> </div> - <IconButton - type="button" - icon="close-small" - variant="ghost" - class="ml-auto size-3.5 text-text-weak hover:text-text-strong transition-all" - onClick={(e) => { - e.stopPropagation() - props.remove(item) - }} - aria-label={props.t("prompt.context.removeFile")} - /> + <Show when={item.comment}> + {(comment) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>} + </Show> </div> - <Show when={item.comment}> - {(comment) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>} - </Show> - </div> - </Tooltip> - )} + </Tooltip> + ) + }} </For> </div> </Show> diff --git a/packages/app/src/components/prompt-input/drag-overlay.tsx b/packages/app/src/components/prompt-input/drag-overlay.tsx index e05b47d7c..41962ce53 100644 --- a/packages/app/src/components/prompt-input/drag-overlay.tsx +++ b/packages/app/src/components/prompt-input/drag-overlay.tsx @@ -6,12 +6,17 @@ type PromptDragOverlayProps = { label: string } +const kindToIcon = { + image: "photo", + "@mention": "link", +} as const + export const PromptDragOverlay: Component<PromptDragOverlayProps> = (props) => { return ( <Show when={props.type !== null}> <div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 pointer-events-none"> <div class="flex flex-col items-center gap-2 text-text-weak"> - <Icon name={props.type === "@mention" ? "link" : "photo"} class="size-8" /> + <Icon name={props.type ? kindToIcon[props.type] : kindToIcon.image} class="size-8" /> <span class="text-14-regular">{props.label}</span> </div> </div> diff --git a/packages/app/src/components/prompt-input/image-attachments.tsx b/packages/app/src/components/prompt-input/image-attachments.tsx index ba3addf0a..835fddc30 100644 --- a/packages/app/src/components/prompt-input/image-attachments.tsx +++ b/packages/app/src/components/prompt-input/image-attachments.tsx @@ -9,6 +9,13 @@ type PromptImageAttachmentsProps = { removeLabel: string } +const fallbackClass = "size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base" +const imageClass = + "size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors" +const removeClass = + "absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover" +const nameClass = "absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md" + export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (props) => { return ( <Show when={props.attachments.length > 0}> @@ -19,7 +26,7 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p <Show when={attachment.mime.startsWith("image/")} fallback={ - <div class="size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base"> + <div class={fallbackClass}> <Icon name="folder" class="size-6 text-text-weak" /> </div> } @@ -27,19 +34,19 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p <img src={attachment.dataUrl} alt={attachment.filename} - class="size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors" + class={imageClass} onClick={() => props.onOpen(attachment)} /> </Show> <button type="button" onClick={() => props.onRemove(attachment.id)} - class="absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover" + class={removeClass} aria-label={props.removeLabel} > <Icon name="close" class="size-3 text-text-weak" /> </button> - <div class="absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md"> + <div class={nameClass}> <span class="text-10-regular text-white truncate block">{attachment.filename}</span> </div> </div> diff --git a/packages/app/src/components/prompt-input/slash-popover.tsx b/packages/app/src/components/prompt-input/slash-popover.tsx index b97bb6752..554a15bb7 100644 --- a/packages/app/src/components/prompt-input/slash-popover.tsx +++ b/packages/app/src/components/prompt-input/slash-popover.tsx @@ -52,47 +52,46 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => { fallback={<div class="text-text-weak px-2 py-1">{props.t("prompt.popover.emptyResults")}</div>} > <For each={props.atFlat.slice(0, 10)}> - {(item) => ( - <button - classList={{ - "w-full flex items-center gap-x-2 rounded-md px-2 py-0.5": true, - "bg-surface-raised-base-hover": props.atActive === props.atKey(item), - }} - onClick={() => props.onAtSelect(item)} - onMouseEnter={() => props.setAtActive(props.atKey(item))} - > - <Show - when={item.type === "agent"} - fallback={ - <> - <FileIcon - node={{ path: item.type === "file" ? item.path : "", type: "file" }} - class="shrink-0 size-4" - /> - <div class="flex items-center text-14-regular min-w-0"> - <span class="text-text-weak whitespace-nowrap truncate min-w-0"> - {item.type === "file" - ? item.path.endsWith("/") - ? item.path - : getDirectory(item.path) - : ""} - </span> - <Show when={item.type === "file" && !item.path.endsWith("/")}> - <span class="text-text-strong whitespace-nowrap"> - {item.type === "file" ? getFilename(item.path) : ""} - </span> - </Show> - </div> - </> - } + {(item) => { + const active = props.atActive === props.atKey(item) + const shared = { + "w-full flex items-center gap-x-2 rounded-md px-2 py-0.5": true, + "bg-surface-raised-base-hover": active, + } + + if (item.type === "agent") { + return ( + <button + classList={shared} + onClick={() => props.onAtSelect(item)} + onMouseEnter={() => props.setAtActive(props.atKey(item))} + > + <Icon name="brain" size="small" class="text-icon-info-active shrink-0" /> + <span class="text-14-regular text-text-strong whitespace-nowrap">@{item.name}</span> + </button> + ) + } + + const isDirectory = item.path.endsWith("/") + const directory = isDirectory ? item.path : getDirectory(item.path) + const filename = isDirectory ? "" : getFilename(item.path) + + return ( + <button + classList={shared} + onClick={() => props.onAtSelect(item)} + onMouseEnter={() => props.setAtActive(props.atKey(item))} > - <Icon name="brain" size="small" class="text-icon-info-active shrink-0" /> - <span class="text-14-regular text-text-strong whitespace-nowrap"> - @{item.type === "agent" ? item.name : ""} - </span> - </Show> - </button> - )} + <FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-4" /> + <div class="flex items-center text-14-regular min-w-0"> + <span class="text-text-weak whitespace-nowrap truncate min-w-0">{directory}</span> + <Show when={!isDirectory}> + <span class="text-text-strong whitespace-nowrap">{filename}</span> + </Show> + </div> + </button> + ) + }} </For> </Show> </Match> diff --git a/packages/app/src/components/question-dock.tsx b/packages/app/src/components/question-dock.tsx index f626fcc9b..1ab184535 100644 --- a/packages/app/src/components/question-dock.tsx +++ b/packages/app/src/components/question-dock.tsx @@ -7,6 +7,32 @@ import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2" import { useLanguage } from "@/context/language" import { useSDK } from "@/context/sdk" +const writeAt = <T,>(list: T[], index: number, value: T) => { + const next = [...list] + next[index] = value + return next +} + +const pickAnswer = (list: QuestionAnswer[], index: number, value: string) => { + return writeAt(list, index, [value]) +} + +const toggleAnswer = (list: QuestionAnswer[], index: number, value: string) => { + const current = list[index] ?? [] + const next = current.includes(value) ? current.filter((item) => item !== value) : [...current, value] + return writeAt(list, index, next) +} + +const appendAnswer = (list: QuestionAnswer[], index: number, value: string) => { + const current = list[index] ?? [] + if (current.includes(value)) return list + return writeAt(list, index, [...current, value]) +} + +const writeCustom = (list: string[], index: number, value: string) => { + return writeAt(list, index, value) +} + export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => { const sdk = useSDK() const language = useLanguage() @@ -38,43 +64,45 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => showToast({ title: language.t("common.requestFailed"), description: message }) } - const reply = (answers: QuestionAnswer[]) => { + const reply = async (answers: QuestionAnswer[]) => { if (store.sending) return setStore("sending", true) - sdk.client.question - .reply({ requestID: props.request.id, answers }) - .catch(fail) - .finally(() => setStore("sending", false)) + try { + await sdk.client.question.reply({ requestID: props.request.id, answers }) + } catch (err) { + fail(err) + } finally { + setStore("sending", false) + } } - const reject = () => { + const reject = async () => { if (store.sending) return setStore("sending", true) - sdk.client.question - .reject({ requestID: props.request.id }) - .catch(fail) - .finally(() => setStore("sending", false)) + try { + await sdk.client.question.reject({ requestID: props.request.id }) + } catch (err) { + fail(err) + } finally { + setStore("sending", false) + } } const submit = () => { - reply(questions().map((_, i) => store.answers[i] ?? [])) + void reply(questions().map((_, i) => store.answers[i] ?? [])) } const pick = (answer: string, custom: boolean = false) => { - const answers = [...store.answers] - answers[store.tab] = [answer] - setStore("answers", answers) + setStore("answers", pickAnswer(store.answers, store.tab, answer)) if (custom) { - const inputs = [...store.custom] - inputs[store.tab] = answer - setStore("custom", inputs) + setStore("custom", writeCustom(store.custom, store.tab, answer)) } if (single()) { - reply([[answer]]) + void reply([[answer]]) return } @@ -82,15 +110,7 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => } const toggle = (answer: string) => { - const existing = store.answers[store.tab] ?? [] - const next = [...existing] - const index = next.indexOf(answer) - if (index === -1) next.push(answer) - if (index !== -1) next.splice(index, 1) - - const answers = [...store.answers] - answers[store.tab] = next - setStore("answers", answers) + setStore("answers", toggleAnswer(store.answers, store.tab, answer)) } const selectTab = (index: number) => { @@ -126,13 +146,7 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => } if (multi()) { - const existing = store.answers[store.tab] ?? [] - const next = [...existing] - if (!next.includes(value)) next.push(value) - - const answers = [...store.answers] - answers[store.tab] = next - setStore("answers", answers) + setStore("answers", appendAnswer(store.answers, store.tab, value)) setStore("editing", false) return } @@ -225,9 +239,7 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => value={input()} disabled={store.sending} onInput={(e) => { - const inputs = [...store.custom] - inputs[store.tab] = e.currentTarget.value - setStore("custom", inputs) + setStore("custom", writeCustom(store.custom, store.tab, e.currentTarget.value)) }} /> <Button type="submit" variant="primary" size="small" disabled={store.sending}> diff --git a/packages/app/src/components/server/server-row.tsx b/packages/app/src/components/server/server-row.tsx index b43c07882..f93bdb33b 100644 --- a/packages/app/src/components/server/server-row.tsx +++ b/packages/app/src/components/server/server-row.tsx @@ -1,5 +1,5 @@ import { Tooltip } from "@opencode-ai/ui/tooltip" -import { JSXElement, ParentProps, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js" +import { JSXElement, ParentProps, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js" import { serverDisplayName } from "@/context/server" import type { ServerHealth } from "@/utils/server-health" @@ -17,6 +17,7 @@ export function ServerRow(props: ServerRowProps) { const [truncated, setTruncated] = createSignal(false) let nameRef: HTMLSpanElement | undefined let versionRef: HTMLSpanElement | undefined + const name = createMemo(() => serverDisplayName(props.url)) const check = () => { const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false @@ -25,25 +26,24 @@ export function ServerRow(props: ServerRowProps) { } createEffect(() => { + name() props.url props.status?.version - if (typeof requestAnimationFrame === "function") { - requestAnimationFrame(check) - return - } - check() + queueMicrotask(check) }) onMount(() => { check() - if (typeof window === "undefined") return - window.addEventListener("resize", check) - onCleanup(() => window.removeEventListener("resize", check)) + if (typeof ResizeObserver !== "function") return + const observer = new ResizeObserver(check) + if (nameRef) observer.observe(nameRef) + if (versionRef) observer.observe(versionRef) + onCleanup(() => observer.disconnect()) }) const tooltipValue = () => ( <span class="flex items-center gap-2"> - <span>{serverDisplayName(props.url)}</span> + <span>{name()}</span> <Show when={props.status?.version}> <span class="text-text-invert-base">{props.status?.version}</span> </Show> @@ -62,7 +62,7 @@ export function ServerRow(props: ServerRowProps) { }} /> <span ref={nameRef} class={props.nameClass ?? "truncate"}> - {serverDisplayName(props.url)} + {name()} </span> <Show when={props.status?.version}> <span ref={versionRef} class={props.versionClass ?? "text-text-weak text-14-regular truncate"}> diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx index 4e5dae139..8b77edf3a 100644 --- a/packages/app/src/components/session-context-usage.tsx +++ b/packages/app/src/components/session-context-usage.tsx @@ -13,6 +13,18 @@ interface SessionContextUsageProps { variant?: "button" | "indicator" } +function openSessionContext(args: { + view: ReturnType<ReturnType<typeof useLayout>["view"]> + layout: ReturnType<typeof useLayout> + tabs: ReturnType<ReturnType<typeof useLayout>["tabs"]> +}) { + if (!args.view.reviewPanel.opened()) args.view.reviewPanel.open() + args.layout.fileTree.open() + args.layout.fileTree.setTab("all") + args.tabs.open("context") + args.tabs.setActive("context") +} + export function SessionContextUsage(props: SessionContextUsageProps) { const sync = useSync() const params = useParams() @@ -41,11 +53,11 @@ export function SessionContextUsage(props: SessionContextUsageProps) { const openContext = () => { if (!params.id) return - if (!view().reviewPanel.opened()) view().reviewPanel.open() - layout.fileTree.open() - layout.fileTree.setTab("all") - tabs().open("context") - tabs().setActive("context") + openSessionContext({ + view: view(), + layout, + tabs: tabs(), + }) } const circle = () => ( diff --git a/packages/app/src/components/session/session-context-breakdown.test.ts b/packages/app/src/components/session/session-context-breakdown.test.ts new file mode 100644 index 000000000..f38aecb55 --- /dev/null +++ b/packages/app/src/components/session/session-context-breakdown.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test" +import type { Message, Part } from "@opencode-ai/sdk/v2/client" +import { estimateSessionContextBreakdown } from "./session-context-breakdown" + +const user = (id: string) => { + return { + id, + role: "user", + time: { created: 1 }, + } as unknown as Message +} + +const assistant = (id: string) => { + return { + id, + role: "assistant", + time: { created: 1 }, + } as unknown as Message +} + +describe("estimateSessionContextBreakdown", () => { + test("estimates tokens and keeps remaining tokens as other", () => { + const messages = [user("u1"), assistant("a1")] + const parts = { + u1: [{ type: "text", text: "hello world" }] as unknown as Part[], + a1: [{ type: "text", text: "assistant response" }] as unknown as Part[], + } + + const output = estimateSessionContextBreakdown({ + messages, + parts, + input: 20, + systemPrompt: "system prompt", + }) + + const map = Object.fromEntries(output.map((segment) => [segment.key, segment.tokens])) + expect(map.system).toBe(4) + expect(map.user).toBe(3) + expect(map.assistant).toBe(5) + expect(map.other).toBe(8) + }) + + test("scales segments when estimates exceed input", () => { + const messages = [user("u1"), assistant("a1")] + const parts = { + u1: [{ type: "text", text: "x".repeat(400) }] as unknown as Part[], + a1: [{ type: "text", text: "y".repeat(400) }] as unknown as Part[], + } + + const output = estimateSessionContextBreakdown({ + messages, + parts, + input: 10, + systemPrompt: "z".repeat(200), + }) + + const total = output.reduce((sum, segment) => sum + segment.tokens, 0) + expect(total).toBeLessThanOrEqual(10) + expect(output.every((segment) => segment.width <= 100)).toBeTrue() + }) +}) diff --git a/packages/app/src/components/session/session-context-breakdown.ts b/packages/app/src/components/session/session-context-breakdown.ts new file mode 100644 index 000000000..e263b2957 --- /dev/null +++ b/packages/app/src/components/session/session-context-breakdown.ts @@ -0,0 +1,132 @@ +import type { Message, Part } from "@opencode-ai/sdk/v2/client" + +export type SessionContextBreakdownKey = "system" | "user" | "assistant" | "tool" | "other" + +export type SessionContextBreakdownSegment = { + key: SessionContextBreakdownKey + tokens: number + width: number + percent: number +} + +const estimateTokens = (chars: number) => Math.ceil(chars / 4) +const toPercent = (tokens: number, input: number) => (tokens / input) * 100 +const toPercentLabel = (tokens: number, input: number) => Math.round(toPercent(tokens, input) * 10) / 10 + +const charsFromUserPart = (part: Part) => { + if (part.type === "text") return part.text.length + if (part.type === "file") return part.source?.text.value.length ?? 0 + if (part.type === "agent") return part.source?.value.length ?? 0 + return 0 +} + +const charsFromAssistantPart = (part: Part) => { + if (part.type === "text") return { assistant: part.text.length, tool: 0 } + if (part.type === "reasoning") return { assistant: part.text.length, tool: 0 } + if (part.type !== "tool") return { assistant: 0, tool: 0 } + + const input = Object.keys(part.state.input).length * 16 + if (part.state.status === "pending") return { assistant: 0, tool: input + part.state.raw.length } + if (part.state.status === "completed") return { assistant: 0, tool: input + part.state.output.length } + if (part.state.status === "error") return { assistant: 0, tool: input + part.state.error.length } + return { assistant: 0, tool: input } +} + +const build = ( + tokens: { system: number; user: number; assistant: number; tool: number; other: number }, + input: number, +) => { + return [ + { + key: "system", + tokens: tokens.system, + }, + { + key: "user", + tokens: tokens.user, + }, + { + key: "assistant", + tokens: tokens.assistant, + }, + { + key: "tool", + tokens: tokens.tool, + }, + { + key: "other", + tokens: tokens.other, + }, + ] + .filter((x) => x.tokens > 0) + .map((x) => ({ + key: x.key, + tokens: x.tokens, + width: toPercent(x.tokens, input), + percent: toPercentLabel(x.tokens, input), + })) as SessionContextBreakdownSegment[] +} + +export function estimateSessionContextBreakdown(args: { + messages: Message[] + parts: Record<string, Part[] | undefined> + input: number + systemPrompt?: string +}) { + if (!args.input) return [] + + const counts = args.messages.reduce( + (acc, msg) => { + const parts = args.parts[msg.id] ?? [] + if (msg.role === "user") { + const user = parts.reduce((sum, part) => sum + charsFromUserPart(part), 0) + return { ...acc, user: acc.user + user } + } + + if (msg.role !== "assistant") return acc + const assistant = parts.reduce( + (sum, part) => { + const next = charsFromAssistantPart(part) + return { + assistant: sum.assistant + next.assistant, + tool: sum.tool + next.tool, + } + }, + { assistant: 0, tool: 0 }, + ) + return { + ...acc, + assistant: acc.assistant + assistant.assistant, + tool: acc.tool + assistant.tool, + } + }, + { + system: args.systemPrompt?.length ?? 0, + user: 0, + assistant: 0, + tool: 0, + }, + ) + + const tokens = { + system: estimateTokens(counts.system), + user: estimateTokens(counts.user), + assistant: estimateTokens(counts.assistant), + tool: estimateTokens(counts.tool), + } + const estimated = tokens.system + tokens.user + tokens.assistant + tokens.tool + + if (estimated <= args.input) { + return build({ ...tokens, other: args.input - estimated }, args.input) + } + + const scale = args.input / estimated + const scaled = { + system: Math.floor(tokens.system * scale), + user: Math.floor(tokens.user * scale), + assistant: Math.floor(tokens.assistant * scale), + tool: Math.floor(tokens.tool * scale), + } + const total = scaled.system + scaled.user + scaled.assistant + scaled.tool + return build({ ...scaled, other: Math.max(0, args.input - total) }, args.input) +} diff --git a/packages/app/src/components/session/session-context-format.ts b/packages/app/src/components/session/session-context-format.ts new file mode 100644 index 000000000..e7c536d58 --- /dev/null +++ b/packages/app/src/components/session/session-context-format.ts @@ -0,0 +1,20 @@ +import { DateTime } from "luxon" + +export function createSessionContextFormatter(locale: string) { + return { + number(value: number | null | undefined) { + if (value === undefined) return "—" + if (value === null) return "—" + return value.toLocaleString(locale) + }, + percent(value: number | null | undefined) { + if (value === undefined) return "—" + if (value === null) return "—" + return value.toLocaleString(locale) + "%" + }, + time(value: number | undefined) { + if (!value) return "—" + return DateTime.fromMillis(value).setLocale(locale).toLocaleString(DateTime.DATETIME_MED) + }, + } +} diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx index 8aae44863..eb5b4197d 100644 --- a/packages/app/src/components/session/session-context-tab.tsx +++ b/packages/app/src/components/session/session-context-tab.tsx @@ -1,7 +1,6 @@ import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js" import type { JSX } from "solid-js" import { useParams } from "@solidjs/router" -import { DateTime } from "luxon" import { useSync } from "@/context/sync" import { useLayout } from "@/context/layout" import { checksum } from "@opencode-ai/util/encode" @@ -14,6 +13,8 @@ import { Markdown } from "@opencode-ai/ui/markdown" import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client" import { useLanguage } from "@/context/language" import { getSessionContextMetrics } from "./session-context-metrics" +import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown" +import { createSessionContextFormatter } from "./session-context-format" interface SessionContextTabProps { messages: () => Message[] @@ -22,6 +23,74 @@ interface SessionContextTabProps { info: () => ReturnType<ReturnType<typeof useSync>["session"]["get"]> } +const BREAKDOWN_COLOR: Record<SessionContextBreakdownKey, string> = { + system: "var(--syntax-info)", + user: "var(--syntax-success)", + assistant: "var(--syntax-property)", + tool: "var(--syntax-warning)", + other: "var(--syntax-comment)", +} + +function Stat(props: { label: string; value: JSX.Element }) { + return ( + <div class="flex flex-col gap-1"> + <div class="text-12-regular text-text-weak">{props.label}</div> + <div class="text-12-medium text-text-strong">{props.value}</div> + </div> + ) +} + +function RawMessageContent(props: { message: Message; getParts: (id: string) => Part[]; onRendered: () => void }) { + const file = createMemo(() => { + const parts = props.getParts(props.message.id) + const contents = JSON.stringify({ message: props.message, parts }, null, 2) + return { + name: `${props.message.role}-${props.message.id}.json`, + contents, + cacheKey: checksum(contents), + } + }) + + return ( + <Code + file={file()} + overflow="wrap" + class="select-text" + onRendered={() => requestAnimationFrame(props.onRendered)} + /> + ) +} + +function RawMessage(props: { + message: Message + getParts: (id: string) => Part[] + onRendered: () => void + time: (value: number | undefined) => string +}) { + return ( + <Accordion.Item value={props.message.id}> + <StickyAccordionHeader> + <Accordion.Trigger> + <div class="flex items-center justify-between gap-2 w-full"> + <div class="min-w-0 truncate"> + {props.message.role} <span class="text-text-base">• {props.message.id}</span> + </div> + <div class="flex items-center gap-3"> + <div class="shrink-0 text-12-regular text-text-weak">{props.time(props.message.time.created)}</div> + <Icon name="chevron-grabber-vertical" size="small" class="shrink-0 text-text-weak" /> + </div> + </div> + </Accordion.Trigger> + </StickyAccordionHeader> + <Accordion.Content class="bg-background-base"> + <div class="p-3"> + <RawMessageContent message={props.message} getParts={props.getParts} onRendered={props.onRendered} /> + </div> + </Accordion.Content> + </Accordion.Item> + ) +} + export function SessionContextTab(props: SessionContextTabProps) { const params = useParams() const sync = useSync() @@ -37,6 +106,7 @@ export function SessionContextTab(props: SessionContextTabProps) { const metrics = createMemo(() => getSessionContextMetrics(props.messages(), sync.data.provider.all)) const ctx = createMemo(() => metrics().context) + const formatter = createMemo(() => createSessionContextFormatter(language.locale())) const cost = createMemo(() => { return usd().format(metrics().totalCost) @@ -62,23 +132,6 @@ export function SessionContextTab(props: SessionContextTabProps) { return trimmed }) - const number = (value: number | null | undefined) => { - if (value === undefined) return "—" - if (value === null) return "—" - return value.toLocaleString(language.locale()) - } - - const percent = (value: number | null | undefined) => { - if (value === undefined) return "—" - if (value === null) return "—" - return value.toLocaleString(language.locale()) + "%" - } - - const time = (value: number | undefined) => { - if (!value) return "—" - return DateTime.fromMillis(value).setLocale(language.locale()).toLocaleString(DateTime.DATETIME_MED) - } - const providerLabel = createMemo(() => { const c = ctx() if (!c) return "—" @@ -96,122 +149,23 @@ export function SessionContextTab(props: SessionContextTabProps) { () => [ctx()?.message.id, ctx()?.input, props.messages().length, systemPrompt()], () => { const c = ctx() - if (!c) return [] - const input = c.input - if (!input) return [] - - const out = { - system: systemPrompt()?.length ?? 0, - user: 0, - assistant: 0, - tool: 0, - } - - for (const msg of props.messages()) { - const parts = (sync.data.part[msg.id] ?? []) as Part[] - - if (msg.role === "user") { - for (const part of parts) { - if (part.type === "text") out.user += part.text.length - if (part.type === "file") out.user += part.source?.text.value.length ?? 0 - if (part.type === "agent") out.user += part.source?.value.length ?? 0 - } - continue - } - - if (msg.role === "assistant") { - for (const part of parts) { - if (part.type === "text") out.assistant += part.text.length - if (part.type === "reasoning") out.assistant += part.text.length - if (part.type === "tool") { - out.tool += Object.keys(part.state.input).length * 16 - if (part.state.status === "pending") out.tool += part.state.raw.length - if (part.state.status === "completed") out.tool += part.state.output.length - if (part.state.status === "error") out.tool += part.state.error.length - } - } - } - } - - const estimateTokens = (chars: number) => Math.ceil(chars / 4) - const system = estimateTokens(out.system) - const user = estimateTokens(out.user) - const assistant = estimateTokens(out.assistant) - const tool = estimateTokens(out.tool) - const estimated = system + user + assistant + tool - - const pct = (tokens: number) => (tokens / input) * 100 - const pctLabel = (tokens: number) => (Math.round(pct(tokens) * 10) / 10).toString() + "%" - - const build = (tokens: { system: number; user: number; assistant: number; tool: number; other: number }) => { - return [ - { - key: "system", - label: language.t("context.breakdown.system"), - tokens: tokens.system, - width: pct(tokens.system), - percent: pctLabel(tokens.system), - color: "var(--syntax-info)", - }, - { - key: "user", - label: language.t("context.breakdown.user"), - tokens: tokens.user, - width: pct(tokens.user), - percent: pctLabel(tokens.user), - color: "var(--syntax-success)", - }, - { - key: "assistant", - label: language.t("context.breakdown.assistant"), - tokens: tokens.assistant, - width: pct(tokens.assistant), - percent: pctLabel(tokens.assistant), - color: "var(--syntax-property)", - }, - { - key: "tool", - label: language.t("context.breakdown.tool"), - tokens: tokens.tool, - width: pct(tokens.tool), - percent: pctLabel(tokens.tool), - color: "var(--syntax-warning)", - }, - { - key: "other", - label: language.t("context.breakdown.other"), - tokens: tokens.other, - width: pct(tokens.other), - percent: pctLabel(tokens.other), - color: "var(--syntax-comment)", - }, - ].filter((x) => x.tokens > 0) - } - - if (estimated <= input) { - return build({ system, user, assistant, tool, other: input - estimated }) - } - - const scale = input / estimated - const scaled = { - system: Math.floor(system * scale), - user: Math.floor(user * scale), - assistant: Math.floor(assistant * scale), - tool: Math.floor(tool * scale), - } - const scaledTotal = scaled.system + scaled.user + scaled.assistant + scaled.tool - return build({ ...scaled, other: Math.max(0, input - scaledTotal) }) + if (!c?.input) return [] + return estimateSessionContextBreakdown({ + messages: props.messages(), + parts: sync.data.part as Record<string, Part[] | undefined>, + input: c.input, + systemPrompt: systemPrompt(), + }) }, ), ) - function Stat(statProps: { label: string; value: JSX.Element }) { - return ( - <div class="flex flex-col gap-1"> - <div class="text-12-regular text-text-weak">{statProps.label}</div> - <div class="text-12-medium text-text-strong">{statProps.value}</div> - </div> - ) + const breakdownLabel = (key: SessionContextBreakdownKey) => { + if (key === "system") return language.t("context.breakdown.system") + if (key === "user") return language.t("context.breakdown.user") + if (key === "assistant") return language.t("context.breakdown.assistant") + if (key === "tool") return language.t("context.breakdown.tool") + return language.t("context.breakdown.other") } const stats = createMemo(() => { @@ -222,15 +176,15 @@ export function SessionContextTab(props: SessionContextTabProps) { { label: language.t("context.stats.messages"), value: count.all.toLocaleString(language.locale()) }, { label: language.t("context.stats.provider"), value: providerLabel() }, { label: language.t("context.stats.model"), value: modelLabel() }, - { label: language.t("context.stats.limit"), value: number(c?.limit) }, - { label: language.t("context.stats.totalTokens"), value: number(c?.total) }, - { label: language.t("context.stats.usage"), value: percent(c?.usage) }, - { label: language.t("context.stats.inputTokens"), value: number(c?.input) }, - { label: language.t("context.stats.outputTokens"), value: number(c?.output) }, - { label: language.t("context.stats.reasoningTokens"), value: number(c?.reasoning) }, + { label: language.t("context.stats.limit"), value: formatter().number(c?.limit) }, + { label: language.t("context.stats.totalTokens"), value: formatter().number(c?.total) }, + { label: language.t("context.stats.usage"), value: formatter().percent(c?.usage) }, + { label: language.t("context.stats.inputTokens"), value: formatter().number(c?.input) }, + { label: language.t("context.stats.outputTokens"), value: formatter().number(c?.output) }, + { label: language.t("context.stats.reasoningTokens"), value: formatter().number(c?.reasoning) }, { label: language.t("context.stats.cacheTokens"), - value: `${number(c?.cacheRead)} / ${number(c?.cacheWrite)}`, + value: `${formatter().number(c?.cacheRead)} / ${formatter().number(c?.cacheWrite)}`, }, { label: language.t("context.stats.userMessages"), value: count.user.toLocaleString(language.locale()) }, { @@ -238,55 +192,15 @@ export function SessionContextTab(props: SessionContextTabProps) { value: count.assistant.toLocaleString(language.locale()), }, { label: language.t("context.stats.totalCost"), value: cost() }, - { label: language.t("context.stats.sessionCreated"), value: time(props.info()?.time.created) }, - { label: language.t("context.stats.lastActivity"), value: time(c?.message.time.created) }, + { label: language.t("context.stats.sessionCreated"), value: formatter().time(props.info()?.time.created) }, + { label: language.t("context.stats.lastActivity"), value: formatter().time(c?.message.time.created) }, ] satisfies { label: string; value: JSX.Element }[] }) - function RawMessageContent(msgProps: { message: Message }) { - const file = createMemo(() => { - const parts = (sync.data.part[msgProps.message.id] ?? []) as Part[] - const contents = JSON.stringify({ message: msgProps.message, parts }, null, 2) - return { - name: `${msgProps.message.role}-${msgProps.message.id}.json`, - contents, - cacheKey: checksum(contents), - } - }) - - return ( - <Code file={file()} overflow="wrap" class="select-text" onRendered={() => requestAnimationFrame(restoreScroll)} /> - ) - } - - function RawMessage(msgProps: { message: Message }) { - return ( - <Accordion.Item value={msgProps.message.id}> - <StickyAccordionHeader> - <Accordion.Trigger> - <div class="flex items-center justify-between gap-2 w-full"> - <div class="min-w-0 truncate"> - {msgProps.message.role} <span class="text-text-base">• {msgProps.message.id}</span> - </div> - <div class="flex items-center gap-3"> - <div class="shrink-0 text-12-regular text-text-weak">{time(msgProps.message.time.created)}</div> - <Icon name="chevron-grabber-vertical" size="small" class="shrink-0 text-text-weak" /> - </div> - </div> - </Accordion.Trigger> - </StickyAccordionHeader> - <Accordion.Content class="bg-background-base"> - <div class="p-3"> - <RawMessageContent message={msgProps.message} /> - </div> - </Accordion.Content> - </Accordion.Item> - ) - } - let scroll: HTMLDivElement | undefined let frame: number | undefined let pending: { x: number; y: number } | undefined + const getParts = (id: string) => (sync.data.part[id] ?? []) as Part[] const restoreScroll = () => { const el = scroll @@ -356,7 +270,7 @@ export function SessionContextTab(props: SessionContextTabProps) { class="h-full" style={{ width: `${segment.width}%`, - "background-color": segment.color, + "background-color": BREAKDOWN_COLOR[segment.key], }} /> )} @@ -366,9 +280,9 @@ export function SessionContextTab(props: SessionContextTabProps) { <For each={breakdown()}> {(segment) => ( <div class="flex items-center gap-1 text-11-regular text-text-weak"> - <div class="size-2 rounded-sm" style={{ "background-color": segment.color }} /> - <div>{segment.label}</div> - <div class="text-text-weaker">{segment.percent}</div> + <div class="size-2 rounded-sm" style={{ "background-color": BREAKDOWN_COLOR[segment.key] }} /> + <div>{breakdownLabel(segment.key)}</div> + <div class="text-text-weaker">{segment.percent.toLocaleString(language.locale())}%</div> </div> )} </For> @@ -391,7 +305,11 @@ export function SessionContextTab(props: SessionContextTabProps) { <div class="flex flex-col gap-2"> <div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div> <Accordion multiple> - <For each={props.messages()}>{(message) => <RawMessage message={message} />}</For> + <For each={props.messages()}> + {(message) => ( + <RawMessage message={message} getParts={getParts} onRendered={restoreScroll} time={formatter().time} /> + )} + </For> </Accordion> </div> </div> diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 54e24a6fb..c1468ce37 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -25,6 +25,164 @@ import { Keybind } from "@opencode-ai/ui/keybind" import { showToast } from "@opencode-ai/ui/toast" import { StatusPopover } from "../status-popover" +const OPEN_APPS = [ + "vscode", + "cursor", + "zed", + "textmate", + "antigravity", + "finder", + "terminal", + "iterm2", + "ghostty", + "xcode", + "android-studio", + "powershell", + "sublime-text", +] as const + +type OpenApp = (typeof OPEN_APPS)[number] +type OS = "macos" | "windows" | "linux" | "unknown" + +const MAC_APPS = [ + { id: "vscode", label: "VS Code", icon: "vscode", openWith: "Visual Studio Code" }, + { id: "cursor", label: "Cursor", icon: "cursor", openWith: "Cursor" }, + { id: "zed", label: "Zed", icon: "zed", openWith: "Zed" }, + { id: "textmate", label: "TextMate", icon: "textmate", openWith: "TextMate" }, + { id: "antigravity", label: "Antigravity", icon: "antigravity", openWith: "Antigravity" }, + { id: "terminal", label: "Terminal", icon: "terminal", openWith: "Terminal" }, + { id: "iterm2", label: "iTerm2", icon: "iterm2", openWith: "iTerm" }, + { id: "ghostty", label: "Ghostty", icon: "ghostty", openWith: "Ghostty" }, + { id: "xcode", label: "Xcode", icon: "xcode", openWith: "Xcode" }, + { id: "android-studio", label: "Android Studio", icon: "android-studio", openWith: "Android Studio" }, + { id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" }, +] as const + +const WINDOWS_APPS = [ + { id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" }, + { id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" }, + { id: "zed", label: "Zed", icon: "zed", openWith: "zed" }, + { id: "powershell", label: "PowerShell", icon: "powershell", openWith: "powershell" }, + { id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" }, +] as const + +const LINUX_APPS = [ + { id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" }, + { id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" }, + { id: "zed", label: "Zed", icon: "zed", openWith: "zed" }, + { id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" }, +] as const + +type OpenOption = (typeof MAC_APPS)[number] | (typeof WINDOWS_APPS)[number] | (typeof LINUX_APPS)[number] +type OpenIcon = OpenApp | "file-explorer" +const OPEN_ICON_BASE = new Set<OpenIcon>(["finder", "vscode", "cursor", "zed"]) + +const openIconSize = (id: OpenIcon) => (OPEN_ICON_BASE.has(id) ? "size-4" : "size-[19px]") + +const detectOS = (platform: ReturnType<typeof usePlatform>): OS => { + if (platform.platform === "desktop" && platform.os) return platform.os + if (typeof navigator !== "object") return "unknown" + const value = navigator.platform || navigator.userAgent + if (/Mac/i.test(value)) return "macos" + if (/Win/i.test(value)) return "windows" + if (/Linux/i.test(value)) return "linux" + return "unknown" +} + +const showRequestError = (language: ReturnType<typeof useLanguage>, err: unknown) => { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: err instanceof Error ? err.message : String(err), + }) +} + +function useSessionShare(args: { + globalSDK: ReturnType<typeof useGlobalSDK> + currentSession: () => + | { + id: string + share?: { + url?: string + } + } + | undefined + projectDirectory: () => string + platform: ReturnType<typeof usePlatform> +}) { + const [state, setState] = createStore({ + share: false, + unshare: false, + copied: false, + timer: undefined as number | undefined, + }) + const shareUrl = createMemo(() => args.currentSession()?.share?.url) + + createEffect(() => { + const url = shareUrl() + if (url) return + if (state.timer) window.clearTimeout(state.timer) + setState({ copied: false, timer: undefined }) + }) + + onCleanup(() => { + if (state.timer) window.clearTimeout(state.timer) + }) + + const shareSession = () => { + const session = args.currentSession() + if (!session || state.share) return + setState("share", true) + args.globalSDK.client.session + .share({ sessionID: session.id, directory: args.projectDirectory() }) + .catch((error) => { + console.error("Failed to share session", error) + }) + .finally(() => { + setState("share", false) + }) + } + + const unshareSession = () => { + const session = args.currentSession() + if (!session || state.unshare) return + setState("unshare", true) + args.globalSDK.client.session + .unshare({ sessionID: session.id, directory: args.projectDirectory() }) + .catch((error) => { + console.error("Failed to unshare session", error) + }) + .finally(() => { + setState("unshare", false) + }) + } + + const copyLink = (onError: (error: unknown) => void) => { + const url = shareUrl() + if (!url) return + navigator.clipboard + .writeText(url) + .then(() => { + if (state.timer) window.clearTimeout(state.timer) + setState("copied", true) + const timer = window.setTimeout(() => { + setState("copied", false) + setState("timer", undefined) + }, 3000) + setState("timer", timer) + }) + .catch(onError) + } + + const viewShare = () => { + const url = shareUrl() + if (!url) return + args.platform.openLink(url) + } + + return { state, shareUrl, shareSession, unshareSession, copyLink, viewShare } +} + export function SessionHeader() { const globalSDK = useGlobalSDK() const layout = useLayout() @@ -53,62 +211,7 @@ export function SessionHeader() { const showShare = createMemo(() => shareEnabled() && !!currentSession()) const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const view = createMemo(() => layout.view(sessionKey)) - - const OPEN_APPS = [ - "vscode", - "cursor", - "zed", - "textmate", - "antigravity", - "finder", - "terminal", - "iterm2", - "ghostty", - "xcode", - "android-studio", - "powershell", - "sublime-text", - ] as const - type OpenApp = (typeof OPEN_APPS)[number] - - const MAC_APPS = [ - { id: "vscode", label: "VS Code", icon: "vscode", openWith: "Visual Studio Code" }, - { id: "cursor", label: "Cursor", icon: "cursor", openWith: "Cursor" }, - { id: "zed", label: "Zed", icon: "zed", openWith: "Zed" }, - { id: "textmate", label: "TextMate", icon: "textmate", openWith: "TextMate" }, - { id: "antigravity", label: "Antigravity", icon: "antigravity", openWith: "Antigravity" }, - { id: "terminal", label: "Terminal", icon: "terminal", openWith: "Terminal" }, - { id: "iterm2", label: "iTerm2", icon: "iterm2", openWith: "iTerm" }, - { id: "ghostty", label: "Ghostty", icon: "ghostty", openWith: "Ghostty" }, - { id: "xcode", label: "Xcode", icon: "xcode", openWith: "Xcode" }, - { id: "android-studio", label: "Android Studio", icon: "android-studio", openWith: "Android Studio" }, - { id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" }, - ] as const - - const WINDOWS_APPS = [ - { id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" }, - { id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" }, - { id: "zed", label: "Zed", icon: "zed", openWith: "zed" }, - { id: "powershell", label: "PowerShell", icon: "powershell", openWith: "powershell" }, - { id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" }, - ] as const - - const LINUX_APPS = [ - { id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" }, - { id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" }, - { id: "zed", label: "Zed", icon: "zed", openWith: "zed" }, - { id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" }, - ] as const - - const os = createMemo<"macos" | "windows" | "linux" | "unknown">(() => { - if (platform.platform === "desktop" && platform.os) return platform.os - if (typeof navigator !== "object") return "unknown" - const value = navigator.platform || navigator.userAgent - if (/Mac/i.test(value)) return "macos" - if (/Win/i.test(value)) return "windows" - if (/Linux/i.test(value)) return "linux" - return "unknown" - }) + const os = createMemo(() => detectOS(platform)) const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({ finder: true }) @@ -154,10 +257,6 @@ export function SessionHeader() { ] as const }) - type OpenIcon = OpenApp | "file-explorer" - const base = new Set<OpenIcon>(["finder", "vscode", "cursor", "zed"]) - const size = (id: OpenIcon) => (base.has(id) ? "size-4" : "size-[19px]") - const checksReady = createMemo(() => { if (platform.platform !== "desktop") return true if (!platform.checkAppExists) return true @@ -186,13 +285,7 @@ export function SessionHeader() { const item = options().find((o) => o.id === app) const openWith = item && "openWith" in item ? item.openWith : undefined - Promise.resolve(platform.openPath?.(directory, openWith)).catch((err: unknown) => { - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: err instanceof Error ? err.message : String(err), - }) - }) + Promise.resolve(platform.openPath?.(directory, openWith)).catch((err: unknown) => showRequestError(language, err)) } const copyPath = () => { @@ -208,87 +301,16 @@ export function SessionHeader() { description: directory, }) }) - .catch((err: unknown) => { - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: err instanceof Error ? err.message : String(err), - }) - }) + .catch((err: unknown) => showRequestError(language, err)) } - const [state, setState] = createStore({ - share: false, - unshare: false, - copied: false, - timer: undefined as number | undefined, - }) - const shareUrl = createMemo(() => currentSession()?.share?.url) - - createEffect(() => { - const url = shareUrl() - if (url) return - if (state.timer) window.clearTimeout(state.timer) - setState({ copied: false, timer: undefined }) - }) - - onCleanup(() => { - if (state.timer) window.clearTimeout(state.timer) + const share = useSessionShare({ + globalSDK, + currentSession, + projectDirectory, + platform, }) - function shareSession() { - const session = currentSession() - if (!session || state.share) return - setState("share", true) - globalSDK.client.session - .share({ sessionID: session.id, directory: projectDirectory() }) - .catch((error) => { - console.error("Failed to share session", error) - }) - .finally(() => { - setState("share", false) - }) - } - - function unshareSession() { - const session = currentSession() - if (!session || state.unshare) return - setState("unshare", true) - globalSDK.client.session - .unshare({ sessionID: session.id, directory: projectDirectory() }) - .catch((error) => { - console.error("Failed to unshare session", error) - }) - .finally(() => { - setState("unshare", false) - }) - } - - function copyLink() { - const url = shareUrl() - if (!url) return - navigator.clipboard - .writeText(url) - .then(() => { - if (state.timer) window.clearTimeout(state.timer) - setState("copied", true) - const timer = window.setTimeout(() => { - setState("copied", false) - setState("timer", undefined) - }, 3000) - setState("timer", timer) - }) - .catch((error) => { - console.error("Failed to copy share link", error) - }) - } - - function viewShare() { - const url = shareUrl() - if (!url) return - platform.openLink(url) - } - const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center")) const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right")) @@ -391,7 +413,7 @@ export function SessionHeader() { }} > <div class="flex size-5 shrink-0 items-center justify-center"> - <AppIcon id={o.icon} class={size(o.icon)} /> + <AppIcon id={o.icon} class={openIconSize(o.icon)} /> </div> <DropdownMenu.ItemLabel>{o.label}</DropdownMenu.ItemLabel> <DropdownMenu.ItemIndicator> @@ -428,7 +450,7 @@ export function SessionHeader() { <Popover title={language.t("session.share.popover.title")} description={ - shareUrl() + share.shareUrl() ? language.t("session.share.popover.description.shared") : language.t("session.share.popover.description.unshared") } @@ -441,24 +463,24 @@ export function SessionHeader() { variant: "ghost", class: "rounded-md h-[24px] px-3 border border-border-base bg-surface-panel shadow-none data-[expanded]:bg-surface-raised-base-active", - classList: { "rounded-r-none": shareUrl() !== undefined }, + classList: { "rounded-r-none": share.shareUrl() !== undefined }, style: { scale: 1 }, }} trigger={language.t("session.share.action.share")} > <div class="flex flex-col gap-2"> <Show - when={shareUrl()} + when={share.shareUrl()} fallback={ <div class="flex"> <Button size="large" variant="primary" class="w-1/2" - onClick={shareSession} - disabled={state.share} + onClick={share.shareSession} + disabled={share.state.share} > - {state.share + {share.state.share ? language.t("session.share.action.publishing") : language.t("session.share.action.publish")} </Button> @@ -467,7 +489,7 @@ export function SessionHeader() { > <div class="flex flex-col gap-2"> <TextField - value={shareUrl() ?? ""} + value={share.shareUrl() ?? ""} readOnly copyable copyKind="link" @@ -479,10 +501,10 @@ export function SessionHeader() { size="large" variant="secondary" class="w-full shadow-none border border-border-weak-base" - onClick={unshareSession} - disabled={state.unshare} + onClick={share.unshareSession} + disabled={share.state.unshare} > - {state.unshare + {share.state.unshare ? language.t("session.share.action.unpublishing") : language.t("session.share.action.unpublish")} </Button> @@ -490,8 +512,8 @@ export function SessionHeader() { size="large" variant="primary" class="w-full" - onClick={viewShare} - disabled={state.unshare} + onClick={share.viewShare} + disabled={share.state.unshare} > {language.t("session.share.action.view")} </Button> @@ -500,10 +522,10 @@ export function SessionHeader() { </Show> </div> </Popover> - <Show when={shareUrl()} fallback={<div aria-hidden="true" />}> + <Show when={share.shareUrl()} fallback={<div aria-hidden="true" />}> <Tooltip value={ - state.copied + share.state.copied ? language.t("session.share.copy.copied") : language.t("session.share.copy.copyLink") } @@ -511,13 +533,13 @@ export function SessionHeader() { gutter={8} > <IconButton - icon={state.copied ? "check" : "link"} + icon={share.state.copied ? "check" : "link"} variant="ghost" class="rounded-l-none h-[24px] border border-border-base bg-surface-panel shadow-none" - onClick={copyLink} - disabled={state.unshare} + onClick={() => share.copyLink((error) => showRequestError(language, error))} + disabled={share.state.unshare} aria-label={ - state.copied + share.state.copied ? language.t("session.share.copy.copied") : language.t("session.share.copy.copyLink") } diff --git a/packages/app/src/components/session/session-new-view.tsx b/packages/app/src/components/session/session-new-view.tsx index 480cd58c1..ab96652d4 100644 --- a/packages/app/src/components/session/session-new-view.tsx +++ b/packages/app/src/components/session/session-new-view.tsx @@ -8,6 +8,8 @@ import { getDirectory, getFilename } from "@opencode-ai/util/path" const MAIN_WORKTREE = "main" const CREATE_WORKTREE = "create" +const ROOT_CLASS = + "size-full flex flex-col justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto 2xl:max-w-[1000px] px-6 pb-[calc(var(--prompt-height,11.25rem)+64px)]" interface NewSessionViewProps { worktree: string @@ -47,7 +49,7 @@ export function NewSessionView(props: NewSessionViewProps) { } return ( - <div class="size-full flex flex-col justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto 2xl:max-w-[1000px] px-6 pb-[calc(var(--prompt-height,11.25rem)+64px)]"> + <div class={ROOT_CLASS}> <div class="text-20-medium text-text-weaker">{language.t("command.session.new")}</div> <div class="flex justify-center items-center gap-3"> <Icon name="folder" size="small" /> diff --git a/packages/app/src/components/session/session-sortable-tab.tsx b/packages/app/src/components/session/session-sortable-tab.tsx index 516f3c8ed..b94e7a8e9 100644 --- a/packages/app/src/components/session/session-sortable-tab.tsx +++ b/packages/app/src/components/session/session-sortable-tab.tsx @@ -31,8 +31,12 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v const command = useCommand() const sortable = createSortable(props.tab) const path = createMemo(() => file.pathFromTab(props.tab)) + const content = createMemo(() => { + const value = path() + if (!value) return + return <FileVisual path={value} /> + }) return ( - // @ts-ignore <div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}> <div class="relative h-full"> <Tabs.Trigger @@ -55,7 +59,7 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v hideCloseButton onMiddleClick={() => props.onTabClose(props.tab)} > - <Show when={path()}>{(p) => <FileVisual path={p()} />}</Show> + <Show when={content()}>{(value) => value()}</Show> </Tabs.Trigger> </div> </div> diff --git a/packages/app/src/components/session/session-sortable-terminal-tab.tsx b/packages/app/src/components/session/session-sortable-terminal-tab.tsx index aedf67876..6fe6186d5 100644 --- a/packages/app/src/components/session/session-sortable-terminal-tab.tsx +++ b/packages/app/src/components/session/session-sortable-terminal-tab.tsx @@ -1,5 +1,5 @@ import type { JSX } from "solid-js" -import { Show } from "solid-js" +import { Show, createEffect, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { createSortable } from "@thisbeyond/solid-dnd" import { IconButton } from "@opencode-ai/ui/icon-button" @@ -20,6 +20,8 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () => menuPosition: { x: 0, y: 0 }, blurEnabled: false, }) + let input: HTMLInputElement | undefined + let blurFrame: number | undefined const isDefaultTitle = () => { const number = props.terminal.titleNumber @@ -77,13 +79,6 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () => setStore("blurEnabled", false) setStore("title", props.terminal.title) setStore("editing", true) - setTimeout(() => { - const input = document.getElementById(`terminal-title-input-${props.terminal.id}`) as HTMLInputElement - if (!input) return - input.focus() - input.select() - setTimeout(() => setStore("blurEnabled", true), 100) - }, 10) } const save = () => { @@ -114,9 +109,25 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () => setStore("menuOpen", true) } + createEffect(() => { + if (!store.editing) return + if (!input) return + input.focus() + input.select() + if (blurFrame !== undefined) cancelAnimationFrame(blurFrame) + blurFrame = requestAnimationFrame(() => { + blurFrame = undefined + setStore("blurEnabled", true) + }) + }) + + onCleanup(() => { + if (blurFrame === undefined) return + cancelAnimationFrame(blurFrame) + }) + return ( <div - // @ts-ignore use:sortable class="outline-none focus:outline-none focus-visible:outline-none" classList={{ @@ -153,7 +164,7 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () => <Show when={store.editing}> <div class="absolute inset-0 flex items-center px-3 bg-muted z-10 pointer-events-auto"> <input - id={`terminal-title-input-${props.terminal.id}`} + ref={input} type="text" value={store.title} onInput={(e) => setStore("title", e.currentTarget.value)} diff --git a/packages/app/src/components/settings-agents.tsx b/packages/app/src/components/settings-agents.tsx index e68f1e59c..74a942f77 100644 --- a/packages/app/src/components/settings-agents.tsx +++ b/packages/app/src/components/settings-agents.tsx @@ -2,6 +2,7 @@ import { Component } from "solid-js" import { useLanguage } from "@/context/language" export const SettingsAgents: Component = () => { + // TODO: Replace this placeholder with full agents settings controls. const language = useLanguage() return ( diff --git a/packages/app/src/components/settings-commands.tsx b/packages/app/src/components/settings-commands.tsx index cf796d0aa..e158d231c 100644 --- a/packages/app/src/components/settings-commands.tsx +++ b/packages/app/src/components/settings-commands.tsx @@ -2,6 +2,7 @@ import { Component } from "solid-js" import { useLanguage } from "@/context/language" export const SettingsCommands: Component = () => { + // TODO: Replace this placeholder with full commands settings controls. const language = useLanguage() return ( diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index 72135c342..c673cab80 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -1,4 +1,4 @@ -import { Component, Show, createEffect, createMemo, createResource, type JSX } from "solid-js" +import { Component, Show, createMemo, createResource, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" @@ -133,6 +133,261 @@ export const SettingsGeneral: Component = () => { const soundOptions = [...SOUND_OPTIONS] + const soundSelectProps = (current: () => string, set: (id: string) => void) => ({ + options: soundOptions, + current: soundOptions.find((o) => o.id === current()), + value: (o: (typeof soundOptions)[number]) => o.id, + label: (o: (typeof soundOptions)[number]) => language.t(o.label), + onHighlight: (option: (typeof soundOptions)[number] | undefined) => { + if (!option) return + playDemoSound(option.src) + }, + onSelect: (option: (typeof soundOptions)[number] | undefined) => { + if (!option) return + set(option.id) + playDemoSound(option.src) + }, + variant: "secondary" as const, + size: "small" as const, + triggerVariant: "settings" as const, + }) + + const AppearanceSection = () => ( + <div class="flex flex-col gap-1"> + <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.appearance")}</h3> + + <div class="bg-surface-raised-base px-4 rounded-lg"> + <SettingsRow + title={language.t("settings.general.row.language.title")} + description={language.t("settings.general.row.language.description")} + > + <Select + data-action="settings-language" + options={languageOptions()} + current={languageOptions().find((o) => o.value === language.locale())} + value={(o) => o.value} + label={(o) => o.label} + onSelect={(option) => option && language.setLocale(option.value)} + variant="secondary" + size="small" + triggerVariant="settings" + /> + </SettingsRow> + + <SettingsRow + title={language.t("settings.general.row.appearance.title")} + description={language.t("settings.general.row.appearance.description")} + > + <Select + data-action="settings-color-scheme" + options={colorSchemeOptions()} + current={colorSchemeOptions().find((o) => o.value === theme.colorScheme())} + value={(o) => o.value} + label={(o) => o.label} + onSelect={(option) => option && theme.setColorScheme(option.value)} + onHighlight={(option) => { + if (!option) return + theme.previewColorScheme(option.value) + return () => theme.cancelPreview() + }} + variant="secondary" + size="small" + triggerVariant="settings" + /> + </SettingsRow> + + <SettingsRow + title={language.t("settings.general.row.theme.title")} + description={ + <> + {language.t("settings.general.row.theme.description")}{" "} + <Link href="https://opencode.ai/docs/themes/">{language.t("common.learnMore")}</Link> + </> + } + > + <Select + data-action="settings-theme" + options={themeOptions()} + current={themeOptions().find((o) => o.id === theme.themeId())} + value={(o) => o.id} + label={(o) => o.name} + onSelect={(option) => { + if (!option) return + theme.setTheme(option.id) + }} + onHighlight={(option) => { + if (!option) return + theme.previewTheme(option.id) + return () => theme.cancelPreview() + }} + variant="secondary" + size="small" + triggerVariant="settings" + /> + </SettingsRow> + + <SettingsRow + title={language.t("settings.general.row.font.title")} + description={language.t("settings.general.row.font.description")} + > + <Select + data-action="settings-font" + options={fontOptionsList} + current={fontOptionsList.find((o) => o.value === settings.appearance.font())} + value={(o) => o.value} + label={(o) => language.t(o.label)} + onSelect={(option) => option && settings.appearance.setFont(option.value)} + variant="secondary" + size="small" + triggerVariant="settings" + triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "min-width": "180px" }} + > + {(option) => ( + <span style={{ "font-family": monoFontFamily(option?.value) }}> + {option ? language.t(option.label) : ""} + </span> + )} + </Select> + </SettingsRow> + </div> + </div> + ) + + const NotificationsSection = () => ( + <div class="flex flex-col gap-1"> + <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.notifications")}</h3> + + <div class="bg-surface-raised-base px-4 rounded-lg"> + <SettingsRow + title={language.t("settings.general.notifications.agent.title")} + description={language.t("settings.general.notifications.agent.description")} + > + <div data-action="settings-notifications-agent"> + <Switch + checked={settings.notifications.agent()} + onChange={(checked) => settings.notifications.setAgent(checked)} + /> + </div> + </SettingsRow> + + <SettingsRow + title={language.t("settings.general.notifications.permissions.title")} + description={language.t("settings.general.notifications.permissions.description")} + > + <div data-action="settings-notifications-permissions"> + <Switch + checked={settings.notifications.permissions()} + onChange={(checked) => settings.notifications.setPermissions(checked)} + /> + </div> + </SettingsRow> + + <SettingsRow + title={language.t("settings.general.notifications.errors.title")} + description={language.t("settings.general.notifications.errors.description")} + > + <div data-action="settings-notifications-errors"> + <Switch + checked={settings.notifications.errors()} + onChange={(checked) => settings.notifications.setErrors(checked)} + /> + </div> + </SettingsRow> + </div> + </div> + ) + + const SoundsSection = () => ( + <div class="flex flex-col gap-1"> + <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.sounds")}</h3> + + <div class="bg-surface-raised-base px-4 rounded-lg"> + <SettingsRow + title={language.t("settings.general.sounds.agent.title")} + description={language.t("settings.general.sounds.agent.description")} + > + <Select + data-action="settings-sounds-agent" + {...soundSelectProps( + () => settings.sounds.agent(), + (id) => settings.sounds.setAgent(id), + )} + /> + </SettingsRow> + + <SettingsRow + title={language.t("settings.general.sounds.permissions.title")} + description={language.t("settings.general.sounds.permissions.description")} + > + <Select + data-action="settings-sounds-permissions" + {...soundSelectProps( + () => settings.sounds.permissions(), + (id) => settings.sounds.setPermissions(id), + )} + /> + </SettingsRow> + + <SettingsRow + title={language.t("settings.general.sounds.errors.title")} + description={language.t("settings.general.sounds.errors.description")} + > + <Select + data-action="settings-sounds-errors" + {...soundSelectProps( + () => settings.sounds.errors(), + (id) => settings.sounds.setErrors(id), + )} + /> + </SettingsRow> + </div> + </div> + ) + + const UpdatesSection = () => ( + <div class="flex flex-col gap-1"> + <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.updates")}</h3> + + <div class="bg-surface-raised-base px-4 rounded-lg"> + <SettingsRow + title={language.t("settings.updates.row.startup.title")} + description={language.t("settings.updates.row.startup.description")} + > + <div data-action="settings-updates-startup"> + <Switch + checked={settings.updates.startup()} + disabled={!platform.checkUpdate} + onChange={(checked) => settings.updates.setStartup(checked)} + /> + </div> + </SettingsRow> + + <SettingsRow + title={language.t("settings.general.row.releaseNotes.title")} + description={language.t("settings.general.row.releaseNotes.description")} + > + <div data-action="settings-release-notes"> + <Switch + checked={settings.general.releaseNotes()} + onChange={(checked) => settings.general.setReleaseNotes(checked)} + /> + </div> + </SettingsRow> + + <SettingsRow + title={language.t("settings.updates.row.check.title")} + description={language.t("settings.updates.row.check.description")} + > + <Button size="small" variant="secondary" disabled={store.checking || !platform.checkUpdate} onClick={check}> + {store.checking + ? language.t("settings.updates.action.checking") + : language.t("settings.updates.action.checkNow")} + </Button> + </SettingsRow> + </div> + </div> + ) + return ( <div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10"> <div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]"> @@ -142,230 +397,11 @@ export const SettingsGeneral: Component = () => { </div> <div class="flex flex-col gap-8 w-full"> - {/* Appearance Section */} - <div class="flex flex-col gap-1"> - <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.appearance")}</h3> - - <div class="bg-surface-raised-base px-4 rounded-lg"> - <SettingsRow - title={language.t("settings.general.row.language.title")} - description={language.t("settings.general.row.language.description")} - > - <Select - data-action="settings-language" - options={languageOptions()} - current={languageOptions().find((o) => o.value === language.locale())} - value={(o) => o.value} - label={(o) => o.label} - onSelect={(option) => option && language.setLocale(option.value)} - variant="secondary" - size="small" - triggerVariant="settings" - /> - </SettingsRow> - - <SettingsRow - title={language.t("settings.general.row.appearance.title")} - description={language.t("settings.general.row.appearance.description")} - > - <Select - data-action="settings-color-scheme" - options={colorSchemeOptions()} - current={colorSchemeOptions().find((o) => o.value === theme.colorScheme())} - value={(o) => o.value} - label={(o) => o.label} - onSelect={(option) => option && theme.setColorScheme(option.value)} - onHighlight={(option) => { - if (!option) return - theme.previewColorScheme(option.value) - return () => theme.cancelPreview() - }} - variant="secondary" - size="small" - triggerVariant="settings" - /> - </SettingsRow> - - <SettingsRow - title={language.t("settings.general.row.theme.title")} - description={ - <> - {language.t("settings.general.row.theme.description")}{" "} - <Link href="https://opencode.ai/docs/themes/">{language.t("common.learnMore")}</Link> - </> - } - > - <Select - data-action="settings-theme" - options={themeOptions()} - current={themeOptions().find((o) => o.id === theme.themeId())} - value={(o) => o.id} - label={(o) => o.name} - onSelect={(option) => { - if (!option) return - theme.setTheme(option.id) - }} - onHighlight={(option) => { - if (!option) return - theme.previewTheme(option.id) - return () => theme.cancelPreview() - }} - variant="secondary" - size="small" - triggerVariant="settings" - /> - </SettingsRow> - - <SettingsRow - title={language.t("settings.general.row.font.title")} - description={language.t("settings.general.row.font.description")} - > - <Select - data-action="settings-font" - options={fontOptionsList} - current={fontOptionsList.find((o) => o.value === settings.appearance.font())} - value={(o) => o.value} - label={(o) => language.t(o.label)} - onSelect={(option) => option && settings.appearance.setFont(option.value)} - variant="secondary" - size="small" - triggerVariant="settings" - triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "min-width": "180px" }} - > - {(option) => ( - <span style={{ "font-family": monoFontFamily(option?.value) }}> - {option ? language.t(option.label) : ""} - </span> - )} - </Select> - </SettingsRow> - </div> - </div> + <AppearanceSection /> - {/* System notifications Section */} - <div class="flex flex-col gap-1"> - <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.notifications")}</h3> - - <div class="bg-surface-raised-base px-4 rounded-lg"> - <SettingsRow - title={language.t("settings.general.notifications.agent.title")} - description={language.t("settings.general.notifications.agent.description")} - > - <div data-action="settings-notifications-agent"> - <Switch - checked={settings.notifications.agent()} - onChange={(checked) => settings.notifications.setAgent(checked)} - /> - </div> - </SettingsRow> - - <SettingsRow - title={language.t("settings.general.notifications.permissions.title")} - description={language.t("settings.general.notifications.permissions.description")} - > - <div data-action="settings-notifications-permissions"> - <Switch - checked={settings.notifications.permissions()} - onChange={(checked) => settings.notifications.setPermissions(checked)} - /> - </div> - </SettingsRow> - - <SettingsRow - title={language.t("settings.general.notifications.errors.title")} - description={language.t("settings.general.notifications.errors.description")} - > - <div data-action="settings-notifications-errors"> - <Switch - checked={settings.notifications.errors()} - onChange={(checked) => settings.notifications.setErrors(checked)} - /> - </div> - </SettingsRow> - </div> - </div> + <NotificationsSection /> - {/* Sound effects Section */} - <div class="flex flex-col gap-1"> - <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.sounds")}</h3> - - <div class="bg-surface-raised-base px-4 rounded-lg"> - <SettingsRow - title={language.t("settings.general.sounds.agent.title")} - description={language.t("settings.general.sounds.agent.description")} - > - <Select - data-action="settings-sounds-agent" - options={soundOptions} - current={soundOptions.find((o) => o.id === settings.sounds.agent())} - value={(o) => o.id} - label={(o) => language.t(o.label)} - onHighlight={(option) => { - if (!option) return - playDemoSound(option.src) - }} - onSelect={(option) => { - if (!option) return - settings.sounds.setAgent(option.id) - playDemoSound(option.src) - }} - variant="secondary" - size="small" - triggerVariant="settings" - /> - </SettingsRow> - - <SettingsRow - title={language.t("settings.general.sounds.permissions.title")} - description={language.t("settings.general.sounds.permissions.description")} - > - <Select - data-action="settings-sounds-permissions" - options={soundOptions} - current={soundOptions.find((o) => o.id === settings.sounds.permissions())} - value={(o) => o.id} - label={(o) => language.t(o.label)} - onHighlight={(option) => { - if (!option) return - playDemoSound(option.src) - }} - onSelect={(option) => { - if (!option) return - settings.sounds.setPermissions(option.id) - playDemoSound(option.src) - }} - variant="secondary" - size="small" - triggerVariant="settings" - /> - </SettingsRow> - - <SettingsRow - title={language.t("settings.general.sounds.errors.title")} - description={language.t("settings.general.sounds.errors.description")} - > - <Select - data-action="settings-sounds-errors" - options={soundOptions} - current={soundOptions.find((o) => o.id === settings.sounds.errors())} - value={(o) => o.id} - label={(o) => language.t(o.label)} - onHighlight={(option) => { - if (!option) return - playDemoSound(option.src) - }} - onSelect={(option) => { - if (!option) return - settings.sounds.setErrors(option.id) - playDemoSound(option.src) - }} - variant="secondary" - size="small" - triggerVariant="settings" - /> - </SettingsRow> - </div> - </div> + <SoundsSection /> <Show when={platform.platform === "desktop" && platform.os === "windows" && platform.getWslEnabled}> {(_) => { @@ -395,53 +431,7 @@ export const SettingsGeneral: Component = () => { }} </Show> - {/* Updates Section */} - <div class="flex flex-col gap-1"> - <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.updates")}</h3> - - <div class="bg-surface-raised-base px-4 rounded-lg"> - <SettingsRow - title={language.t("settings.updates.row.startup.title")} - description={language.t("settings.updates.row.startup.description")} - > - <div data-action="settings-updates-startup"> - <Switch - checked={settings.updates.startup()} - disabled={!platform.checkUpdate} - onChange={(checked) => settings.updates.setStartup(checked)} - /> - </div> - </SettingsRow> - - <SettingsRow - title={language.t("settings.general.row.releaseNotes.title")} - description={language.t("settings.general.row.releaseNotes.description")} - > - <div data-action="settings-release-notes"> - <Switch - checked={settings.general.releaseNotes()} - onChange={(checked) => settings.general.setReleaseNotes(checked)} - /> - </div> - </SettingsRow> - - <SettingsRow - title={language.t("settings.updates.row.check.title")} - description={language.t("settings.updates.row.check.description")} - > - <Button - size="small" - variant="secondary" - disabled={store.checking || !platform.checkUpdate} - onClick={check} - > - {store.checking - ? language.t("settings.updates.action.checking") - : language.t("settings.updates.action.checkNow")} - </Button> - </SettingsRow> - </div> - </div> + <UpdatesSection /> <Show when={linux()}> {(_) => { diff --git a/packages/app/src/components/settings-keybinds.tsx b/packages/app/src/components/settings-keybinds.tsx index 79e000f37..bcc731af9 100644 --- a/packages/app/src/components/settings-keybinds.tsx +++ b/packages/app/src/components/settings-keybinds.tsx @@ -21,6 +21,9 @@ type KeybindMeta = { group: KeybindGroup } +type KeybindMap = Record<string, string | undefined> +type CommandContext = ReturnType<typeof useCommand> + const GROUPS: KeybindGroup[] = ["General", "Session", "Navigation", "Model and agent", "Terminal", "Prompt"] type GroupKey = @@ -107,6 +110,150 @@ function signatures(config: string | undefined) { return sigs } +function keybinds(value: unknown): KeybindMap { + if (!value || typeof value !== "object" || Array.isArray(value)) return {} + return value as KeybindMap +} + +function listFor(command: CommandContext, map: KeybindMap, palette: string) { + const out = new Map<string, KeybindMeta>() + out.set(PALETTE_ID, { title: palette, group: "General" }) + + for (const opt of command.catalog) { + if (opt.id.startsWith("suggested.")) continue + out.set(opt.id, { title: opt.title, group: groupFor(opt.id) }) + } + + for (const opt of command.options) { + if (opt.id.startsWith("suggested.")) continue + out.set(opt.id, { title: opt.title, group: groupFor(opt.id) }) + } + + for (const [id, value] of Object.entries(map)) { + if (typeof value !== "string") continue + if (out.has(id)) continue + out.set(id, { title: id, group: groupFor(id) }) + } + + return out +} + +function groupedFor(list: Map<string, KeybindMeta>) { + const out = new Map<KeybindGroup, string[]>() + for (const group of GROUPS) out.set(group, []) + + for (const [id, item] of list) { + const ids = out.get(item.group) + if (!ids) continue + ids.push(id) + } + + for (const group of GROUPS) { + const ids = out.get(group) + if (!ids) continue + ids.sort((a, b) => (list.get(a)?.title ?? "").localeCompare(list.get(b)?.title ?? "")) + } + + return out +} + +function filteredFor( + query: string, + list: Map<string, KeybindMeta>, + grouped: Map<KeybindGroup, string[]>, + keybind: (id: string) => string, +) { + const value = query.toLowerCase().trim() + if (!value) return grouped + + const out = new Map<KeybindGroup, string[]>() + for (const group of GROUPS) out.set(group, []) + + const items = Array.from(list.entries()).map(([id, meta]) => ({ + id, + title: meta.title, + group: meta.group, + keybind: keybind(id), + })) + + const results = fuzzysort.go(value, items, { + keys: ["title", "keybind"], + threshold: -10000, + }) + + for (const result of results) { + const ids = out.get(result.obj.group) + if (!ids) continue + ids.push(result.obj.id) + } + + return out +} + +function useKeyCapture(input: { + active: () => string | null + stop: () => void + set: (id: string, keybind: string) => void + used: () => Map<string, { id: string; title: string }[]> + language: ReturnType<typeof useLanguage> +}) { + onMount(() => { + const handle = (event: KeyboardEvent) => { + const id = input.active() + if (!id) return + + event.preventDefault() + event.stopPropagation() + event.stopImmediatePropagation() + + if (event.key === "Escape") { + input.stop() + return + } + + const clear = + (event.key === "Backspace" || event.key === "Delete") && + !event.ctrlKey && + !event.metaKey && + !event.altKey && + !event.shiftKey + if (clear) { + input.set(id, "none") + input.stop() + return + } + + const next = recordKeybind(event) + if (!next) return + + const conflicts = new Map<string, string>() + for (const sig of signatures(next)) { + for (const item of input.used().get(sig) ?? []) { + if (item.id === id) continue + conflicts.set(item.id, item.title) + } + } + + if (conflicts.size > 0) { + showToast({ + title: input.language.t("settings.shortcuts.conflict.title"), + description: input.language.t("settings.shortcuts.conflict.description", { + keybind: formatKeybind(next), + titles: [...conflicts.values()].join(", "), + }), + }) + return + } + + input.set(id, next) + input.stop() + } + + document.addEventListener("keydown", handle, true) + onCleanup(() => document.removeEventListener("keydown", handle, true)) + }) +} + export const SettingsKeybinds: Component = () => { const command = useCommand() const language = useLanguage() @@ -135,11 +282,9 @@ export const SettingsKeybinds: Component = () => { command.keybinds(false) } - const hasOverrides = createMemo(() => { - const keybinds = settings.current.keybinds as Record<string, string | undefined> | undefined - if (!keybinds) return false - return Object.values(keybinds).some((x) => typeof x === "string") - }) + const map = createMemo(() => keybinds(settings.current.keybinds)) + + const hasOverrides = createMemo(() => Object.values(map()).some((x) => typeof x === "string")) const resetAll = () => { stop() @@ -152,88 +297,15 @@ export const SettingsKeybinds: Component = () => { const list = createMemo(() => { language.locale() - const out = new Map<string, KeybindMeta>() - out.set(PALETTE_ID, { title: language.t("command.palette"), group: "General" }) - - for (const opt of command.catalog) { - if (opt.id.startsWith("suggested.")) continue - out.set(opt.id, { title: opt.title, group: groupFor(opt.id) }) - } - - for (const opt of command.options) { - if (opt.id.startsWith("suggested.")) continue - out.set(opt.id, { title: opt.title, group: groupFor(opt.id) }) - } - - const keybinds = settings.current.keybinds as Record<string, string | undefined> | undefined - if (keybinds) { - for (const [id, value] of Object.entries(keybinds)) { - if (typeof value !== "string") continue - if (out.has(id)) continue - out.set(id, { title: id, group: groupFor(id) }) - } - } - - return out + return listFor(command, map(), language.t("command.palette")) }) const title = (id: string) => list().get(id)?.title ?? "" - const grouped = createMemo(() => { - const map = list() - const out = new Map<KeybindGroup, string[]>() - - for (const group of GROUPS) out.set(group, []) - - for (const [id, item] of map) { - const ids = out.get(item.group) - if (!ids) continue - ids.push(id) - } - - for (const group of GROUPS) { - const ids = out.get(group) - if (!ids) continue - - ids.sort((a, b) => { - const at = map.get(a)?.title ?? "" - const bt = map.get(b)?.title ?? "" - return at.localeCompare(bt) - }) - } - - return out - }) + const grouped = createMemo(() => groupedFor(list())) const filtered = createMemo(() => { - const query = store.filter.toLowerCase().trim() - if (!query) return grouped() - - const map = list() - const out = new Map<KeybindGroup, string[]>() - - for (const group of GROUPS) out.set(group, []) - - const items = Array.from(map.entries()).map(([id, meta]) => ({ - id, - title: meta.title, - group: meta.group, - keybind: command.keybind(id) || "", - })) - - const results = fuzzysort.go(query, items, { - keys: ["title", "keybind"], - threshold: -10000, - }) - - for (const result of results) { - const item = result.obj - const ids = out.get(item.group) - if (!ids) continue - ids.push(item.id) - } - - return out + return filteredFor(store.filter, list(), grouped(), (id) => command.keybind(id) || "") }) const hasResults = createMemo(() => { @@ -282,69 +354,14 @@ export const SettingsKeybinds: Component = () => { return map }) - const setKeybind = (id: string, keybind: string) => { - settings.keybinds.set(id, keybind) - } - - onMount(() => { - const handle = (event: KeyboardEvent) => { - const id = store.active - if (!id) return - - event.preventDefault() - event.stopPropagation() - event.stopImmediatePropagation() + const setKeybind = (id: string, keybind: string) => settings.keybinds.set(id, keybind) - if (event.key === "Escape") { - stop() - return - } - - const clear = - (event.key === "Backspace" || event.key === "Delete") && - !event.ctrlKey && - !event.metaKey && - !event.altKey && - !event.shiftKey - if (clear) { - setKeybind(id, "none") - stop() - return - } - - const next = recordKeybind(event) - if (!next) return - - const map = used() - const conflicts = new Map<string, string>() - - for (const sig of signatures(next)) { - const list = map.get(sig) ?? [] - for (const item of list) { - if (item.id === id) continue - conflicts.set(item.id, item.title) - } - } - - if (conflicts.size > 0) { - showToast({ - title: language.t("settings.shortcuts.conflict.title"), - description: language.t("settings.shortcuts.conflict.description", { - keybind: formatKeybind(next), - titles: [...conflicts.values()].join(", "), - }), - }) - return - } - - setKeybind(id, next) - stop() - } - - document.addEventListener("keydown", handle, true) - onCleanup(() => { - document.removeEventListener("keydown", handle, true) - }) + useKeyCapture({ + active: () => store.active, + stop, + set: setKeybind, + used, + language, }) onCleanup(() => { diff --git a/packages/app/src/components/settings-mcp.tsx b/packages/app/src/components/settings-mcp.tsx index 928464a51..507e041aa 100644 --- a/packages/app/src/components/settings-mcp.tsx +++ b/packages/app/src/components/settings-mcp.tsx @@ -2,6 +2,7 @@ import { Component } from "solid-js" import { useLanguage } from "@/context/language" export const SettingsMcp: Component = () => { + // TODO: Replace this placeholder with full MCP settings controls. const language = useLanguage() return ( diff --git a/packages/app/src/components/settings-models.tsx b/packages/app/src/components/settings-models.tsx index 1807d561e..3a0b7a4fb 100644 --- a/packages/app/src/components/settings-models.tsx +++ b/packages/app/src/components/settings-models.tsx @@ -12,6 +12,25 @@ import { popularProviders } from "@/hooks/use-providers" type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number] +const ListLoadingState: Component<{ label: string }> = (props) => { + return ( + <div class="flex flex-col items-center justify-center py-12 text-center"> + <span class="text-14-regular text-text-weak">{props.label}</span> + </div> + ) +} + +const ListEmptyState: Component<{ message: string; filter: string }> = (props) => { + return ( + <div class="flex flex-col items-center justify-center py-12 text-center"> + <span class="text-14-regular text-text-weak">{props.message}</span> + <Show when={props.filter}> + <span class="text-14-regular text-text-strong mt-1">"{props.filter}"</span> + </Show> + </div> + ) +} + export const SettingsModels: Component = () => { const language = useLanguage() const models = useModels() @@ -68,24 +87,12 @@ export const SettingsModels: Component = () => { <Show when={!list.grouped.loading} fallback={ - <div class="flex flex-col items-center justify-center py-12 text-center"> - <span class="text-14-regular text-text-weak"> - {language.t("common.loading")} - {language.t("common.loading.ellipsis")} - </span> - </div> + <ListLoadingState label={`${language.t("common.loading")}${language.t("common.loading.ellipsis")}`} /> } > <Show when={list.flat().length > 0} - fallback={ - <div class="flex flex-col items-center justify-center py-12 text-center"> - <span class="text-14-regular text-text-weak">{language.t("dialog.model.empty")}</span> - <Show when={list.filter()}> - <span class="text-14-regular text-text-strong mt-1">"{list.filter()}"</span> - </Show> - </div> - } + fallback={<ListEmptyState message={language.t("dialog.model.empty")} filter={list.filter()} />} > <For each={list.grouped.latest}> {(group) => ( diff --git a/packages/app/src/components/settings-permissions.tsx b/packages/app/src/components/settings-permissions.tsx index 7dd43a707..348854491 100644 --- a/packages/app/src/components/settings-permissions.tsx +++ b/packages/app/src/components/settings-permissions.tsx @@ -165,12 +165,14 @@ export const SettingsPermissions: Component = () => { const nextValue = existing && typeof existing === "object" && !Array.isArray(existing) ? { ...existing, "*": action } : action - globalSync.set("config", "permission", { ...map, [id]: nextValue }) - globalSync.updateConfig({ permission: { [id]: nextValue } }).catch((err: unknown) => { + const rollback = (err: unknown) => { globalSync.set("config", "permission", before) const message = err instanceof Error ? err.message : String(err) showToast({ title: language.t("settings.permissions.toast.updateFailed.title"), description: message }) - }) + } + + globalSync.set("config", "permission", { ...map, [id]: nextValue }) + globalSync.updateConfig({ permission: { [id]: nextValue } }).catch(rollback) } return ( diff --git a/packages/app/src/components/settings-providers.tsx b/packages/app/src/components/settings-providers.tsx index d2444e2d2..a3375c9c6 100644 --- a/packages/app/src/components/settings-providers.tsx +++ b/packages/app/src/components/settings-providers.tsx @@ -14,7 +14,17 @@ import { DialogSelectProvider } from "./dialog-select-provider" import { DialogCustomProvider } from "./dialog-custom-provider" type ProviderSource = "env" | "api" | "config" | "custom" -type ProviderMeta = { source?: ProviderSource } +type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number] + +const PROVIDER_NOTES = [ + { match: (id: string) => id === "opencode", key: "dialog.provider.opencode.note" }, + { match: (id: string) => id === "anthropic", key: "dialog.provider.anthropic.note" }, + { match: (id: string) => id.startsWith("github-copilot"), key: "dialog.provider.copilot.note" }, + { match: (id: string) => id === "openai", key: "dialog.provider.openai.note" }, + { match: (id: string) => id === "google", key: "dialog.provider.google.note" }, + { match: (id: string) => id === "openrouter", key: "dialog.provider.openrouter.note" }, + { match: (id: string) => id === "vercel", key: "dialog.provider.vercel.note" }, +] as const export const SettingsProviders: Component = () => { const dialog = useDialog() @@ -44,22 +54,28 @@ export const SettingsProviders: Component = () => { return items }) - const source = (item: unknown) => (item as ProviderMeta).source + const source = (item: ProviderItem): ProviderSource | undefined => { + if (!("source" in item)) return + const value = item.source + if (value === "env" || value === "api" || value === "config" || value === "custom") return value + return + } - const type = (item: unknown) => { + const type = (item: ProviderItem) => { const current = source(item) if (current === "env") return language.t("settings.providers.tag.environment") if (current === "api") return language.t("provider.connect.method.apiKey") if (current === "config") { - const id = (item as { id?: string }).id - if (id && isConfigCustom(id)) return language.t("settings.providers.tag.custom") + if (isConfigCustom(item.id)) return language.t("settings.providers.tag.custom") return language.t("settings.providers.tag.config") } if (current === "custom") return language.t("settings.providers.tag.custom") return language.t("settings.providers.tag.other") } - const canDisconnect = (item: unknown) => source(item) !== "env" + const canDisconnect = (item: ProviderItem) => source(item) !== "env" + + const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key const isConfigCustom = (providerID: string) => { const provider = globalSync.data.config.provider?.[providerID] @@ -175,40 +191,8 @@ export const SettingsProviders: Component = () => { <Tag>{language.t("dialog.provider.tag.recommended")}</Tag> </Show> </div> - <Show when={item.id === "opencode"}> - <span class="text-12-regular text-text-weak pl-8"> - {language.t("dialog.provider.opencode.note")} - </span> - </Show> - <Show when={item.id === "anthropic"}> - <span class="text-12-regular text-text-weak pl-8"> - {language.t("dialog.provider.anthropic.note")} - </span> - </Show> - <Show when={item.id.startsWith("github-copilot")}> - <span class="text-12-regular text-text-weak pl-8"> - {language.t("dialog.provider.copilot.note")} - </span> - </Show> - <Show when={item.id === "openai"}> - <span class="text-12-regular text-text-weak pl-8"> - {language.t("dialog.provider.openai.note")} - </span> - </Show> - <Show when={item.id === "google"}> - <span class="text-12-regular text-text-weak pl-8"> - {language.t("dialog.provider.google.note")} - </span> - </Show> - <Show when={item.id === "openrouter"}> - <span class="text-12-regular text-text-weak pl-8"> - {language.t("dialog.provider.openrouter.note")} - </span> - </Show> - <Show when={item.id === "vercel"}> - <span class="text-12-regular text-text-weak pl-8"> - {language.t("dialog.provider.vercel.note")} - </span> + <Show when={note(item.id)}> + {(key) => <span class="text-12-regular text-text-weak pl-8">{language.t(key())}</span>} </Show> </div> <Button diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx index 6e8999017..26ee2d070 100644 --- a/packages/app/src/components/status-popover.tsx +++ b/packages/app/src/components/status-popover.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, For, onCleanup, Show } from "solid-js" +import { createEffect, createMemo, createSignal, For, onCleanup, Show, type Accessor, type JSXElement } from "solid-js" import { createStore, reconcile } from "solid-js/store" import { useNavigate } from "@solidjs/router" import { useDialog } from "@opencode-ai/ui/context/dialog" @@ -7,134 +7,189 @@ import { Tabs } from "@opencode-ai/ui/tabs" import { Button } from "@opencode-ai/ui/button" import { Switch } from "@opencode-ai/ui/switch" import { Icon } from "@opencode-ai/ui/icon" +import { showToast } from "@opencode-ai/ui/toast" import { useSync } from "@/context/sync" import { useSDK } from "@/context/sdk" import { normalizeServerUrl, useServer } from "@/context/server" import { usePlatform } from "@/context/platform" import { useLanguage } from "@/context/language" import { DialogSelectServer } from "./dialog-select-server" -import { showToast } from "@opencode-ai/ui/toast" import { ServerRow } from "@/components/server/server-row" import { checkServerHealth, type ServerHealth } from "@/utils/server-health" -export function StatusPopover() { - const sync = useSync() - const sdk = useSDK() - const server = useServer() - const platform = usePlatform() - const dialog = useDialog() - const language = useLanguage() - const navigate = useNavigate() +const pollMs = 10_000 - const [store, setStore] = createStore({ - status: {} as Record<string, ServerHealth | undefined>, - loading: null as string | null, - defaultServerUrl: undefined as string | undefined, - }) - const fetcher = platform.fetch ?? globalThis.fetch +const pluginEmptyMessage = (value: string, file: string): JSXElement => { + const parts = value.split(file) + if (parts.length === 1) return value + return ( + <> + {parts[0]} + <code class="bg-surface-raised-base px-1.5 py-0.5 rounded-sm text-text-base">{file}</code> + {parts.slice(1).join(file)} + </> + ) +} - const servers = createMemo(() => { - const current = server.url - const list = server.list - if (!current) return list - if (!list.includes(current)) return [current, ...list] - return [current, ...list.filter((x) => x !== current)] +const listServersByHealth = ( + list: string[], + active: string | undefined, + status: Record<string, ServerHealth | undefined>, +) => { + if (!list.length) return list + const order = new Map(list.map((url, index) => [url, index] as const)) + const rank = (value?: ServerHealth) => { + if (value?.healthy === true) return 0 + if (value?.healthy === false) return 2 + return 1 + } + + return list.slice().sort((a, b) => { + if (a === active) return -1 + if (b === active) return 1 + const diff = rank(status[a]) - rank(status[b]) + if (diff !== 0) return diff + return (order.get(a) ?? 0) - (order.get(b) ?? 0) }) +} - const sortedServers = createMemo(() => { +const useServerHealth = (servers: Accessor<string[]>, fetcher: typeof fetch) => { + const [status, setStatus] = createStore({} as Record<string, ServerHealth | undefined>) + + createEffect(() => { const list = servers() - if (!list.length) return list - const active = server.url - const order = new Map(list.map((url, index) => [url, index] as const)) - const rank = (value?: ServerHealth) => { - if (value?.healthy === true) return 0 - if (value?.healthy === false) return 2 - return 1 + let dead = false + + const refresh = async () => { + const results: Record<string, ServerHealth> = {} + await Promise.all( + list.map(async (url) => { + results[url] = await checkServerHealth(url, fetcher) + }), + ) + if (dead) return + setStatus(reconcile(results)) } - return list.slice().sort((a, b) => { - if (a === active) return -1 - if (b === active) return 1 - const diff = rank(store.status[a]) - rank(store.status[b]) - if (diff !== 0) return diff - return (order.get(a) ?? 0) - (order.get(b) ?? 0) + + void refresh() + const id = setInterval(() => void refresh(), pollMs) + onCleanup(() => { + dead = true + clearInterval(id) }) }) - async function refreshHealth() { - const results: Record<string, ServerHealth> = {} - await Promise.all( - servers().map(async (url) => { - results[url] = await checkServerHealth(url, fetcher) - }), - ) - setStore("status", reconcile(results)) - } + return status +} + +const useDefaultServerUrl = ( + get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined, +) => { + const [url, setUrl] = createSignal<string | undefined>() + const [tick, setTick] = createSignal(0) createEffect(() => { - servers() - refreshHealth() - const interval = setInterval(refreshHealth, 10_000) - onCleanup(() => clearInterval(interval)) + tick() + let dead = false + const result = get?.() + if (!result) { + setUrl(undefined) + onCleanup(() => { + dead = true + }) + return + } + + if (result instanceof Promise) { + void result.then((next) => { + if (dead) return + setUrl(next ? normalizeServerUrl(next) : undefined) + }) + onCleanup(() => { + dead = true + }) + return + } + + setUrl(normalizeServerUrl(result)) + onCleanup(() => { + dead = true + }) }) - const mcpItems = createMemo(() => - Object.entries(sync.data.mcp ?? {}) - .map(([name, status]) => ({ name, status: status.status })) - .sort((a, b) => a.name.localeCompare(b.name)), - ) + return { url, refresh: () => setTick((value) => value + 1) } +} - const mcpConnected = createMemo(() => mcpItems().filter((i) => i.status === "connected").length) +const useMcpToggle = (input: { + sync: ReturnType<typeof useSync> + sdk: ReturnType<typeof useSDK> + language: ReturnType<typeof useLanguage> +}) => { + const [loading, setLoading] = createSignal<string | null>(null) - const toggleMcp = async (name: string) => { - if (store.loading) return - setStore("loading", name) + const toggle = async (name: string) => { + if (loading()) return + setLoading(name) try { - const status = sync.data.mcp[name] - await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ name })) - const result = await sdk.client.mcp.status() - if (result.data) sync.set("mcp", result.data) + const status = input.sync.data.mcp[name] + await (status?.status === "connected" + ? input.sdk.client.mcp.disconnect({ name }) + : input.sdk.client.mcp.connect({ name })) + const result = await input.sdk.client.mcp.status() + if (result.data) input.sync.set("mcp", result.data) } catch (err) { showToast({ variant: "error", - title: language.t("common.requestFailed"), + title: input.language.t("common.requestFailed"), description: err instanceof Error ? err.message : String(err), }) } finally { - setStore("loading", null) + setLoading(null) } } + return { loading, toggle } +} + +export function StatusPopover() { + const sync = useSync() + const sdk = useSDK() + const server = useServer() + const platform = usePlatform() + const dialog = useDialog() + const language = useLanguage() + const navigate = useNavigate() + + const fetcher = platform.fetch ?? globalThis.fetch + const servers = createMemo(() => { + const current = server.url + const list = server.list + if (!current) return list + if (!list.includes(current)) return [current, ...list] + return [current, ...list.filter((item) => item !== current)] + }) + const health = useServerHealth(servers, fetcher) + const sortedServers = createMemo(() => listServersByHealth(servers(), server.url, health)) + const mcp = useMcpToggle({ sync, sdk, language }) + const defaultServer = useDefaultServerUrl(platform.getDefaultServerUrl) + const mcpItems = createMemo(() => + Object.entries(sync.data.mcp ?? {}) + .map(([name, status]) => ({ name, status: status.status })) + .sort((a, b) => a.name.localeCompare(b.name)), + ) + const mcpConnected = createMemo(() => mcpItems().filter((item) => item.status === "connected").length) const lspItems = createMemo(() => sync.data.lsp ?? []) const lspCount = createMemo(() => lspItems().length) const plugins = createMemo(() => sync.data.config.plugin ?? []) const pluginCount = createMemo(() => plugins().length) - + const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json")) const overallHealthy = createMemo(() => { const serverHealthy = server.healthy() === true - const anyMcpIssue = mcpItems().some((m) => m.status !== "connected" && m.status !== "disabled") + const anyMcpIssue = mcpItems().some((item) => item.status !== "connected" && item.status !== "disabled") return serverHealthy && !anyMcpIssue }) - const serverCount = createMemo(() => sortedServers().length) - - const refreshDefaultServerUrl = () => { - const result = platform.getDefaultServerUrl?.() - if (!result) { - setStore("defaultServerUrl", undefined) - return - } - if (result instanceof Promise) { - result.then((url) => setStore("defaultServerUrl", url ? normalizeServerUrl(url) : undefined)) - return - } - setStore("defaultServerUrl", normalizeServerUrl(result)) - } - - createEffect(() => { - refreshDefaultServerUrl() - }) - return ( <Popover triggerAs={Button} @@ -173,7 +228,7 @@ export function StatusPopover() { > <Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10"> <Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular"> - {serverCount() > 0 ? `${serverCount()} ` : ""} + {sortedServers().length > 0 ? `${sortedServers().length} ` : ""} {language.t("status.popover.tab.servers")} </Tabs.Trigger> <Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular"> @@ -195,11 +250,7 @@ export function StatusPopover() { <div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14"> <For each={sortedServers()}> {(url) => { - const isActive = () => url === server.url - const isDefault = () => url === store.defaultServerUrl - const status = () => store.status[url] - const isBlocked = () => status()?.healthy === false - + const isBlocked = () => health[url]?.healthy === false return ( <button type="button" @@ -217,13 +268,13 @@ export function StatusPopover() { > <ServerRow url={url} - status={status()} + status={health[url]} dimmed={isBlocked()} class="flex items-center gap-2 w-full min-w-0" nameClass="text-14-regular text-text-base truncate" versionClass="text-12-regular text-text-weak truncate" badge={ - <Show when={isDefault()}> + <Show when={url === defaultServer.url()}> <span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md"> {language.t("common.default")} </span> @@ -231,7 +282,7 @@ export function StatusPopover() { } > <div class="flex-1" /> - <Show when={isActive()}> + <Show when={url === server.url}> <Icon name="check" size="small" class="text-icon-weak shrink-0" /> </Show> </ServerRow> @@ -243,7 +294,7 @@ export function StatusPopover() { <Button variant="secondary" class="mt-3 self-start h-8 px-3 py-1.5" - onClick={() => dialog.show(() => <DialogSelectServer />, refreshDefaultServerUrl)} + onClick={() => dialog.show(() => <DialogSelectServer />, defaultServer.refresh)} > {language.t("status.popover.action.manageServers")} </Button> @@ -269,8 +320,8 @@ export function StatusPopover() { <button type="button" class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left" - onClick={() => toggleMcp(item.name)} - disabled={store.loading === item.name} + onClick={() => mcp.toggle(item.name)} + disabled={mcp.loading() === item.name} > <div classList={{ @@ -286,8 +337,8 @@ export function StatusPopover() { <div onClick={(event) => event.stopPropagation()}> <Switch checked={enabled()} - disabled={store.loading === item.name} - onChange={() => toggleMcp(item.name)} + disabled={mcp.loading() === item.name} + onChange={() => mcp.toggle(item.name)} /> </div> </button> @@ -334,23 +385,7 @@ export function StatusPopover() { <div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14"> <Show when={plugins().length > 0} - fallback={ - <div class="text-14-regular text-text-base text-center my-auto"> - {(() => { - const value = language.t("dialog.plugins.empty") - const file = "opencode.json" - const parts = value.split(file) - if (parts.length === 1) return value - return ( - <> - {parts[0]} - <code class="bg-surface-raised-base px-1.5 py-0.5 rounded-sm text-text-base">{file}</code> - {parts.slice(1).join(file)} - </> - ) - })()} - </div> - } + fallback={<div class="text-14-regular text-text-base text-center my-auto">{pluginEmpty()}</div>} > <For each={plugins()}> {(plugin) => ( diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 09c04db40..f6bb0b48a 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -56,6 +56,91 @@ const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = { }, } +const debugTerminal = (...values: unknown[]) => { + if (!import.meta.env.DEV) return + console.debug("[terminal]", ...values) +} + +const useTerminalUiBindings = (input: { + container: HTMLDivElement + term: Term + cleanups: VoidFunction[] + handlePointerDown: () => void + handleLinkClick: (event: MouseEvent) => void +}) => { + const handleCopy = (event: ClipboardEvent) => { + const selection = input.term.getSelection() + if (!selection) return + + const clipboard = event.clipboardData + if (!clipboard) return + + event.preventDefault() + clipboard.setData("text/plain", selection) + } + + const handlePaste = (event: ClipboardEvent) => { + const clipboard = event.clipboardData + const text = clipboard?.getData("text/plain") ?? clipboard?.getData("text") ?? "" + if (!text) return + + event.preventDefault() + event.stopPropagation() + input.term.paste(text) + } + + const handleTextareaFocus = () => { + input.term.options.cursorBlink = true + } + const handleTextareaBlur = () => { + input.term.options.cursorBlink = false + } + + input.container.addEventListener("copy", handleCopy, true) + input.cleanups.push(() => input.container.removeEventListener("copy", handleCopy, true)) + + input.container.addEventListener("paste", handlePaste, true) + input.cleanups.push(() => input.container.removeEventListener("paste", handlePaste, true)) + + input.container.addEventListener("pointerdown", input.handlePointerDown) + input.cleanups.push(() => input.container.removeEventListener("pointerdown", input.handlePointerDown)) + + input.container.addEventListener("click", input.handleLinkClick, { capture: true }) + input.cleanups.push(() => input.container.removeEventListener("click", input.handleLinkClick, { capture: true })) + + input.term.textarea?.addEventListener("focus", handleTextareaFocus) + input.term.textarea?.addEventListener("blur", handleTextareaBlur) + input.cleanups.push(() => input.term.textarea?.removeEventListener("focus", handleTextareaFocus)) + input.cleanups.push(() => input.term.textarea?.removeEventListener("blur", handleTextareaBlur)) +} + +const persistTerminal = (input: { + term: Term | undefined + addon: SerializeAddon | undefined + cursor: number + pty: LocalPTY + onCleanup?: (pty: LocalPTY) => void +}) => { + if (!input.addon || !input.onCleanup || !input.term) return + const buffer = (() => { + try { + return input.addon.serialize() + } catch { + debugTerminal("failed to serialize terminal buffer") + return "" + } + })() + + input.onCleanup({ + ...input.pty, + buffer, + cursor: input.cursor, + rows: input.term.rows, + cols: input.term.cols, + scrollY: input.term.getViewportY(), + }) +} + export const Terminal = (props: TerminalProps) => { const platform = usePlatform() const sdk = useSDK() @@ -70,8 +155,6 @@ export const Terminal = (props: TerminalProps) => { let serializeAddon: SerializeAddon let fitAddon: FitAddon let handleResize: () => void - let handleTextareaFocus: () => void - let handleTextareaBlur: () => void let disposed = false const cleanups: VoidFunction[] = [] const start = @@ -84,12 +167,23 @@ export const Terminal = (props: TerminalProps) => { for (const fn of fns) { try { fn() - } catch { - // ignore + } catch (err) { + debugTerminal("cleanup failed", err) } } } + const pushSize = (cols: number, rows: number) => { + return sdk.client.pty + .update({ + ptyID: local.pty.id, + size: { cols, rows }, + }) + .catch((err) => { + debugTerminal("failed to sync terminal size", err) + }) + } + const getTerminalColors = (): TerminalColors => { const mode = theme.mode() === "dark" ? "dark" : "light" const fallback = DEFAULT_TERMINAL_COLORS[mode] @@ -219,27 +313,6 @@ export const Terminal = (props: TerminalProps) => { ghostty = g term = t - const handleCopy = (event: ClipboardEvent) => { - const selection = t.getSelection() - if (!selection) return - - const clipboard = event.clipboardData - if (!clipboard) return - - event.preventDefault() - clipboard.setData("text/plain", selection) - } - - const handlePaste = (event: ClipboardEvent) => { - const clipboard = event.clipboardData - const text = clipboard?.getData("text/plain") ?? clipboard?.getData("text") ?? "" - if (!text) return - - event.preventDefault() - event.stopPropagation() - t.paste(text) - } - t.attachCustomKeyEventHandler((event) => { const key = event.key.toLowerCase() @@ -255,12 +328,6 @@ export const Terminal = (props: TerminalProps) => { return matchKeybind(keybinds, event) }) - container.addEventListener("copy", handleCopy, true) - cleanups.push(() => container.removeEventListener("copy", handleCopy, true)) - - container.addEventListener("paste", handlePaste, true) - cleanups.push(() => container.removeEventListener("paste", handlePaste, true)) - const fit = new mod.FitAddon() const serializer = new SerializeAddon() cleanups.push(() => disposeIfDisposable(fit)) @@ -270,24 +337,7 @@ export const Terminal = (props: TerminalProps) => { serializeAddon = serializer t.open(container) - - container.addEventListener("pointerdown", handlePointerDown) - cleanups.push(() => container.removeEventListener("pointerdown", handlePointerDown)) - - container.addEventListener("click", handleLinkClick, { capture: true }) - cleanups.push(() => container.removeEventListener("click", handleLinkClick, { capture: true })) - - handleTextareaFocus = () => { - t.options.cursorBlink = true - } - handleTextareaBlur = () => { - t.options.cursorBlink = false - } - - t.textarea?.addEventListener("focus", handleTextareaFocus) - t.textarea?.addEventListener("blur", handleTextareaBlur) - cleanups.push(() => t.textarea?.removeEventListener("focus", handleTextareaFocus)) - cleanups.push(() => t.textarea?.removeEventListener("blur", handleTextareaBlur)) + useTerminalUiBindings({ container, term: t, cleanups, handlePointerDown, handleLinkClick }) focusTerminal() @@ -316,15 +366,7 @@ export const Terminal = (props: TerminalProps) => { const onResize = t.onResize(async (size) => { if (socket.readyState === WebSocket.OPEN) { - await sdk.client.pty - .update({ - ptyID: local.pty.id, - size: { - cols: size.cols, - rows: size.rows, - }, - }) - .catch(() => {}) + await pushSize(size.cols, size.rows) } }) cleanups.push(() => disposeIfDisposable(onResize)) @@ -346,15 +388,7 @@ export const Terminal = (props: TerminalProps) => { const handleOpen = () => { local.onConnect?.() - sdk.client.pty - .update({ - ptyID: local.pty.id, - size: { - cols: t.cols, - rows: t.rows, - }, - }) - .catch(() => {}) + void pushSize(t.cols, t.rows) } socket.addEventListener("open", handleOpen) cleanups.push(() => socket.removeEventListener("open", handleOpen)) @@ -374,8 +408,8 @@ export const Terminal = (props: TerminalProps) => { if (typeof next === "number" && Number.isSafeInteger(next) && next >= 0) { cursor = next } - } catch { - // ignore + } catch (err) { + debugTerminal("invalid websocket control frame", err) } return } @@ -425,25 +459,7 @@ export const Terminal = (props: TerminalProps) => { onCleanup(() => { disposed = true - const t = term - if (serializeAddon && props.onCleanup && t) { - const buffer = (() => { - try { - return serializeAddon.serialize() - } catch { - return "" - } - })() - props.onCleanup({ - ...local.pty, - buffer, - cursor, - rows: t.rows, - cols: t.cols, - scrollY: t.getViewportY(), - }) - } - + persistTerminal({ term, addon: serializeAddon, cursor, pty: local.pty, onCleanup: props.onCleanup }) cleanup() }) diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index e7b8066ae..039a25fae 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -13,6 +13,28 @@ import { useCommand } from "@/context/command" import { useLanguage } from "@/context/language" import { applyPath, backPath, forwardPath } from "./titlebar-history" +type TauriDesktopWindow = { + startDragging?: () => Promise<void> + toggleMaximize?: () => Promise<void> +} + +type TauriThemeWindow = { + setTheme?: (theme?: "light" | "dark" | null) => Promise<void> +} + +type TauriApi = { + window?: { + getCurrentWindow?: () => TauriDesktopWindow + } + webviewWindow?: { + getCurrentWebviewWindow?: () => TauriThemeWindow + } +} + +const tauriApi = () => (window as unknown as { __TAURI__?: TauriApi }).__TAURI__ +const currentDesktopWindow = () => tauriApi()?.window?.getCurrentWindow?.() +const currentThemeWindow = () => tauriApi()?.webviewWindow?.getCurrentWebviewWindow?.() + export function Titlebar() { const layout = useLayout() const platform = usePlatform() @@ -82,22 +104,7 @@ export function Titlebar() { const getWin = () => { if (platform.platform !== "desktop") return - - const tauri = ( - window as unknown as { - __TAURI__?: { - window?: { - getCurrentWindow?: () => { - startDragging?: () => Promise<void> - toggleMaximize?: () => Promise<void> - } - } - } - } - ).__TAURI__ - if (!tauri?.window?.getCurrentWindow) return - - return tauri.window.getCurrentWindow() + return currentDesktopWindow() } createEffect(() => { @@ -106,13 +113,8 @@ export function Titlebar() { const scheme = theme.colorScheme() const value = scheme === "system" ? null : scheme - const tauri = (window as unknown as { __TAURI__?: { webviewWindow?: { getCurrentWebviewWindow?: () => unknown } } }) - .__TAURI__ - const get = tauri?.webviewWindow?.getCurrentWebviewWindow - if (!get) return - - const win = get() as { setTheme?: (theme?: "light" | "dark" | null) => Promise<void> } - if (!win.setTheme) return + const win = currentThemeWindow() + if (!win?.setTheme) return void win.setTheme(value).catch(() => undefined) }) |
