Skip to content
Merged

V1.1 #19

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,6 @@ tsconfig.tsbuildinfo
# Internal design docs — kept local-only, not tracked in git (see S2-v3.4.1 DOC-2).
# Erased from history via git-filter-repo; the public distilled version lives in design/.
docs/

# Non-code domain packs — own git repo (deepagent-domain), not tracked by main repo.
extra-domain/
1 change: 1 addition & 0 deletions packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"exports": {
".": "./src/index.ts",
"./desktop-menu": "./src/desktop-menu.ts",
"./zoom-levels": "./src/zoom-levels.ts",
"./updater": "./src/updater.ts",
"./wsl/types": "./src/wsl/types.ts",
"./vite": "./vite.js",
Expand Down
153 changes: 142 additions & 11 deletions packages/app/src/components/dialog-connect-provider.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@deepagent-code/sdk/v2/client"
import { Button } from "@deepagent-code/ui/button"
import { Collapsible } from "@deepagent-code/ui/collapsible"
import { useDialog } from "@deepagent-code/ui/context/dialog"
import { Dialog } from "@deepagent-code/ui/dialog"
import { Icon } from "@deepagent-code/ui/icon"
Expand All @@ -16,19 +17,30 @@ import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers"
import { isOfficialProvider } from "@deepagent-code/core/provider-official"

// Recommended/official providers ship a fixed protocol (npm) tied to a canonical endpoint. Letting
// users point one of these at a relay/local server silently breaks because the protocol no longer
// matches the endpoint — those users must add a custom provider instead. So the connect dialog hides
// the baseURL field for these and only exposes it for ad-hoc config/custom providers.
// the baseURL field for these and only exposes it for ad-hoc config/custom providers. Google and xAI
// resolve their real endpoint from the bundled SDK; the URL here only satisfies form validation and
// the placeholder. Keys must exist for every official id (see OFFICIAL_PROVIDER_IDS in core).
const providerBaseURL: Record<string, string> = {
openai: "https://api.openai.com/v1",
deepseek: "https://api.deepseek.com",
anthropic: "https://api.anthropic.com/v1",
zhipuai: "https://open.bigmodel.cn/api/paas/v4",
"zhipuai-coding-plan": "https://open.bigmodel.cn/api/coding/paas/v4",
zai: "https://api.z.ai/api/paas/v4",
"zai-coding-plan": "https://api.z.ai/api/coding/paas/v4",
xai: "https://api.x.ai/v1",
google: "https://generativelanguage.googleapis.com/v1beta",
}

const isBuiltinProvider = (providerID: string) => providerID in providerBaseURL
// Official = first-party in the backend (key store credentials, fixed protocol). This must match the
// backend's OFFICIAL_PROVIDER_IDS exactly so the connect flow routes credentials the same way the
// loader reads them (auth for official, config options.apiKey for third-party).
const isBuiltinProvider = (providerID: string) => isOfficialProvider(providerID)

const providerKind = (providerID: string) => (providerID === "anthropic" ? "anthropic" : "openai-compatible")

Expand Down Expand Up @@ -419,6 +431,13 @@ export function DialogConnectProvider(props: { provider: string }) {
id: model.id,
name: model.name || model.id,
}))
// Official-provider transport settings are surfaced by the config overlay under
// provider.<id>.options (backed by SettingsStore, not the config file). Seed the fields from there.
const transportOption = (key: string): string => {
const value = serverSync.data.config.provider?.[props.provider]?.options?.[key]
if (value === false) return "0"
return typeof value === "number" ? String(value) : ""
}
const [formStore, setFormStore] = createStore({
value: "",
baseURL:
Expand All @@ -430,8 +449,42 @@ export function DialogConnectProvider(props: { provider: string }) {
discovered: [] as DiscoveredProviderModel[],
selectedModel: undefined as string | undefined,
discovering: false,
headerTimeout: transportOption("headerTimeout"),
chunkTimeout: transportOption("chunkTimeout"),
timeout: transportOption("timeout"),
maxRetries: transportOption("maxRetries"),
transportError: undefined as string | undefined,
})

// Parse a transport field: empty → undefined (use default); "0" for header/request timeout →
// false (disabled); otherwise a positive integer. Returns { ok, value } so callers can reject
// malformed input.
function parseTransport(raw: string, allowFalse: boolean): { ok: boolean; value: number | false | undefined } {
const trimmed = raw.trim()
if (trimmed === "") return { ok: true, value: undefined }
const n = Number(trimmed)
if (!Number.isInteger(n) || n < 0) return { ok: false, value: undefined }
if (n === 0) return allowFalse ? { ok: true, value: false } : { ok: false, value: undefined }
return { ok: true, value: n }
}

// Build the SettingsStore-bound options patch for an official provider (routed via the config
// overlay's intercept). Returns undefined when a field is malformed.
function transportOptions(): Record<string, number | false> | undefined | "invalid" {
const header = parseTransport(formStore.headerTimeout, true)
const chunk = parseTransport(formStore.chunkTimeout, false)
const request = parseTransport(formStore.timeout, true)
const retries = parseTransport(formStore.maxRetries, false)
if (!header.ok || !chunk.ok || !request.ok || !retries.ok) return "invalid"
const options: Record<string, number | false> = {}
if (header.value !== undefined) options.headerTimeout = header.value
if (chunk.value !== undefined && chunk.value !== false) options.chunkTimeout = chunk.value
if (request.value !== undefined) options.timeout = request.value
if (retries.value !== undefined && retries.value !== false) options.maxRetries = retries.value
return options
}


async function handleSubmit(e: SubmitEvent) {
e.preventDefault()

Expand All @@ -456,6 +509,43 @@ export function DialogConnectProvider(props: { provider: string }) {

setFormStore("error", undefined)
setFormStore("baseURLError", undefined)

if (isBuiltinProvider(props.provider)) {
const transport = transportOptions()
if (transport === "invalid") {
setFormStore("transportError", language.t("provider.connect.transport.error.number"))
return
}
setFormStore("transportError", undefined)
if (key) {
await serverSDK.client.auth.set({
providerID: props.provider,
auth: {
type: "api",
key,
},
})
}
// Route transport tuning through the config overlay's intercept → SettingsStore. The
// remaining official-provider fields are ignored by the backend loader; only options.<transport>
// is picked up (and stripped from the config file). Always send the key so cleared fields reset.
const disabled = (serverSync.data.config.disabled_providers ?? []).filter((id) => id !== props.provider)
await serverSync.updateConfig({
disabled_providers: disabled,
provider: {
[props.provider]: { options: transport },
},
})
await serverSDK.client.global.dispose()
showToast({
variant: "success",
icon: "circle-check",
title: language.t("provider.connect.toast.connected.title", { provider: provider().name }),
description: language.t("provider.connect.toast.connected.description", { provider: provider().name }),
})
return
}

setFormStore("discovering", true)
try {
const discovered = await serverSDK.client.provider.models
Expand Down Expand Up @@ -484,15 +574,6 @@ export function DialogConnectProvider(props: { provider: string }) {
setFormStore("discovered", nextModels)
setFormStore("selectedModel", nextSelected.id)

if (key) {
await serverSDK.client.auth.set({
providerID: props.provider,
auth: {
type: "api",
key,
},
})
}
const current = serverSync.data.config.provider?.[props.provider] ?? {}
await serverSync.updateConfig({
provider: {
Expand All @@ -502,6 +583,7 @@ export function DialogConnectProvider(props: { provider: string }) {
options: {
...(current.options ?? {}),
baseURL,
...(key ? { apiKey: key } : {}),
},
models: {
...(current.models ?? {}),
Expand Down Expand Up @@ -558,6 +640,55 @@ export function DialogConnectProvider(props: { provider: string }) {
validationState={formStore.error ? "invalid" : undefined}
error={formStore.error}
/>
<Show when={isBuiltinProvider(props.provider)}>
<Collapsible class="w-full">
<Collapsible.Trigger class="flex w-full items-center justify-between gap-2 text-13-medium text-text-base">
<span>{language.t("provider.connect.transport.title")}</span>
<Collapsible.Arrow />
</Collapsible.Trigger>
<Collapsible.Content>
<div class="flex flex-col gap-4 pt-4">
<div class="text-12-regular text-text-weak">
{language.t("provider.connect.transport.description")}
</div>
<TextField
type="text"
inputmode="numeric"
label={language.t("provider.connect.transport.headerTimeout.label")}
placeholder={language.t("provider.connect.transport.headerTimeout.placeholder")}
value={formStore.headerTimeout}
onChange={(v) => setFormStore("headerTimeout", v)}
/>
<TextField
type="text"
inputmode="numeric"
label={language.t("provider.connect.transport.chunkTimeout.label")}
placeholder={language.t("provider.connect.transport.chunkTimeout.placeholder")}
value={formStore.chunkTimeout}
onChange={(v) => setFormStore("chunkTimeout", v)}
/>
<TextField
type="text"
inputmode="numeric"
label={language.t("provider.connect.transport.timeout.label")}
placeholder={language.t("provider.connect.transport.timeout.placeholder")}
value={formStore.timeout}
onChange={(v) => setFormStore("timeout", v)}
/>
<TextField
type="text"
inputmode="numeric"
label={language.t("provider.connect.transport.maxRetries.label")}
placeholder={language.t("provider.connect.transport.maxRetries.placeholder")}
value={formStore.maxRetries}
onChange={(v) => setFormStore("maxRetries", v)}
validationState={formStore.transportError ? "invalid" : undefined}
error={formStore.transportError}
/>
</div>
</Collapsible.Content>
</Collapsible>
</Show>
<Button class="w-auto" type="submit" size="large" variant="primary" disabled={formStore.discovering}>
{language.t("common.continue")}
</Button>
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/components/dialog-custom-provider-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ export function validateCustomProvider(input: ValidateArgs) {
...(env ? { env: [env] } : {}),
options: {
baseURL,
...(key ? { apiKey: key } : {}),
...(Object.keys(headerConfig).length ? { headers: headerConfig } : {}),
},
models: modelConfig,
Expand Down
10 changes: 0 additions & 10 deletions packages/app/src/components/dialog-custom-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,16 +132,6 @@ export function DialogCustomProvider(props: Props) {
const disabledProviders = serverSync.data.config.disabled_providers ?? []
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)

if (result.key) {
await serverSDK.client.auth.set({
providerID: result.providerID,
auth: {
type: "api",
key: result.key,
},
})
}

await serverSync.updateConfig({
provider: { [result.providerID]: result.config },
disabled_providers: nextDisabled,
Expand Down
35 changes: 34 additions & 1 deletion packages/app/src/components/settings-v2/general.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { useParams } from "@solidjs/router"
import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
import { usePlatform, type DisplayBackend } from "@/context/platform"
import { ZOOM_LEVELS } from "@/zoom-levels"
import { useServerSync } from "@/context/server-sync"
import { useServerSDK } from "@/context/server-sdk"
import { useUpdaterAction } from "../updater-action"
Expand Down Expand Up @@ -284,6 +285,10 @@ export const SettingsGeneralV2: Component = () => {
})),
)

const zoomOptions = createMemo(() =>
ZOOM_LEVELS.map((level) => ({ value: String(level), label: `${Math.round(level * 100)}%` })),
)

const noneSound = { id: "none", label: "sound.option.none" } as const
const soundOptions = [noneSound, ...SOUND_OPTIONS]
const mono = () => monoInput(settings.appearance.font())
Expand Down Expand Up @@ -418,7 +423,18 @@ export const SettingsGeneralV2: Component = () => {
if (option.value === deepAgentWishModel()) return
void updateDeepAgentOptions(serverSync, { wishModel: option.value })
}}
/>
>
{(o) => (
<span class="flex items-center gap-2">
<span>{o.label}</span>
<Show when={o.description}>
<span class="rounded-sm bg-background-strong px-1.5 py-0.5 text-11-regular text-text-weak">
{o.description}
</span>
</Show>
</span>
)}
</SelectV2>
</SettingsRowV2>

<SettingsRowV2
Expand Down Expand Up @@ -811,6 +827,23 @@ export const SettingsGeneralV2: Component = () => {
</div>
</SettingsRowV2>

<SettingsRowV2
title={language.t("settings.general.row.zoom.title")}
description={language.t("settings.general.row.zoom.description")}
>
<SelectV2
appearance="inline"
data-action="settings-zoom"
options={zoomOptions()}
current={zoomOptions().find((o) => o.value === String(platform.webviewZoom?.()))}
placement="bottom-end"
gutter={6}
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && platform.setWebviewZoom?.(Number(option.value))}
/>
</SettingsRowV2>

<Show when={linux()}>
<SettingsRowV2
title={
Expand Down
19 changes: 16 additions & 3 deletions packages/app/src/components/windows-app-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ import { IconButtonV2 } from "@deepagent-code/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@deepagent-code/ui/v2/icon"

import { useCommand } from "@/context/command"
import { DESKTOP_MENU, desktopMenuVisible, type DesktopMenuAction, type DesktopMenuEntry } from "@/desktop-menu"
import { DESKTOP_MENU, desktopMenuVisible, type DesktopMenuAction, type DesktopMenuEntry, type DesktopMenuItem } from "@/desktop-menu"
import { usePlatform } from "@/context/platform"
import { canZoomIn, canZoomOut, zoomPercent } from "@/zoom-levels"

export function WindowsAppMenu(props: {
command: ReturnType<typeof useCommand>
Expand Down Expand Up @@ -46,6 +47,18 @@ export function WindowsAppMenu(props: {
if (entry.href) props.platform.openLink(entry.href)
}

const zoom = () => props.platform.webviewZoom?.() ?? 1
const isZoomAction = (action: DesktopMenuAction | undefined) =>
action === "view.zoomIn" || action === "view.zoomOut" || action === "view.resetZoom"
const zoomLabel = (entry: DesktopMenuItem) =>
isZoomAction(entry.action) ? `${entry.label ?? ""} ${zoomPercent(zoom())}%` : entry.label ?? ""
const zoomDisabled = (entry: DesktopMenuItem) => {
if (entry.command) return commandDisabled(entry.command)
if (entry.action === "view.zoomIn") return !canZoomIn(zoom())
if (entry.action === "view.zoomOut") return !canZoomOut(zoom())
return false
}

return (
<DropdownMenu gutter={4} modal={false} placement="bottom-start">
{props.variant === "v2" ? (
Expand Down Expand Up @@ -87,9 +100,9 @@ export function WindowsAppMenu(props: {
<DropdownMenu.Separator />
) : (
<DesktopMenuItem
label={entry.label ?? ""}
label={zoomLabel(entry)}
keybind={entry.command ? props.command.keybind(entry.command) : entry.accelerator?.windows}
disabled={entry.command ? commandDisabled(entry.command) : false}
disabled={zoomDisabled(entry)}
onSelect={() => runEntry(entry)}
/>
),
Expand Down
3 changes: 2 additions & 1 deletion packages/app/src/context/global-sync/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,10 @@ export type ProjectMeta = {
export type SessionPlanStep = {
step_id: string
title: string
status: string // pending | active | done | cancelled
status: string // pending | active | done | cancelled | blocked
acceptance?: string | null
assigned_agent?: string | null
note?: string | null // U10: blocker explanation when status is "blocked"
}
export type SessionPlan = {
plan_id: string
Expand Down
Loading
Loading