diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d9eded3f17..c928e82234 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,7 +8,9 @@ on: workflow_dispatch: concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + # Keep every run on dev so cancelled checks do not pollute the default branch + # commit history. PRs and other branches still share a group and cancel stale runs. + group: ${{ case(github.ref == 'refs/heads/dev', format('{0}-{1}', github.workflow, github.run_id), format('{0}-{1}', github.workflow, github.event.pull_request.number || github.ref)) }} cancel-in-progress: true permissions: diff --git a/bun.lock b/bun.lock index 6140c3497a..931880eaba 100644 --- a/bun.lock +++ b/bun.lock @@ -26,7 +26,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.2.24", + "version": "1.2.25", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -77,7 +77,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.2.24", + "version": "1.2.25", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -111,7 +111,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.2.24", + "version": "1.2.25", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -138,7 +138,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.2.24", + "version": "1.2.25", "dependencies": { "@ai-sdk/anthropic": "2.0.0", "@ai-sdk/openai": "2.0.2", @@ -162,7 +162,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.2.24", + "version": "1.2.25", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -186,7 +186,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.2.24", + "version": "1.2.25", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -219,7 +219,7 @@ }, "packages/desktop-electron": { "name": "@opencode-ai/desktop-electron", - "version": "1.2.24", + "version": "1.2.25", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -250,7 +250,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.2.24", + "version": "1.2.25", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -279,7 +279,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.2.24", + "version": "1.2.25", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -295,7 +295,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.2.24", + "version": "1.2.25", "bin": { "opencode": "./bin/opencode", }, @@ -416,7 +416,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.2.24", + "version": "1.2.25", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -440,7 +440,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.2.24", + "version": "1.2.25", "devDependencies": { "@hey-api/openapi-ts": "0.90.10", "@tsconfig/node22": "catalog:", @@ -451,7 +451,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.2.24", + "version": "1.2.25", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -486,7 +486,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.2.24", + "version": "1.2.25", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -532,7 +532,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.2.24", + "version": "1.2.25", "dependencies": { "zod": "catalog:", }, @@ -543,7 +543,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.2.24", + "version": "1.2.25", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index 1e69a64f78..951ff309c7 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.2.24", + "version": "1.2.25", "description": "", "type": "module", "exports": { diff --git a/packages/app/playwright.config.ts b/packages/app/playwright.config.ts index a97c826514..2667b89a1c 100644 --- a/packages/app/playwright.config.ts +++ b/packages/app/playwright.config.ts @@ -6,6 +6,7 @@ const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1" const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" const command = `bun run dev -- --host 0.0.0.0 --port ${port}` const reuse = !process.env.CI +const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined export default defineConfig({ testDir: "./e2e", @@ -17,6 +18,7 @@ export default defineConfig({ fullyParallel: process.env.PLAYWRIGHT_FULLY_PARALLEL === "1", forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, + workers, reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]], webServer: { command, diff --git a/packages/app/src/components/dialog-custom-provider-form.ts b/packages/app/src/components/dialog-custom-provider-form.ts new file mode 100644 index 0000000000..92d235c3bc --- /dev/null +++ b/packages/app/src/components/dialog-custom-provider-form.ts @@ -0,0 +1,159 @@ +const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/ +const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible" + +type Translator = (key: string, vars?: Record) => string + +export type ModelErr = { + id?: string + name?: string +} + +export type HeaderErr = { + key?: string + value?: string +} + +export type ModelRow = { + row: string + id: string + name: string + err: ModelErr +} + +export type HeaderRow = { + row: string + key: string + value: string + err: HeaderErr +} + +export type FormState = { + providerID: string + name: string + baseURL: string + apiKey: string + models: ModelRow[] + headers: HeaderRow[] + saving: boolean + err: { + providerID?: string + name?: string + baseURL?: string + } +} + +type ValidateArgs = { + form: FormState + t: Translator + disabledProviders: string[] + existingProviderIDs: Set +} + +export function validateCustomProvider(input: ValidateArgs) { + const providerID = input.form.providerID.trim() + const name = input.form.name.trim() + const baseURL = input.form.baseURL.trim() + const apiKey = input.form.apiKey.trim() + + const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim() + const key = apiKey && !env ? apiKey : undefined + + const idError = !providerID + ? input.t("provider.custom.error.providerID.required") + : !PROVIDER_ID.test(providerID) + ? input.t("provider.custom.error.providerID.format") + : undefined + + const nameError = !name ? input.t("provider.custom.error.name.required") : undefined + const urlError = !baseURL + ? input.t("provider.custom.error.baseURL.required") + : !/^https?:\/\//.test(baseURL) + ? input.t("provider.custom.error.baseURL.format") + : undefined + + const disabled = input.disabledProviders.includes(providerID) + const existsError = idError + ? undefined + : input.existingProviderIDs.has(providerID) && !disabled + ? input.t("provider.custom.error.providerID.exists") + : undefined + + const seenModels = new Set() + const models = input.form.models.map((m) => { + const id = m.id.trim() + const idError = !id + ? input.t("provider.custom.error.required") + : seenModels.has(id) + ? input.t("provider.custom.error.duplicate") + : (() => { + seenModels.add(id) + return undefined + })() + const nameError = !m.name.trim() ? input.t("provider.custom.error.required") : undefined + return { id: idError, name: nameError } + }) + const modelsValid = models.every((m) => !m.id && !m.name) + const modelConfig = Object.fromEntries(input.form.models.map((m) => [m.id.trim(), { name: m.name.trim() }])) + + const seenHeaders = new Set() + const headers = input.form.headers.map((h) => { + const key = h.key.trim() + const value = h.value.trim() + + if (!key && !value) return {} + const keyError = !key + ? input.t("provider.custom.error.required") + : seenHeaders.has(key.toLowerCase()) + ? input.t("provider.custom.error.duplicate") + : (() => { + seenHeaders.add(key.toLowerCase()) + return undefined + })() + const valueError = !value ? input.t("provider.custom.error.required") : undefined + return { key: keyError, value: valueError } + }) + const headersValid = headers.every((h) => !h.key && !h.value) + const headerConfig = Object.fromEntries( + input.form.headers + .map((h) => ({ key: h.key.trim(), value: h.value.trim() })) + .filter((h) => !!h.key && !!h.value) + .map((h) => [h.key, h.value]), + ) + + const err = { + providerID: idError ?? existsError, + name: nameError, + baseURL: urlError, + } + + const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid + if (!ok) return { err, models, headers } + + return { + err, + models, + headers, + result: { + providerID, + name, + key, + config: { + npm: OPENAI_COMPATIBLE, + name, + ...(env ? { env: [env] } : {}), + options: { + baseURL, + ...(Object.keys(headerConfig).length ? { headers: headerConfig } : {}), + }, + models: modelConfig, + }, + }, + } +} + +let row = 0 + +const nextRow = () => `row-${row++}` + +export const modelRow = (): ModelRow => ({ row: nextRow(), id: "", name: "", err: {} }) +export const headerRow = (): HeaderRow => ({ row: nextRow(), key: "", value: "", err: {} }) diff --git a/packages/app/src/components/dialog-custom-provider.test.ts b/packages/app/src/components/dialog-custom-provider.test.ts new file mode 100644 index 0000000000..8cfd78ebeb --- /dev/null +++ b/packages/app/src/components/dialog-custom-provider.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from "bun:test" +import { validateCustomProvider } from "./dialog-custom-provider-form" + +const t = (key: string) => key + +describe("validateCustomProvider", () => { + test("builds trimmed config payload", () => { + const result = validateCustomProvider({ + form: { + providerID: "custom-provider", + name: " Custom Provider ", + baseURL: "https://api.example.com ", + apiKey: " {env: CUSTOM_PROVIDER_KEY} ", + models: [{ row: "m0", id: " model-a ", name: " Model A ", err: {} }], + headers: [ + { row: "h0", key: " X-Test ", value: " enabled ", err: {} }, + { row: "h1", key: "", value: "", err: {} }, + ], + saving: false, + err: {}, + }, + t, + disabledProviders: [], + existingProviderIDs: new Set(), + }) + + expect(result.result).toEqual({ + providerID: "custom-provider", + name: "Custom Provider", + key: undefined, + config: { + npm: "@ai-sdk/openai-compatible", + name: "Custom Provider", + env: ["CUSTOM_PROVIDER_KEY"], + options: { + baseURL: "https://api.example.com", + headers: { + "X-Test": "enabled", + }, + }, + models: { + "model-a": { name: "Model A" }, + }, + }, + }) + }) + + test("flags duplicate rows and allows reconnecting disabled providers", () => { + const result = validateCustomProvider({ + form: { + providerID: "custom-provider", + name: "Provider", + baseURL: "https://api.example.com", + apiKey: "secret", + models: [ + { row: "m0", id: "model-a", name: "Model A", err: {} }, + { row: "m1", id: "model-a", name: "Model A 2", err: {} }, + ], + headers: [ + { row: "h0", key: "Authorization", value: "one", err: {} }, + { row: "h1", key: "authorization", value: "two", err: {} }, + ], + saving: false, + err: {}, + }, + t, + disabledProviders: ["custom-provider"], + existingProviderIDs: new Set(["custom-provider"]), + }) + + expect(result.result).toBeUndefined() + expect(result.err.providerID).toBeUndefined() + expect(result.models[1]).toEqual({ + id: "provider.custom.error.duplicate", + name: undefined, + }) + expect(result.headers[1]).toEqual({ + key: "provider.custom.error.duplicate", + value: undefined, + }) + }) +}) diff --git a/packages/app/src/components/dialog-custom-provider.tsx b/packages/app/src/components/dialog-custom-provider.tsx index 017b85a2c9..4d220a0b19 100644 --- a/packages/app/src/components/dialog-custom-provider.tsx +++ b/packages/app/src/components/dialog-custom-provider.tsx @@ -5,158 +5,15 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { TextField } from "@opencode-ai/ui/text-field" import { showToast } from "@opencode-ai/ui/toast" -import { For } from "solid-js" -import { createStore } from "solid-js/store" +import { batch, For } from "solid-js" +import { createStore, produce } from "solid-js/store" import { Link } from "@/components/link" import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSync } from "@/context/global-sync" import { useLanguage } from "@/context/language" +import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form" import { DialogSelectProvider } from "./dialog-select-provider" -const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/ -const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible" - -type Translator = ReturnType["t"] - -type ModelRow = { - id: string - name: string -} - -type HeaderRow = { - key: string - value: string -} - -type FormState = { - providerID: string - name: string - baseURL: string - apiKey: string - models: ModelRow[] - headers: HeaderRow[] - saving: boolean -} - -type FormErrors = { - providerID: string | undefined - name: string | undefined - baseURL: string | undefined - models: Array<{ id?: string; name?: string }> - headers: Array<{ key?: string; value?: string }> -} - -type ValidateArgs = { - form: FormState - t: Translator - disabledProviders: string[] - existingProviderIDs: Set -} - -function validateCustomProvider(input: ValidateArgs) { - const providerID = input.form.providerID.trim() - const name = input.form.name.trim() - const baseURL = input.form.baseURL.trim() - const apiKey = input.form.apiKey.trim() - - const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim() - const key = apiKey && !env ? apiKey : undefined - - const idError = !providerID - ? input.t("provider.custom.error.providerID.required") - : !PROVIDER_ID.test(providerID) - ? input.t("provider.custom.error.providerID.format") - : undefined - - const nameError = !name ? input.t("provider.custom.error.name.required") : undefined - const urlError = !baseURL - ? input.t("provider.custom.error.baseURL.required") - : !/^https?:\/\//.test(baseURL) - ? input.t("provider.custom.error.baseURL.format") - : undefined - - const disabled = input.disabledProviders.includes(providerID) - const existsError = idError - ? undefined - : input.existingProviderIDs.has(providerID) && !disabled - ? input.t("provider.custom.error.providerID.exists") - : undefined - - const seenModels = new Set() - const modelErrors = input.form.models.map((m) => { - const id = m.id.trim() - const modelIdError = !id - ? input.t("provider.custom.error.required") - : seenModels.has(id) - ? input.t("provider.custom.error.duplicate") - : (() => { - seenModels.add(id) - return undefined - })() - const modelNameError = !m.name.trim() ? input.t("provider.custom.error.required") : undefined - return { id: modelIdError, name: modelNameError } - }) - const modelsValid = modelErrors.every((m) => !m.id && !m.name) - const models = Object.fromEntries(input.form.models.map((m) => [m.id.trim(), { name: m.name.trim() }])) - - const seenHeaders = new Set() - const headerErrors = input.form.headers.map((h) => { - const key = h.key.trim() - const value = h.value.trim() - - if (!key && !value) return {} - const keyError = !key - ? input.t("provider.custom.error.required") - : seenHeaders.has(key.toLowerCase()) - ? input.t("provider.custom.error.duplicate") - : (() => { - seenHeaders.add(key.toLowerCase()) - return undefined - })() - const valueError = !value ? input.t("provider.custom.error.required") : undefined - return { key: keyError, value: valueError } - }) - const headersValid = headerErrors.every((h) => !h.key && !h.value) - const headers = Object.fromEntries( - input.form.headers - .map((h) => ({ key: h.key.trim(), value: h.value.trim() })) - .filter((h) => !!h.key && !!h.value) - .map((h) => [h.key, h.value]), - ) - - const errors: FormErrors = { - providerID: idError ?? existsError, - name: nameError, - baseURL: urlError, - models: modelErrors, - headers: headerErrors, - } - - const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid - if (!ok) return { errors } - - const options = { - baseURL, - ...(Object.keys(headers).length ? { headers } : {}), - } - - return { - errors, - result: { - providerID, - name, - key, - config: { - npm: OPENAI_COMPATIBLE, - name, - ...(env ? { env: [env] } : {}), - options, - models, - }, - }, - } -} - type Props = { back?: "providers" | "close" } @@ -172,17 +29,10 @@ export function DialogCustomProvider(props: Props) { name: "", baseURL: "", apiKey: "", - models: [{ id: "", name: "" }], - headers: [{ key: "", value: "" }], + models: [modelRow()], + headers: [headerRow()], saving: false, - }) - - const [errors, setErrors] = createStore({ - providerID: undefined, - name: undefined, - baseURL: undefined, - models: [{}], - headers: [{}], + err: {}, }) const goBack = () => { @@ -194,25 +44,61 @@ export function DialogCustomProvider(props: Props) { } const addModel = () => { - setForm("models", (v) => [...v, { id: "", name: "" }]) - setErrors("models", (v) => [...v, {}]) + setForm( + "models", + produce((rows) => { + rows.push(modelRow()) + }), + ) } const removeModel = (index: number) => { if (form.models.length <= 1) return - setForm("models", (v) => v.filter((_, i) => i !== index)) - setErrors("models", (v) => v.filter((_, i) => i !== index)) + setForm( + "models", + produce((rows) => { + rows.splice(index, 1) + }), + ) } const addHeader = () => { - setForm("headers", (v) => [...v, { key: "", value: "" }]) - setErrors("headers", (v) => [...v, {}]) + setForm( + "headers", + produce((rows) => { + rows.push(headerRow()) + }), + ) } const removeHeader = (index: number) => { if (form.headers.length <= 1) return - setForm("headers", (v) => v.filter((_, i) => i !== index)) - setErrors("headers", (v) => v.filter((_, i) => i !== index)) + setForm( + "headers", + produce((rows) => { + rows.splice(index, 1) + }), + ) + } + + const setField = (key: "providerID" | "name" | "baseURL" | "apiKey", value: string) => { + setForm(key, value) + if (key === "apiKey") return + setForm("err", key, undefined) + } + + const setModel = (index: number, key: "id" | "name", value: string) => { + batch(() => { + setForm("models", index, key, value) + setForm("models", index, "err", key, undefined) + }) + } + + const setHeader = (index: number, key: "key" | "value", value: string) => { + batch(() => { + setForm("headers", index, key, value) + setForm("headers", index, "err", key, undefined) + }) } const validate = () => { @@ -222,7 +108,11 @@ export function DialogCustomProvider(props: Props) { disabledProviders: globalSync.data.config.disabled_providers ?? [], existingProviderIDs: new Set(globalSync.data.provider.all.map((p) => p.id)), }) - setErrors(output.errors) + batch(() => { + setForm("err", output.err) + output.models.forEach((err, index) => setForm("models", index, "err", err)) + output.headers.forEach((err, index) => setForm("headers", index, "err", err)) + }) return output.result } @@ -305,32 +195,32 @@ export function DialogCustomProvider(props: Props) { placeholder={language.t("provider.custom.field.providerID.placeholder")} description={language.t("provider.custom.field.providerID.description")} value={form.providerID} - onChange={(v) => setForm("providerID", v)} - validationState={errors.providerID ? "invalid" : undefined} - error={errors.providerID} + onChange={(v) => setField("providerID", v)} + validationState={form.err.providerID ? "invalid" : undefined} + error={form.err.providerID} /> setForm("name", v)} - validationState={errors.name ? "invalid" : undefined} - error={errors.name} + onChange={(v) => setField("name", v)} + validationState={form.err.name ? "invalid" : undefined} + error={form.err.name} /> setForm("baseURL", v)} - validationState={errors.baseURL ? "invalid" : undefined} - error={errors.baseURL} + onChange={(v) => setField("baseURL", v)} + validationState={form.err.baseURL ? "invalid" : undefined} + error={form.err.baseURL} /> setForm("apiKey", v)} + onChange={(v) => setField("apiKey", v)} /> @@ -338,16 +228,16 @@ export function DialogCustomProvider(props: Props) { {(m, i) => ( -
+
setForm("models", i(), "id", v)} - validationState={errors.models[i()]?.id ? "invalid" : undefined} - error={errors.models[i()]?.id} + onChange={(v) => setModel(i(), "id", v)} + validationState={m.err.id ? "invalid" : undefined} + error={m.err.id} />
@@ -356,9 +246,9 @@ export function DialogCustomProvider(props: Props) { hideLabel placeholder={language.t("provider.custom.models.name.placeholder")} value={m.name} - onChange={(v) => setForm("models", i(), "name", v)} - validationState={errors.models[i()]?.name ? "invalid" : undefined} - error={errors.models[i()]?.name} + onChange={(v) => setModel(i(), "name", v)} + validationState={m.err.name ? "invalid" : undefined} + error={m.err.name} />
{language.t("provider.custom.headers.label")} {(h, i) => ( -
+
setForm("headers", i(), "key", v)} - validationState={errors.headers[i()]?.key ? "invalid" : undefined} - error={errors.headers[i()]?.key} + onChange={(v) => setHeader(i(), "key", v)} + validationState={h.err.key ? "invalid" : undefined} + error={h.err.key} />
@@ -400,9 +290,9 @@ export function DialogCustomProvider(props: Props) { hideLabel placeholder={language.t("provider.custom.headers.value.placeholder")} value={h.value} - onChange={(v) => setForm("headers", i(), "value", v)} - validationState={errors.headers[i()]?.value ? "invalid" : undefined} - error={errors.headers[i()]?.value} + onChange={(v) => setHeader(i(), "value", v)} + validationState={h.err.value ? "invalid" : undefined} + error={h.err.value} />
ReturnType["tabs"]> language: ReturnType }) { + const tabState = createSessionTabs({ + tabs: props.tabs, + pathFromTab: props.file.pathFromTab, + normalizeTab: (tab) => (tab.startsWith("file://") ? props.file.tab(tab) : tab), + }) const recent = createMemo(() => { - const all = props.tabs().all() - const active = props.tabs().active() + const all = tabState.openedTabs() + const active = tabState.activeFileTab() const order = active ? [active, ...all.filter((item) => item !== active)] : all const seen = new Set() const category = props.language.t("palette.group.files") diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index f1a33e75f3..ac5beed694 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -26,7 +26,6 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { IconButton } from "@opencode-ai/ui/icon-button" import { Select } from "@opencode-ai/ui/select" -import { RadioGroup } from "@opencode-ai/ui/radio-group" import { useDialog } from "@opencode-ai/ui/context/dialog" import { ModelSelectorPopover } from "@/components/dialog-select-model" import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid" @@ -37,6 +36,7 @@ import { usePermission } from "@/context/permission" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { useSessionLayout } from "@/pages/session/session-layout" +import { createSessionTabs } from "@/pages/session/helpers" import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom" import { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments" import { @@ -48,7 +48,7 @@ import { type PromptHistoryStoredEntry, promptLength, } from "./prompt-input/history" -import { createPromptSubmit } from "./prompt-input/submit" +import { createPromptSubmit, type FollowupDraft } from "./prompt-input/submit" import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover" import { PromptContextItems } from "./prompt-input/context-items" import { PromptImageAttachments } from "./prompt-input/image-attachments" @@ -61,6 +61,11 @@ interface PromptInputProps { ref?: (el: HTMLDivElement) => void newSessionWorktree?: string onNewSessionWorktreeReset?: () => void + edit?: { id: string; prompt: Prompt; context: FollowupDraft["context"] } + onEditLoaded?: () => void + shouldQueue?: () => boolean + onQueue?: (draft: FollowupDraft) => void + onAbort?: () => void onSubmit?: () => void } @@ -154,6 +159,12 @@ export const PromptInput: Component = (props) => { requestAnimationFrame(scrollCursorIntoView) } + const activeFileTab = createSessionTabs({ + tabs, + pathFromTab: files.pathFromTab, + normalizeTab: (tab) => (tab.startsWith("file://") ? files.tab(tab) : tab), + }).activeFileTab + const commentInReview = (path: string) => { const sessionID = params.id if (!sessionID) return false @@ -205,7 +216,7 @@ export const PromptInput: Component = (props) => { const recent = createMemo(() => { const all = tabs().all() - const active = tabs().active() + const active = activeFileTab() const order = active ? [active, ...all.filter((x) => x !== active)] : all const seen = new Set() const paths: string[] = [] @@ -941,6 +952,45 @@ export const PromptInput: Component = (props) => { setCurrentHistory("entries", next) } + createEffect( + on( + () => props.edit?.id, + (id) => { + const edit = props.edit + if (!id || !edit) return + + for (const item of prompt.context.items()) { + prompt.context.remove(item.key) + } + + for (const item of edit.context) { + prompt.context.add({ + type: item.type, + path: item.path, + selection: item.selection, + comment: item.comment, + commentID: item.commentID, + commentOrigin: item.commentOrigin, + preview: item.preview, + }) + } + + setStore("mode", "normal") + setStore("popover", null) + setStore("historyIndex", -1) + setStore("savedPrompt", null) + prompt.set(edit.prompt, promptLength(edit.prompt)) + requestAnimationFrame(() => { + editorRef.focus() + setCursorPosition(editorRef, promptLength(edit.prompt)) + queueScroll() + }) + props.onEditLoaded?.() + }, + { defer: true }, + ), + ) + const navigateHistory = (direction: "up" | "down") => { const result = navigatePromptHistory({ direction, @@ -995,6 +1045,9 @@ export const PromptInput: Component = (props) => { setPopover: (popover) => setStore("popover", popover), newSessionWorktree: () => props.newSessionWorktree, onNewSessionWorktreeReset: props.onNewSessionWorktreeReset, + shouldQueue: props.shouldQueue, + onQueue: props.onQueue, + onAbort: props.onAbort, onSubmit: props.onSubmit, }) @@ -1481,36 +1534,6 @@ export const PromptInput: Component = (props) => {
-
- mode} - label={(mode) => ( - - - - )} - onSelect={(mode) => mode && setMode(mode)} - fill - pad="none" - class="w-[68px]" - /> -
diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index fee6b070d9..eb3e0c82fb 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -9,7 +9,7 @@ import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" import { useLocal } from "@/context/local" import { usePermission } from "@/context/permission" -import { type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt" +import { type ContextItem, type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt" import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" import { Identifier } from "@/utils/id" @@ -25,6 +25,145 @@ type PendingPrompt = { const pending = new Map() +export type FollowupDraft = { + sessionID: string + sessionDirectory: string + prompt: Prompt + context: (ContextItem & { key: string })[] + agent: string + model: { providerID: string; modelID: string } + variant?: string +} + +type FollowupSendInput = { + client: ReturnType["client"] + globalSync: ReturnType + sync: ReturnType + draft: FollowupDraft + messageID?: string + optimisticBusy?: boolean + before?: () => Promise | boolean +} + +const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ? part.content : "")).join("") + +const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttachmentPart => part.type === "image") + +export async function sendFollowupDraft(input: FollowupSendInput) { + const text = draftText(input.draft.prompt) + const images = draftImages(input.draft.prompt) + const [, setStore] = input.globalSync.child(input.draft.sessionDirectory) + + const setBusy = () => { + if (!input.optimisticBusy) return + setStore("session_status", input.draft.sessionID, { type: "busy" }) + } + + const setIdle = () => { + if (!input.optimisticBusy) return + setStore("session_status", input.draft.sessionID, { type: "idle" }) + } + + const wait = async () => { + const ok = await input.before?.() + if (ok === false) return false + return true + } + + const [head, ...tail] = text.split(" ") + const cmd = head?.startsWith("/") ? head.slice(1) : undefined + if (cmd && input.sync.data.command.find((item) => item.name === cmd)) { + setBusy() + try { + if (!(await wait())) { + setIdle() + return false + } + + await input.client.session.command({ + sessionID: input.draft.sessionID, + command: cmd, + arguments: tail.join(" "), + agent: input.draft.agent, + model: `${input.draft.model.providerID}/${input.draft.model.modelID}`, + variant: input.draft.variant, + parts: images.map((attachment) => ({ + id: Identifier.ascending("part"), + type: "file" as const, + mime: attachment.mime, + url: attachment.dataUrl, + filename: attachment.filename, + })), + }) + return true + } catch (err) { + setIdle() + throw err + } + } + + const messageID = input.messageID ?? Identifier.ascending("message") + const { requestParts, optimisticParts } = buildRequestParts({ + prompt: input.draft.prompt, + context: input.draft.context, + images, + text, + sessionID: input.draft.sessionID, + messageID, + sessionDirectory: input.draft.sessionDirectory, + }) + + const message: Message = { + id: messageID, + sessionID: input.draft.sessionID, + role: "user", + time: { created: Date.now() }, + agent: input.draft.agent, + model: input.draft.model, + variant: input.draft.variant, + } + + const add = () => + input.sync.session.optimistic.add({ + directory: input.draft.sessionDirectory, + sessionID: input.draft.sessionID, + message, + parts: optimisticParts, + }) + + const remove = () => + input.sync.session.optimistic.remove({ + directory: input.draft.sessionDirectory, + sessionID: input.draft.sessionID, + messageID, + }) + + setBusy() + add() + + try { + if (!(await wait())) { + setIdle() + remove() + return false + } + + await input.client.session.promptAsync({ + sessionID: input.draft.sessionID, + agent: input.draft.agent, + model: input.draft.model, + messageID, + parts: requestParts, + variant: input.draft.variant, + }) + return true + } catch (err) { + setIdle() + remove() + throw err + } +} + type PromptSubmitInput = { info: Accessor<{ id: string } | undefined> imageAttachments: Accessor @@ -41,6 +180,9 @@ type PromptSubmitInput = { setPopover: (popover: "at" | "slash" | null) => void newSessionWorktree?: Accessor onNewSessionWorktreeReset?: () => void + shouldQueue?: Accessor + onQueue?: (draft: FollowupDraft) => void + onAbort?: () => void onSubmit?: () => void } @@ -82,6 +224,8 @@ export function createPromptSubmit(input: PromptSubmitInput) { const [, setStore] = globalSync.child(sdk.directory) setStore("todo", sessionID, []) + input.onAbort?.() + const queued = pending.get(sessionID) if (queued) { queued.abort.abort() @@ -116,6 +260,12 @@ export function createPromptSubmit(input: PromptSubmitInput) { } } + const clearContext = () => { + for (const item of prompt.context.items()) { + prompt.context.remove(item.key) + } + } + const handleSubmit = async (event: Event) => { event.preventDefault() @@ -215,14 +365,22 @@ export function createPromptSubmit(input: PromptSubmitInput) { return } - input.onSubmit?.() - const model = { modelID: currentModel.id, providerID: currentModel.provider.id, } const agent = currentAgent.name const variant = local.model.variant.current() + const context = prompt.context.items().slice() + const draft: FollowupDraft = { + sessionID: session.id, + sessionDirectory, + prompt: currentPrompt, + context, + agent, + model, + variant, + } const clearInput = () => { prompt.reset() @@ -243,6 +401,15 @@ export function createPromptSubmit(input: PromptSubmitInput) { }) } + if (!isNewSession && mode === "normal" && input.shouldQueue?.()) { + input.onQueue?.(draft) + clearContext() + clearInput() + return + } + + input.onSubmit?.() + if (mode === "shell") { clearInput() client.session @@ -295,48 +462,19 @@ export function createPromptSubmit(input: PromptSubmitInput) { } } - const context = prompt.context.items().slice() const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim()) - const messageID = Identifier.ascending("message") - const { requestParts, optimisticParts } = buildRequestParts({ - prompt: currentPrompt, - context, - images, - text, - sessionID: session.id, - messageID, - sessionDirectory, - }) - const optimisticMessage: Message = { - id: messageID, - sessionID: session.id, - role: "user", - time: { created: Date.now() }, - agent, - model, - variant, - } - - const addOptimisticMessage = () => - sync.session.optimistic.add({ - directory: sessionDirectory, - sessionID: session.id, - message: optimisticMessage, - parts: optimisticParts, - }) - - const removeOptimisticMessage = () => + const removeOptimisticMessage = () => { sync.session.optimistic.remove({ directory: sessionDirectory, sessionID: session.id, messageID, }) + } removeCommentItems(commentItems) clearInput() - addOptimisticMessage() const waitForWorktree = async () => { const worktree = WorktreeState.get(sessionDirectory) @@ -393,20 +531,15 @@ export function createPromptSubmit(input: PromptSubmitInput) { return true } - const send = async () => { - const ok = await waitForWorktree() - if (!ok) return - await client.session.promptAsync({ - sessionID: session.id, - agent, - model, - messageID, - parts: requestParts, - variant, - }) - } - - void send().catch((err) => { + void sendFollowupDraft({ + client, + sync, + globalSync, + draft, + messageID, + optimisticBusy: sessionDirectory === projectDirectory, + before: waitForWorktree, + }).catch((err) => { pending.delete(session.id) if (sessionDirectory === projectDirectory) { sync.set("session_status", session.id, { type: "idle" }) diff --git a/packages/app/src/components/server/server-row.tsx b/packages/app/src/components/server/server-row.tsx index 5bb290ec30..8a4b7be4dc 100644 --- a/packages/app/src/components/server/server-row.tsx +++ b/packages/app/src/components/server/server-row.tsx @@ -65,22 +65,26 @@ export function ServerRow(props: ServerRowProps) { return (
-
-
- +
+
+ {name()} - + v{props.status?.version} diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx index 99e6c13a3d..7379833f8b 100644 --- a/packages/app/src/components/session-context-usage.tsx +++ b/packages/app/src/components/session-context-usage.tsx @@ -3,11 +3,13 @@ import { Tooltip, type TooltipProps } from "@opencode-ai/ui/tooltip" import { ProgressCircle } from "@opencode-ai/ui/progress-circle" import { Button } from "@opencode-ai/ui/button" +import { useFile } from "@/context/file" import { useLayout } from "@/context/layout" import { useSync } from "@/context/sync" import { useLanguage } from "@/context/language" import { getSessionContextMetrics } from "@/components/session/session-context-metrics" import { useSessionLayout } from "@/pages/session/session-layout" +import { createSessionTabs } from "@/pages/session/helpers" interface SessionContextUsageProps { variant?: "button" | "indicator" @@ -27,11 +29,17 @@ function openSessionContext(args: { export function SessionContextUsage(props: SessionContextUsageProps) { const sync = useSync() + const file = useFile() const layout = useLayout() const language = useLanguage() const { params, tabs, view } = useSessionLayout() const variant = createMemo(() => props.variant ?? "button") + const tabState = createSessionTabs({ + tabs, + pathFromTab: file.pathFromTab, + normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab), + }) const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : [])) const usd = createMemo( @@ -51,7 +59,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) { const openContext = () => { if (!params.id) return - if (tabs().active() === "context") { + if (tabState.activeTab() === "context") { tabs().close("context") return } diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 9476f8b9ba..8cb704bf1d 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -4,9 +4,7 @@ import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { Keybind } from "@opencode-ai/ui/keybind" -import { Popover } from "@opencode-ai/ui/popover" import { Spinner } from "@opencode-ai/ui/spinner" -import { TextField } from "@opencode-ai/ui/text-field" import { showToast } from "@opencode-ai/ui/toast" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { getFilename } from "@opencode-ai/util/path" @@ -14,12 +12,10 @@ import { createEffect, createMemo, For, onCleanup, Show } from "solid-js" import { createStore } from "solid-js/store" import { Portal } from "solid-js/web" import { useCommand } from "@/context/command" -import { useGlobalSDK } from "@/context/global-sdk" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" import { usePlatform } from "@/context/platform" import { useServer } from "@/context/server" -import { useSync } from "@/context/sync" import { useTerminal } from "@/context/terminal" import { focusTerminalById } from "@/pages/session/helpers" import { useSessionLayout } from "@/pages/session/session-layout" @@ -112,12 +108,6 @@ const LINUX_APPS = [ }, ] 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(["finder", "vscode", "cursor", "zed"]) - -const openIconSize = (id: OpenIcon) => (OPEN_ICON_BASE.has(id) ? "size-4" : "size-[19px]") - const detectOS = (platform: ReturnType): OS => { if (platform.platform === "desktop" && platform.os) return platform.os if (typeof navigator !== "object") return "unknown" @@ -136,98 +126,10 @@ const showRequestError = (language: ReturnType, err: unknown }) } -function useSessionShare(args: { - globalSDK: ReturnType - currentSession: () => - | { - share?: { - url?: string - } - } - | undefined - sessionID: () => string | undefined - projectDirectory: () => string - platform: ReturnType -}) { - 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 sessionID = args.sessionID() - if (!sessionID || state.share) return - setState("share", true) - args.globalSDK.client.session - .share({ sessionID, directory: args.projectDirectory() }) - .catch((error) => { - console.error("Failed to share session", error) - }) - .finally(() => { - setState("share", false) - }) - } - - const unshareSession = () => { - const sessionID = args.sessionID() - if (!sessionID || state.unshare) return - setState("unshare", true) - args.globalSDK.client.session - .unshare({ sessionID, 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() const command = useCommand() const server = useServer() - const sync = useSync() const platform = usePlatform() const language = useLanguage() const terminal = useTerminal() @@ -245,10 +147,6 @@ export function SessionHeader() { return getFilename(projectDirectory()) }) const hotkey = createMemo(() => command.keybind("file.open")) - - const currentSession = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) - const shareEnabled = createMemo(() => sync.data.config.share !== "disabled") - const showShare = createMemo(() => shareEnabled() && !!params.id) const os = createMemo(() => detectOS(platform)) const [exists, setExists] = createStore>>({ @@ -356,14 +254,6 @@ export function SessionHeader() { .catch((err: unknown) => showRequestError(language, err)) } - const share = useSessionShare({ - globalSDK, - currentSession, - sessionID: () => params.id, - projectDirectory, - platform, - }) - const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center")) const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right")) @@ -391,7 +281,9 @@ export function SessionHeader() { {(keybind) => ( - {keybind()} + + {keybind()} + )} @@ -402,7 +294,6 @@ export function SessionHeader() { {(mount) => (
-