diff --git a/.agents/upstream-review.md b/.agents/upstream-review.md index 5ee479362..596a4fef6 100644 --- a/.agents/upstream-review.md +++ b/.agents/upstream-review.md @@ -64,6 +64,7 @@ Historical groups are indexed in the linked archive. This file migration changes | Files in question answers / `6c583620ff7ad3235b135af7107c0543467eecfa` | `7220dfe2c949476eaa7d21eccbcd3a0ce0eddb49` (#9871) | Adopted across contracts, server, web, desktop and mobile: `attachmentsByQuestionId` on respond commands and events, the `questionAttachments` capability, normalizer claims, `user-input.answer-submitted` history, provider answer path lines in `ProviderService`, and a separate `projection.attachment-cleanup` cursor that retains answer files through reverts and deletes. Per adapter: Codex, Claude, Cursor, Grok and OpenCode receive path lines; Antigravity questions never accept custom answers, so the decider refuses their attachments; Prime asks no user-input questions. Pylon adaptation: normalizer and failed-dispatch cleanup keep `thread.input-queue.follow-up`; mobile readiness uses the #10404 upload helpers from #460 as upstream does. Pylon diverges on cleanup mechanics, which upstream shares at the frozen head: the live path writes the cleanup cursor with the projector cursors at the last finished cleanup; bootstrap selects only revert/delete rows past it without decoding payloads, lists the attachments directory once and skips threads without files (1,000 deleted threads with 3,000 files: 28.8 s to 2 ms); a database without the row starts at its lowest projector cursor; file errors are logged and retried once on the next start instead of pinning the cursor; revert retention reads only answer activities; unused `minLastAppliedSequence` removed. Mobile discards question drafts only on live thread data and explains a paste refused by an older server. Claude, Cursor, Grok and OpenCode are code-path verified; upstream live-tested Codex. Cursor unchanged. | [Question attachments #463](https://github.com/pylon-code/pylon/pull/463); 868 focused tests (28 files: server 16, web 7, mobile 5), five package typechecks, scoped lint/format, before/after bootstrap probe. | | Desktop window capture (SnapShots), Electron 44 and post-Electron 43 recording / `6c583620ff7ad3235b135af7107c0543467eecfa` | `299404a754f52c02c69634528d8856b3c93b378c` (#8103), `9fe4d6568b5e4852f6085d3a22dbc5aa3a632408` (#10645), `ef7014d851f56bb037a9da963095ffd883c7fa08` (#9001), `4a9d2d0ced2a2b899dbee9e4a5162fd83f81edb8` (#8626, partial); skipped `8de9169f078fdafedf47d8c299cb8e2c79fda6fb` (v0.0.40 release bump) | Adopted opt-in capture on macOS, Windows and Wayland (KDE/Hyprland helpers, GNOME extension, Niri, portal), setup/settings, attachment metadata and fenced prompt context, plus CI/release steps for both crates. Electron 41.5.0 moves to 44.1.0 (maintainer approved): packaged macOS builds require 13.0 and mac update manifests carry `minimumSystemVersion` 22.0.0 so macOS 12 keeps its build; the runtime repair script uses Electron's checksummed installer, checks its fallback download against the package's bundled checksums and reinstalls partial runtimes, and desktop tests install the runtime once in a Vitest global setup; pickers without a default path reopen in the last picked directory; #8626 is superseded except its required `websql` removal. #9001 restores preview recording via `getDisplayMedia` arming and keeps inactive macOS guests paintable, preserving Pylon's hardware-first bitrate recorder, #10403 `ideal` frame rate, stream cleanup, lock timeouts and z-index presentation; #462's recording-transfer tests now use its capture trigger. Agent screenshots and snapshots keep `capturePage` without `stayHidden` (unchanged 41.5.0 to 44.1.0) under a surface lease that composites the guest inside the window. Maintainer-approved rename to per-channel `com.pylon.code[.nightly|.dev].desktop` (XDG portals need a dotted app ID) with matching WM class, AppImage `StartupWMClass` and entry icon; the legacy `pylon-code-url-handler.desktop` is removed once the scheme default moves; AGENTS.md updated. Pylon-owned coexistence identifiers: `snap-shot@pylon-code.com` / `PylonSnapShot` extension, channel-keyed `pylon-kde-snap-shot`/`pylon-hyprland-snap-shot` installs and Pylon Niri endpoint; Pylon copy. Captured-window context also rides Pylon follow-ups; undecodable capture metadata drops only the source; `accessibleText` is dropped when structured accessibility exists (icon/tree sidecar deferred until payload size is measured as a problem; the double `SnapShotSource` decode in `ForwardCompatibleOptional` is accepted until capture-heavy threads show decode cost); over-quota drafts no longer re-deliver captures and undeliverable captures are reported once; accessibility details format only when opened; Settings -> Keybindings warns on the global capture chord. Cursor unchanged. | [Window capture #461](https://github.com/pylon-code/pylon/pull/461); after rebasing on `origin/pylon` (through #477): desktop 629 (23 Linux-only skipped), web 584, contracts 164, server 131 (`ProviderService` and question-attachment suites), build-script/manifest/native TS 96 tests; seven Electron-loading desktop suites pass from a fresh or partial `dist`; cargo fmt/test on Linux (resource-monitor 18, KDE 11, Hyprland 10); contracts, shared, client-runtime, t3, web, desktop and mobile typechecks; scoped lint/format. | | Pre-cursor gap sources before `beae2147a9`: previously unrecorded / `6c583620ff7ad3235b135af7107c0543467eecfa` | `8dcb96314c976899e4df6951fb9af03131c2a46f` (#8733), `8b817cbcaad71a53e2ef73f3881067f8aa8094bc` (#8840), `7963ac7404ff2196c3e8e4198ecc02a5e742b0a1` (v0.0.37), `ad38700ac678b8c8a0310d434a44d94a7ee6a47f` (#8917), `4e8e64fc065a4a72535eee5fe60b689f5b48d35c` (#8933), `5ce92c2f192040bf77c0211fa33bf03c74c031ef` (#8932), `f47e74004af232f0e3df8dc10093601d1c2c3ea3` (#8851), `0df043fd4eaa190eb491a3060836156eb0ae915e`, `85b656ff300f71060ad6305c7e1e29a72b442ce9`, `c78ae50a5a5fdf8f42d0aaa0103b26ee836f0cfc` (#8085), `ce71c04f0aa9d2e5cd340e2a04cb1b0d5e24419d` (#8936), `0947c30e6946b2ad6d6cd518fd44292e75e834e8` (#9010), `b883fc066ea5c9bebbe1c3e9b4bc2471aab3685f` (#8367), `9dbdcece5f488c66f6b9ac516b610f45bbbb676a` (#9033), `9d1879b142a2f5d01383357646a4679d1a2bd202` (#9076), `cb007469161ff0db2bc2dc8123c4b30e186aae50` (#8925), `692eb1a5792b9930959b19805acf2bf2611318c9` (#9092), `60cef47ec983637ddc68faed7b1488b6f3c3a175` (v0.0.38) | Adopted: #8085 names remote web session cookies from the persisted environment ID, migrates a valid legacy `t3_session` and gives CLI auth a launcher-free identity layer; #8367 bootstraps from one config subscription, with Pylon's usage-limit source and `/usage-limits` options on web and mobile; #9076 adds Direct, Hold and Double press quit modes, migrating booleans (true to Hold, false to Direct) in browser storage and the desktop file while `readConfirmQuit` keeps its Hold fallback; #8925 adds Project settings to web thread menus; #8851 keeps Pylon's left-run branch layout. Partial: #8932 shimmer clipping only, its shimmer derivation superseded by #10173 and #10273; #8936 web tool grouping now shared with client-runtime, its viewed-image assets already landed with the #9023 port. Already covered: #9010; the #8917 conventions rule (via #9321), with its approvability file skipped. Superseded: #8733 by #8734 (#221), #8840 by #9606, #9033 by #8890 (#402), #9092 by #9332. Skipped: both release bumps and the three CodeRabbit configs; neither CodeRabbit nor Macroscope runs on Pylon PRs. Cursor unchanged. | [Pre-cursor gaps #481](https://github.com/pylon-code/pylon/pull/481); focused tests (server 88, client-runtime 213, web 460, contracts 112, desktop 43), six package typechecks, scoped lint and format. | +| Shared setup wizard components, provider-setup links and Antigravity client completion / `6c583620ff7ad3235b135af7107c0543467eecfa` | `3faeee49ac67dfd9534369f1e1c627c0356b75ac` (#10832), `d487dfbf46be344e818725be70ee04be2436bfb4` (#9647); completes the provider-setup link excluded from #455 and the web client port of `06336460c9988f29c71e839c4c9c840c4552e077` (#9348) that #440 left out, with the project-defaults hunk of `9f40b2f563c662b43887b11ff99c466fe871c1af` (#9754) | Adopted #10832: Welcome, Add provider, Publish repository, Pylon Connect and SnapShot setup share `WizardPopup`/`WizardHeader`/`WizardSteps`/`WizardPanel`/`WizardFooter`, and window capture's duplicate `ui/wizard-steps` stepper (#8103's Connect dialog hunk included) is removed. Pylon kept: PylonMark and Pylon/Pylon Connect copy, Add provider's multiple-instance blocking and Prime guidance, Pylon SnapShots copy, `useThreadShell` and the repository open-link error toast. Provider-setup link: model pickers (composer, Settings → Projects machine and project default model, text generation and source control writer models), the provider status banner and the composer's settings button open Settings → Providers on the owning device with the instance selected (`/settings/providers?environmentId&instanceId`); a cold-opened link waits for the device list, and a device missing from the loaded list or a removed instance each say so. #9348 web completion: the `antigravity-default` marker resolves to the account catalog default in the picker, trigger and composer model state; Antigravity never borrows another account's or a static model and has no custom models in settings; send-block reasons; `refreshModels` on Settings and mobile refresh. #9647: the banner stays hidden and saved-model sends proceed while Antigravity's sign-in is unchecked after a restart. Pylon fixes beyond upstream: with no Antigravity model and no catalog the composer asks for a model refresh (upstream asks for a choice the user cannot make); an instance whose listed models are all hidden stays on its own default instead of #9348's driver-level fallback, and Continue on another account falls back to the target's default. Pylon adaptations: settings targets come from Pylon's `resolveComposerInstanceSelection`, which names a disabled session-bound instance only when no enabled, available continuation peer exists (explicit unavailability keeps the picker); Prime's single-model picker message, locked continuation checks and the explicit-unavailability banner stay. Mobile Antigravity send blocks (`isModelSelectionUnavailable` alerts and outbox restores) are not ported: Pylon mobile holds disabled, uninstalled, signed-out, errored and unavailable providers through the shared admission check, the server rejects a vanished model at turn start, and upstream's catalog-membership test would hold every send after a restart and every `antigravity-default` selection; revisit when upstream fixes those cases or mobile gains marker resolution. Mobile otherwise gets only the refresh change (upstream removed mobile provider setup in #9721). Cursor unchanged. | [Setup wizards #479](https://github.com/pylon-code/pylon/pull/479); 820 focused tests (web 818 in 32 files, mobile 2), web, mobile and client-runtime typechecks, scoped lint/format; upstream UI evidence linked, no local client pass. | ## Deferred register diff --git a/apps/mobile/src/features/threads/provider-catalog-refresh.test.ts b/apps/mobile/src/features/threads/provider-catalog-refresh.test.ts index 565a54074..07f89da6d 100644 --- a/apps/mobile/src/features/threads/provider-catalog-refresh.test.ts +++ b/apps/mobile/src/features/threads/provider-catalog-refresh.test.ts @@ -25,7 +25,10 @@ describe("mobile provider catalog refresh", () => { expect(second).toBe(first); expect(refreshProviders).toHaveBeenCalledOnce(); - expect(refreshProviders).toHaveBeenCalledWith({ environmentId, input: {} }); + expect(refreshProviders).toHaveBeenCalledWith({ + environmentId, + input: { refreshModels: true }, + }); resolveRefresh?.("refreshed"); await expect(first).resolves.toBe("refreshed"); diff --git a/apps/mobile/src/features/threads/provider-catalog-refresh.ts b/apps/mobile/src/features/threads/provider-catalog-refresh.ts index 3e79a5b1c..ffe87b9f0 100644 --- a/apps/mobile/src/features/threads/provider-catalog-refresh.ts +++ b/apps/mobile/src/features/threads/provider-catalog-refresh.ts @@ -7,10 +7,13 @@ import { type RefreshProvidersTarget = { readonly environmentId: EnvironmentId; - readonly input: Record; + readonly input: { readonly refreshModels: true }; }; -/** Deduplicates taps while the server refresh command is still running. */ +/** + * Explicit model-picker refresh that also reloads account model catalogs. + * Repeated taps share the pending discovery. + */ export function createProviderCatalogRefreshRunner( refreshProviders: (target: RefreshProvidersTarget) => Promise, ) { @@ -18,7 +21,7 @@ export function createProviderCatalogRefreshRunner( return (environmentId: EnvironmentId): Promise => { if (pending) return pending; - pending = refreshProviders({ environmentId, input: {} }).finally(() => { + pending = refreshProviders({ environmentId, input: { refreshModels: true } }).finally(() => { pending = null; }); return pending; diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index d467a6a36..2d684885f 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1,5 +1,6 @@ import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { + ANTIGRAVITY_DEFAULT_MODEL, EnvironmentId, MessageId, ProjectId, @@ -67,6 +68,7 @@ import { shouldShowPlanFollowUpPrompt, shouldWriteThreadErrorToCurrentServerThread, toolGroupConsumesUpwardNavigation, + getAntigravitySendBlockReason, } from "./ChatView.logic"; describe("agent browser close confirmation", () => { @@ -839,6 +841,109 @@ describe("buildThreadTurnInterruptInput", () => { }); }); +describe("getAntigravitySendBlockReason", () => { + const catalogModels: ServerProvider["models"] = [ + { slug: "gemini-pro", name: "Gemini Pro", isCustom: false, capabilities: null }, + ]; + + function antigravity(overrides: Partial = {}): ServerProvider { + return { + driver: ProviderDriverKind.make("antigravity"), + instanceId: ProviderInstanceId.make("google_work"), + enabled: true, + installed: true, + status: "ready", + auth: { status: "authenticated" }, + version: null, + checkedAt: now, + models: [], + slashCommands: [], + skills: [], + ...overrides, + }; + } + + it("blocks sends while the selected Antigravity account is signed out", () => { + const provider = antigravity({ + status: "error", + auth: { status: "unauthenticated" }, + models: catalogModels, + }); + + expect(getAntigravitySendBlockReason(provider, "gemini-pro")).toBe( + "Sign in to Antigravity in provider settings before sending.", + ); + }); + + it("blocks sends until the selected Antigravity profile is installed", () => { + expect( + getAntigravitySendBlockReason( + antigravity({ installed: false, models: catalogModels }), + "gemini-pro", + ), + ).toBe("Install Antigravity in provider settings before sending."); + }); + + it("lets Antigravity check saved credentials when resuming after a restart", () => { + const provider = antigravity({ status: "warning", auth: { status: "unknown" } }); + + expect(getAntigravitySendBlockReason(provider, "gemini-pro")).toBeNull(); + expect(getAntigravitySendBlockReason(provider, ANTIGRAVITY_DEFAULT_MODEL)).toBeNull(); + expect( + getAntigravitySendBlockReason({ ...provider, models: catalogModels }, "gemini-pro"), + ).toBeNull(); + // A new thread or a default-model selection resolves to no model while the + // catalog is empty; the actionable step is reloading the catalog. + expect(getAntigravitySendBlockReason(provider, "")).toBe( + "Refresh Antigravity models in provider settings before sending.", + ); + }); + + it("blocks saved model sends until Antigravity loads its account catalog", () => { + expect(getAntigravitySendBlockReason(antigravity(), "gemini-pro")).toBe( + "Refresh Antigravity models in provider settings before sending.", + ); + }); + + it("blocks an empty Antigravity selection after the catalog has loaded", () => { + expect(getAntigravitySendBlockReason(antigravity({ models: catalogModels }), "")).toBe( + "Choose an Antigravity model before sending.", + ); + }); + + it("blocks a saved model that a ready catalog no longer lists", () => { + const provider = antigravity({ models: catalogModels }); + + expect(getAntigravitySendBlockReason(provider, "saved-model-not-in-current-catalog")).toBe( + "That Antigravity model is no longer available. Choose another model.", + ); + expect(getAntigravitySendBlockReason(provider, "gemini-pro")).toBeNull(); + expect(getAntigravitySendBlockReason(provider, ANTIGRAVITY_DEFAULT_MODEL)).toBeNull(); + }); + + it("allows a saved native model to retry after a provider error without changing it", () => { + expect( + getAntigravitySendBlockReason( + antigravity({ status: "error", models: catalogModels }), + "saved-model-not-in-current-catalog", + ), + ).toBeNull(); + }); + + it("keeps existing send behavior for other providers", () => { + expect( + getAntigravitySendBlockReason( + antigravity({ + driver: ProviderDriverKind.make("codex"), + installed: false, + auth: { status: "unknown" }, + }), + "gpt-model", + ), + ).toBeNull(); + }); +}); + describe("buildRunningThreadTurnInterruptInput", () => { it("targets only the active turn of a running thread", () => { const activeTurnId = TurnId.make("turn-running"); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 4bdab760a..81d97e5e8 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,4 +1,5 @@ import { + ANTIGRAVITY_DEFAULT_MODEL, type AssetCreateUrlInput, type AssetCreateUrlResult, type ChatFileAttachment, @@ -396,6 +397,48 @@ export function collectLocalTimelineMessageIds( return new Set(optimisticUserMessages.map((message) => message.id)); } +/** Why the composer cannot send to Antigravity yet, or null when the server can take the turn. */ +export function getAntigravitySendBlockReason( + provider: + | Pick + | null + | undefined, + model: string, +): string | null { + if (provider?.driver !== "antigravity") return null; + if (!provider.installed) { + return "Install Antigravity in provider settings before sending."; + } + if (provider.auth.status === "unauthenticated") { + return "Sign in to Antigravity in provider settings before sending."; + } + const slug = model.trim(); + if (slug.length === 0) { + // With no catalog there is nothing to choose from; reloading it is the next step. + return provider.models.length === 0 + ? "Refresh Antigravity models in provider settings before sending." + : "Choose an Antigravity model before sending."; + } + // A restart clears the account status and catalog. Session startup checks + // saved credentials and validates the model before sending the prompt. + if (provider.auth.status === "unknown") return null; + if (provider.models.length === 0) { + return "Refresh Antigravity models in provider settings before sending."; + } + // A saved model that left the catalog is kept in the picker as unavailable + // so the user sees what the thread used. The server rejects it at turn + // start, so block here unless the provider is in an error state, where a + // retry with the same model is the right move. + if ( + provider.status === "ready" && + slug !== ANTIGRAVITY_DEFAULT_MODEL && + !provider.models.some((entry) => entry.slug === slug || entry.aliases?.includes(slug)) + ) { + return "That Antigravity model is no longer available. Choose another model."; + } + return null; +} + /** * The interrupt a Stop action may send for the focused thread, or null when * nothing can be stopped. Mirrors the composer's Stop button: a running turn, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9384322bc..b6cebf00f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -275,6 +275,7 @@ import { buildThreadHandoffSeed, getThreadContinuationLinks, getThreadHandoffOffer, + resolveThreadHandoffTargetModel, summarizeHandoffDiff, } from "./chat/ThreadHandoff.logic"; import { ThreadContinuationBanner } from "./chat/ThreadContinuationBanner"; @@ -4369,6 +4370,15 @@ export default function ChatView(props: ChatViewProps) { const toggleInteractionMode = useCallback(() => { handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan"); }, [handleInteractionModeChange, interactionMode]); + const openProviderSetup = useCallback( + (instanceId: ProviderInstanceId) => { + void navigate({ + to: "/settings/providers", + search: { environmentId, instanceId }, + }); + }, + [environmentId, navigate], + ); const createBrowserSurface = useCallback( (profileId?: string) => { if (!activeThreadRef) return; @@ -8758,13 +8768,21 @@ export default function ChatView(props: ChatViewProps) { // The accounts run the same provider CLI, so the model slug normally // carries over untouched; resolving it against the target keeps a // per-account model list from producing a selection it cannot honor. - const targetModel = resolveAppModelSelectionForInstance( - offer.targetInstanceId, + const targetModel = resolveThreadHandoffTargetModel({ + targetInstanceId: offer.targetInstanceId, settings, - providerStatuses, - sendCtx.selectedModel, - ); - if (targetModel === null) return; + providers: providerStatuses, + selectedModel: sendCtx.selectedModel, + }); + if (targetModel === null) { + toastManager.add({ + type: "error", + title: `Can't continue on ${offer.targetAccountName}`, + description: + "That account reports no models. Refresh it in Settings → Providers, then try again.", + }); + return; + } const nextThreadModelSelection = createModelSelection(offer.targetInstanceId, targetModel); const targetProviderModels = providerStatuses.find((provider) => provider.instanceId === offer.targetInstanceId)?.models ?? @@ -9417,6 +9435,7 @@ export default function ChatView(props: ChatViewProps) { setDismissedProviderStatusBannerKey(providerStatusBannerKey)} + onOpenProviderSetup={openProviderSetup} /> - -
- - Publish repository - - Pick where to host it, then point us at a repo to push to. - -
- {publishWizardSteps.map((label, index) => { - const isComplete = index < publishWizardStep; - const isClickable = - publishWizardStep !== 2 && - index < publishWizardSteps.length - 1 && - index <= publishWizardStep; + + + + publishWizardStep === 2 || + index >= publishWizardSteps.length - 1 || + index > publishWizardStep + } + onStepChange={setPublishWizardStep} + /> + + + +
+ + Provider + + { + setSelectedPublishProvider(value as PublishProviderKind); + setPublishRepositoryOverride(null); + }} + aria-labelledby="publish-provider-cards-label" + className="grid grid-cols-2 gap-2.5" + > + {sortedPublishProviderOptions.map((option) => { + const readiness = publishProviderReadiness[option.value]; + const isSelected = publishProvider === option.value && readiness.ready; + if (!readiness.ready) { + return ( +
+ + + {option.label} + + + { + event.preventDefault(); + event.stopPropagation(); + openSourceControlSettings(); + }} + > + Setup Required + + } + /> + + {readiness.hint ?? + "Open Settings -> Source Control to configure this provider."} + + +
+ ); + } + return ( - + ); })} -
- - - - -
- - Provider + +
+ +
+
+ +
+ + + {publishHost}/ - { - setSelectedPublishProvider(value as PublishProviderKind); - setPublishRepositoryOverride(null); + { + setPublishRepositoryOverride(event.target.value); }} - aria-labelledby="publish-provider-cards-label" - className="grid grid-cols-2 gap-2.5" - > - {sortedPublishProviderOptions.map((option) => { - const readiness = publishProviderReadiness[option.value]; - const isSelected = publishProvider === option.value && readiness.ready; - if (!readiness.ready) { - return ( -
- - - {option.label} - - - { - event.preventDefault(); - event.stopPropagation(); - openSourceControlSettings(); - }} - > - Setup Required - - } - /> - - {readiness.hint ?? - "Open Settings -> Source Control to configure this provider."} - - -
- ); + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + submitPublishRepository(); } + }} + placeholder={publishPathPlaceholder} + disabled={publishRepositoryAction.isPending} + className="w-full bg-transparent px-3 py-2 font-mono text-sm placeholder:text-muted-foreground/60 focus:outline-none" + /> +
+
- return ( - - - +
+ + Visibility + + + setPublishVisibility(value as SourceControlRepositoryVisibility) + } + aria-labelledby="publish-visibility-cards-label" + disabled={publishRepositoryAction.isPending} + className="grid grid-cols-2 gap-2.5" + > + {[ + { + value: "private" as const, + label: "Private", + description: "Only invited people", + Icon: LockIcon, + }, + { + value: "public" as const, + label: "Public", + description: "Anyone on the web", + Icon: GlobeIcon, + }, + ].map((option) => { + const isSelected = publishVisibility === option.value; + return ( + + + + {option.label} - - ); - })} - -
+ + {option.description} + +
+
+ ); + })} + +
-
-
-
+ + + + {publishWizardStep === 2 ? ( + + ) : ( + <> + + {publishWizardStep < 1 ? ( + + ) : ( + + )} + + )} + + ); } diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index efcf0edb4..c75f9d788 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -103,6 +103,7 @@ import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder"; import { buildRunningThreadTurnInterruptInput, deriveComposerSendState, + getAntigravitySendBlockReason, readFileAsDataUrl, threadShellHasStarted, } from "../ChatView.logic"; @@ -218,6 +219,7 @@ import { } from "../composerFooterLayout"; import { type ComposerPromptEditorHandle, ComposerPromptEditor } from "../ComposerPromptEditor"; import { ProviderModelPicker } from "./ProviderModelPicker"; +import { hasProviderSetup } from "./ProviderStatusBanner"; import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu"; import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions"; import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; @@ -963,6 +965,7 @@ import { getProviderInteractionModeToggle } from "../../providerModels"; import { canStartComposerTurn, resolveComposerInstanceSelection, + resolveComposerProviderSettingsAction, } from "../../composerInstanceSelection"; import { applyProviderInstanceSettings, @@ -1544,6 +1547,7 @@ export interface ChatComposerProps { ) => void; onProviderModelSelect: (instanceId: ProviderInstanceId, model: string) => void; + onOpenProviderSetup: (instanceId: ProviderInstanceId) => void; getModelDisabledReason: (instanceId: ProviderInstanceId, model: string) => string | null; toggleInteractionMode: () => void; handleRuntimeModeChange: (mode: RuntimeMode) => void; @@ -1660,6 +1664,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onPreviousActivePendingUserInputQuestion, onChangeActivePendingUserInputCustomAnswer, onProviderModelSelect, + onOpenProviderSetup, getModelDisabledReason, toggleInteractionMode, handleRuntimeModeChange, @@ -1933,7 +1938,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // the thread's own selection instead of swapping in the setup button and // back once the catalog lands. const providerCatalogPending = noProviderAvailable && !providerCatalogKnown; - const showProviderUnavailable = noProviderAvailable && !providerCatalogPending; + const providerSettingsAction = resolveComposerProviderSettingsAction({ + selection: composerSelection, + catalogKnown: providerCatalogKnown, + lockedProvider, + fallbackSetupInstanceId: providerInstanceEntries.find((entry) => + hasProviderSetup(entry.snapshot), + )?.instanceId, + }); + const providerSetupInstanceId = providerSettingsAction.instanceId; + const showProviderSettingsAction = providerSettingsAction.visible; const providerSelectionBlocked = composerSelection.blockedByUnavailablePreference; const activeSessionInstanceId = activeThread?.session?.providerInstanceId; const providerBindingConflict = composerDraft.providerBindingConflict; @@ -2013,6 +2027,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) projectModelSelection: activeProjectDefaultModelSelection, settings, }); + const providerSendBlockReason = getAntigravitySendBlockReason( + selectedProviderEntry?.snapshot, + selectedModel, + ); const selectedProviderStatus = useMemo( () => selectedProviderEntry?.snapshot ?? null, [selectedProviderEntry], @@ -2275,6 +2293,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const sendDisabledReason = boundProviderSelectionReason ?? baseSendDisabledReason ?? + (activePendingProgress ? null : providerSendBlockReason) ?? (sessionCompactionBlocksSubmission ? "Context compaction in progress" : null); const isSendDisabled = sendDisabledReason !== null; const showSessionCompaction = sessionCompactionScopeKey !== null; @@ -4732,7 +4751,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isStashMenuOpen || isDragOverComposer || isPreparingWorktree || - showProviderUnavailable || + showProviderSettingsAction || projectSelectionRequired || environmentUnavailable !== null || composerSubmissionError !== null || @@ -5014,17 +5033,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const hiddenRestingBlockIds = restingBlockDefs .slice(restingBlockDefs.length - restingHiddenBlockCount) .map((def) => def.id); - const composerControls = showProviderUnavailable ? ( + const composerControls = showProviderSettingsAction ? ( ) : ( <> @@ -5080,6 +5104,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onOpenChange={setIsComposerModelPickerOpen} getModelDisabledReason={getModelDisabledReason} onInstanceModelChange={onProviderModelSelect} + onOpenProviderSetup={onOpenProviderSetup} /> {composerControlsCompact ? ( @@ -5862,7 +5887,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedPromptEffort, selectedModelOptionsForDispatch, selectedModelSelection, - providerAvailable: !providerTurnUnavailable, + providerAvailable: !providerTurnUnavailable && providerSendBlockReason === null, selectedProvider, selectedModel, selectedProviderModels, @@ -5911,6 +5936,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedModelOptionsForDispatch, selectedModelSelection, providerTurnUnavailable, + providerSendBlockReason, selectedPromptEffort, selectedProvider, selectedProviderModels, @@ -6247,7 +6273,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) : prompt.trim() || (providerSelectionBlocked ? "Select another provider to send" - : showProviderUnavailable + : showProviderSettingsAction ? "Enable a provider in Settings" : "Ask anything...")} @@ -6772,7 +6798,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? "Choose a project above to start a thread" : providerSelectionBlocked ? "Select another provider to send a message" - : showProviderUnavailable + : showProviderSettingsAction ? "Enable a provider in Settings to send a message" : phase === "disconnected" ? DISCONNECTED_COMPOSER_PLACEHOLDER diff --git a/apps/web/src/components/chat/ModelPickerContent.test.ts b/apps/web/src/components/chat/ModelPickerContent.test.ts index 0cef52333..8bde964cc 100644 --- a/apps/web/src/components/chat/ModelPickerContent.test.ts +++ b/apps/web/src/components/chat/ModelPickerContent.test.ts @@ -1,14 +1,23 @@ -import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import { + ANTIGRAVITY_DEFAULT_MODEL, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, +} from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { deriveProviderInstanceEntries } from "../../providerInstances"; -import { shouldIncludeModelPickerOption } from "./ModelPickerContent"; +import { + resolveModelPickerSelectedModel, + shouldIncludeModelPickerOption, + shouldOfferModelPickerSetup, +} from "./ModelPickerContent"; -function entry(status: ServerProvider["status"]) { +function entry(status: ServerProvider["status"], driver = "opencode") { return deriveProviderInstanceEntries([ { - instanceId: ProviderInstanceId.make("opencode_work"), - driver: ProviderDriverKind.make("opencode"), + instanceId: ProviderInstanceId.make(`${driver}_work`), + driver: ProviderDriverKind.make(driver), enabled: true, installed: true, version: null, @@ -23,6 +32,48 @@ function entry(status: ServerProvider["status"]) { } describe("shouldIncludeModelPickerOption", () => { + it.each(["ready", "error"] as const)( + "never offers the internal Antigravity default marker as a model when %s", + (status) => { + const providerEntry = entry(status, "antigravity"); + expect( + shouldIncludeModelPickerOption({ + entry: providerEntry, + option: { + slug: ANTIGRAVITY_DEFAULT_MODEL, + name: ANTIGRAVITY_DEFAULT_MODEL, + isUnavailable: true, + }, + activeInstanceId: providerEntry.instanceId, + activeModel: ANTIGRAVITY_DEFAULT_MODEL, + }), + ).toBe(false); + }, + ); + + it("keeps only the active synthetic Antigravity row when the provider status is error", () => { + const providerEntry = entry("error", "antigravity"); + const activeModel = "missing-model"; + const option = { slug: activeModel, name: activeModel, isUnavailable: true }; + + expect( + shouldIncludeModelPickerOption({ + entry: providerEntry, + option, + activeInstanceId: providerEntry.instanceId, + activeModel, + }), + ).toBe(true); + expect( + shouldIncludeModelPickerOption({ + entry: providerEntry, + option, + activeInstanceId: ProviderInstanceId.make("antigravity_personal"), + activeModel, + }), + ).toBe(false); + }); + it("keeps only the active synthetic OpenCode row when the provider status is error", () => { const providerEntry = entry("error"); const activeInstanceId = ProviderInstanceId.make("opencode_work"); @@ -83,3 +134,110 @@ describe("shouldIncludeModelPickerOption", () => { } }); }); + +describe("shouldOfferModelPickerSetup", () => { + const availableModel = { slug: "gemini-3.1-pro", name: "Gemini 3.1 Pro" }; + + it("offers setup before an Antigravity account has models", () => { + expect(shouldOfferModelPickerSetup(entry("error", "antigravity"), [])).toBe(true); + }); + + it("offers setup after sign-out even if a model remains cached", () => { + const providerEntry = entry("ready", "antigravity"); + expect( + shouldOfferModelPickerSetup( + { + ...providerEntry, + snapshot: { ...providerEntry.snapshot, auth: { status: "unauthenticated" } }, + }, + [availableModel], + ), + ).toBe(true); + }); + + it("offers setup when the only model is an unavailable saved selection", () => { + expect( + shouldOfferModelPickerSetup(entry("ready", "antigravity"), [ + { ...availableModel, isUnavailable: true }, + ]), + ).toBe(true); + }); + + it("does not offer setup for a ready account with available models", () => { + expect(shouldOfferModelPickerSetup(entry("ready", "antigravity"), [availableModel])).toBe( + false, + ); + }); + + it("does not restore a disabled provider while its status snapshot is stale", () => { + expect( + shouldOfferModelPickerSetup({ ...entry("error", "antigravity"), enabled: false }, []), + ).toBe(false); + }); + + it("keeps providers without integrated setup on their existing path", () => { + expect(shouldOfferModelPickerSetup(entry("error", "codex"), [])).toBe(false); + }); + + it("uses the environment's setup capability for other drivers", () => { + const providerEntry = entry("error", "custom_driver"); + expect( + shouldOfferModelPickerSetup( + { + ...providerEntry, + snapshot: { + ...providerEntry.snapshot, + setup: { canAuthenticate: true, canInstall: false }, + }, + }, + [], + ), + ).toBe(true); + }); +}); + +describe("resolveModelPickerSelectedModel", () => { + it("follows the catalog default for the marker but keeps an explicit native model", () => { + const driverKind = ProviderDriverKind.make("antigravity"); + const previousOptions = [ + { slug: "gemini-fast", name: "Gemini Fast", aliases: [ANTIGRAVITY_DEFAULT_MODEL] }, + { slug: "gemini-pro", name: "Gemini Pro" }, + ]; + const nextOptions = [ + { slug: "gemini-fast", name: "Gemini Fast" }, + { slug: "gemini-pro", name: "Gemini Pro", aliases: [ANTIGRAVITY_DEFAULT_MODEL] }, + ]; + + expect( + resolveModelPickerSelectedModel({ + driverKind, + model: ANTIGRAVITY_DEFAULT_MODEL, + options: previousOptions, + })?.slug, + ).toBe("gemini-fast"); + expect( + resolveModelPickerSelectedModel({ + driverKind, + model: ANTIGRAVITY_DEFAULT_MODEL, + options: nextOptions, + })?.slug, + ).toBe("gemini-pro"); + expect( + resolveModelPickerSelectedModel({ + driverKind, + model: "gemini-fast", + options: nextOptions, + })?.slug, + ).toBe("gemini-fast"); + }); + + it("does not guess the default from the first model in a catalog", () => { + expect( + resolveModelPickerSelectedModel({ + driverKind: ProviderDriverKind.make("antigravity"), + model: ANTIGRAVITY_DEFAULT_MODEL, + options: [{ slug: "gemini-fast", name: "Gemini Fast" }], + }), + ).toBeUndefined(); + }); +}); diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 22cd36018..548dd742d 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -1,5 +1,6 @@ import { resolveProviderContinuationTransition } from "@t3tools/client-runtime/providerContinuation"; import { + ANTIGRAVITY_DEFAULT_MODEL, type ProviderInstanceId, type ProviderDriverKind, type ResolvedKeybindingsConfig, @@ -11,6 +12,7 @@ import { memo, useMemo, useState, useCallback, useEffect, useLayoutEffect, useRe import { ChevronRightIcon, SearchIcon } from "lucide-react"; import { ModelListRow } from "./ModelListRow"; import { ModelPickerSidebar } from "./ModelPickerSidebar"; +import { getProviderStatusMessage, hasProviderSetup } from "./ProviderStatusBanner"; import { modelPickerLegacySectionKey, modelPickerModelKey, @@ -38,6 +40,7 @@ import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings" import { cn } from "~/lib/utils"; import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area"; import { TooltipProvider } from "../ui/tooltip"; +import { Button } from "../ui/button"; import { isProviderInstancePickerReady, isProviderInstancePickerVisible, @@ -60,22 +63,59 @@ type ModelPickerItem = { isUnavailable?: boolean | undefined; }; +/** Resolve Antigravity's internal default marker to the account catalog's default model. */ +export function resolveModelPickerSelectedModel(input: { + driverKind: ProviderDriverKind | undefined; + model: string; + options: ReadonlyArray; +}) { + if (input.driverKind === "antigravity" && input.model === ANTIGRAVITY_DEFAULT_MODEL) { + const availableModels = input.options.filter( + (option) => option.slug !== ANTIGRAVITY_DEFAULT_MODEL && !option.isUnavailable, + ); + return ( + availableModels.find((option) => option.aliases?.includes(ANTIGRAVITY_DEFAULT_MODEL)) ?? + availableModels.find((option) => option.isDefault) + ); + } + return input.options.find((option) => option.slug === input.model); +} + export function shouldIncludeModelPickerOption(input: { readonly entry: ProviderInstanceEntry; readonly option: ModelEsque; readonly activeInstanceId: ProviderInstanceId; readonly activeModel: string; }): boolean { + if (input.entry.driverKind === "antigravity" && input.option.slug === ANTIGRAVITY_DEFAULT_MODEL) { + return false; + } if (isProviderInstancePickerReady(input.entry)) return true; return ( input.entry.enabled && - input.entry.driverKind === "opencode" && + (input.entry.driverKind === "opencode" || input.entry.driverKind === "antigravity") && input.entry.instanceId === input.activeInstanceId && input.option.slug === input.activeModel && input.option.isUnavailable === true ); } +/** Offer provider setup while an enabled instance cannot yet serve a usable model. */ +export function shouldOfferModelPickerSetup( + entry: ProviderInstanceEntry, + options: ReadonlyArray, +): boolean { + return ( + entry.enabled && + entry.status !== "disabled" && + hasProviderSetup(entry.snapshot) && + (!isProviderInstancePickerReady(entry) || + !entry.installed || + entry.snapshot.auth.status === "unauthenticated" || + !options.some((option) => !option.isUnavailable)) + ); +} + const EMPTY_MODEL_JUMP_LABELS = new Map(); function ModelListSeparator() { @@ -112,6 +152,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { modelOptionsByInstance: ReadonlyMap>; terminalOpen: boolean; onRequestClose?: () => void; + onOpenProviderSetup?: (instanceId: ProviderInstanceId) => void; getModelDisabledReason?: (instanceId: ProviderInstanceId, model: string) => string | null; onInstanceModelChange: (instanceId: ProviderInstanceId, model: string) => void; }) { @@ -132,6 +173,16 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { const activeEntry = props.instanceEntries.find( (entry) => entry.instanceId === props.activeInstanceId, ); + const activeModel = resolveModelPickerSelectedModel({ + driverKind: activeEntry?.driverKind, + model: props.model, + options: modelOptionsByInstance.get(props.activeInstanceId) ?? [], + }); + const activeModelSlug = + activeModel?.slug ?? (props.model === ANTIGRAVITY_DEFAULT_MODEL ? "" : props.model); + const activeModelKey = activeModelSlug + ? modelPickerModelKey(props.activeInstanceId, activeModelSlug) + : null; const activeInstanceHasSelectableUnavailableModel = activeEntry !== undefined && (modelOptionsByInstance.get(props.activeInstanceId) ?? []).some((option) => @@ -139,15 +190,25 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { entry: activeEntry, option, activeInstanceId: props.activeInstanceId, - activeModel: props.model, + activeModel: activeModelSlug, }), ) && !isProviderInstancePickerReady(activeEntry); + const activeInstanceNeedsSetup = + props.onOpenProviderSetup !== undefined && + activeEntry !== undefined && + shouldOfferModelPickerSetup( + activeEntry, + modelOptionsByInstance.get(props.activeInstanceId) ?? [], + ); const [selectedInstanceId, setSelectedInstanceId] = useState( () => { - if (props.lockedProvider !== null || activeInstanceHasSelectableUnavailableModel) { - // When locked, prime the sidebar to the currently-active instance - // so jumping into the picker keeps the focused instance visible. + if ( + props.lockedProvider !== null || + activeInstanceHasSelectableUnavailableModel || + activeInstanceNeedsSetup + ) { + // Keep the active instance visible when it is locked or needs setup. return props.activeInstanceId; } return favorites.length > 0 ? "favorites" : props.activeInstanceId; @@ -158,7 +219,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { new Set( modelOptionsByInstance .get(props.activeInstanceId) - ?.some((model) => model.slug === props.model && model.isLegacy) + ?.some((model) => model.slug === activeModelSlug && model.isLegacy) ? [props.activeInstanceId] : [], ), @@ -234,11 +295,27 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { ); const selectableUnavailableInstanceIds = useMemo(() => { - if (!activeInstanceHasSelectableUnavailableModel) { - return undefined; + const instanceIds = new Set(); + if (activeInstanceHasSelectableUnavailableModel) { + instanceIds.add(props.activeInstanceId); } - return new Set([props.activeInstanceId]); - }, [activeInstanceHasSelectableUnavailableModel, props.activeInstanceId]); + if (props.onOpenProviderSetup) { + for (const entry of instanceEntries) { + if ( + shouldOfferModelPickerSetup(entry, modelOptionsByInstance.get(entry.instanceId) ?? []) + ) { + instanceIds.add(entry.instanceId); + } + } + } + return instanceIds.size > 0 ? instanceIds : undefined; + }, [ + activeInstanceHasSelectableUnavailableModel, + instanceEntries, + modelOptionsByInstance, + props.activeInstanceId, + props.onOpenProviderSetup, + ]); // Flatten models into a searchable array. One pass over the // instance-keyed map; each model carries its instance id + driver kind @@ -259,7 +336,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { entry, option: model, activeInstanceId: props.activeInstanceId, - activeModel: props.model, + activeModel: activeModelSlug, }) ) { continue; @@ -283,7 +360,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { } } return out; - }, [modelOptionsByInstance, entryByInstanceId, props.activeInstanceId, props.model]); + }, [modelOptionsByInstance, entryByInstanceId, props.activeInstanceId, activeModelSlug]); const isLocked = props.lockedProvider !== null; const isSearching = searchQuery.trim().length > 0; @@ -454,6 +531,23 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { ]; }, [filteredModels, legacySection]); + const selectedEntry = + selectedInstanceId === "favorites" ? undefined : entryByInstanceId.get(selectedInstanceId); + const providerSetupEntries = + !isSearching && props.onOpenProviderSetup + ? instanceEntries.filter( + (entry) => + matchesLockedProvider(entry) && + shouldOfferModelPickerSetup( + entry, + modelOptionsByInstance.get(entry.instanceId) ?? [], + ) && + (selectedEntry + ? entry.instanceId === selectedEntry.instanceId + : filteredModels.length === 0), + ) + : []; + const toggleLegacySection = useCallback((instanceId: ProviderInstanceId) => { setExpandedLegacyInstances((expanded) => { const next = new Set(expanded); @@ -693,7 +787,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { autoHighlight open virtualized - value={modelPickerModelKey(props.activeInstanceId, props.model)} + value={activeModelKey} onItemHighlighted={(modelKey, eventDetails) => { highlightedModelKeyRef.current = typeof modelKey === "string" ? modelKey : null; if (eventDetails.reason === "keyboard" && eventDetails.index >= 0) { @@ -826,9 +920,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { isFavorite={favoritesSet.has( providerModelKey(model.instanceId, model.slug), )} - isSelected={ - modelKey === modelPickerModelKey(props.activeInstanceId, props.model) - } + isSelected={modelKey === activeModelKey} showProvider preferShortName={!isLocked} useTriggerLabel={false} @@ -864,9 +956,34 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { />
- - No models found - + {providerSetupEntries.length > 0 ? ( +
+ {providerSetupEntries.map((entry) => ( +
+

+ {getProviderStatusMessage(entry.snapshot)} +

+ +
+ ))} +
+ ) : ( + + No models found + + )}
diff --git a/apps/web/src/components/chat/ProviderModelPicker.test.tsx b/apps/web/src/components/chat/ProviderModelPicker.test.tsx index 769ffae3d..efe93cf82 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.test.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.test.tsx @@ -1,4 +1,9 @@ -import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import { + ANTIGRAVITY_DEFAULT_MODEL, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, +} from "@t3tools/contracts"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vite-plus/test"; @@ -45,6 +50,51 @@ function renderPicker(input: { } describe("ProviderModelPicker", () => { + it.each(["", ANTIGRAVITY_DEFAULT_MODEL])( + "shows a choice prompt before Antigravity has an account catalog for %s", + (model) => { + const markup = renderPicker({ + instanceId: "antigravity", + driver: "antigravity", + model, + options: [], + }); + expect(markup).toContain("Choose model"); + expect(markup).not.toContain(ANTIGRAVITY_DEFAULT_MODEL); + }, + ); + + it.each([{ aliases: [ANTIGRAVITY_DEFAULT_MODEL] }, { isDefault: true }])( + "shows the actual default model for an Antigravity marker with %j", + (defaultMetadata) => { + const markup = renderPicker({ + instanceId: "google_work", + driver: "antigravity", + model: ANTIGRAVITY_DEFAULT_MODEL, + options: [ + { slug: "gemini-fast", name: "Gemini Fast" }, + { slug: "gemini-pro", name: "Gemini Pro", ...defaultMetadata }, + ], + }); + + expect(markup).toContain("Gemini Pro"); + expect(markup).not.toContain("Gemini Fast"); + expect(markup).not.toContain(ANTIGRAVITY_DEFAULT_MODEL); + }, + ); + + it("keeps the selected model label when the Antigravity account catalog does not contain it", () => { + const markup = renderPicker({ + instanceId: "google_work", + driver: "antigravity", + model: "missing-model", + options: [{ slug: "fallback", name: "Fallback model" }], + }); + + expect(markup).toContain("missing-model"); + expect(markup).not.toContain("Fallback model"); + }); + it("shows a missing model slug for a custom OpenCode instance", () => { const markup = renderPicker({ instanceId: "team_runtime", diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index c54457311..e174f3413 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -1,4 +1,5 @@ import { + ANTIGRAVITY_DEFAULT_MODEL, type ProviderInstanceId, type ProviderDriverKind, type ResolvedKeybindingsConfig, @@ -10,7 +11,7 @@ import { buttonVariants } from "../ui/button"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { cn } from "~/lib/utils"; -import { ModelPickerContent } from "./ModelPickerContent"; +import { ModelPickerContent, resolveModelPickerSelectedModel } from "./ModelPickerContent"; import { ProviderInstanceIcon } from "./ProviderInstanceIcon"; import { ModelEsque, @@ -51,6 +52,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { triggerClassName?: string; triggerAriaLabel?: string; onOpenChange?: (open: boolean) => void; + onOpenProviderSetup?: (instanceId: ProviderInstanceId) => void; getModelDisabledReason?: (instanceId: ProviderInstanceId, model: string) => string | null; onInstanceModelChange: (instanceId: ProviderInstanceId, model: string) => void; }) { @@ -69,15 +71,25 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { const activeInstanceId = props.activeInstanceId; const selectedInstanceOptions = props.modelOptionsByInstance.get(activeInstanceId) ?? []; - // OpenCode can keep a model through a transient catalog refresh. Other - // providers keep the active instance's first option as their normal fallback. + // Account-specific catalogs (OpenCode, Antigravity) keep the selected model + // label while it is unavailable. Other providers fall back to the first option. const selectedModel = - selectedInstanceOptions.find((option) => option.slug === props.model) ?? - (activeEntry?.driverKind === "opencode" ? undefined : selectedInstanceOptions[0]); - const triggerTitle = selectedModel ? getTriggerDisplayModelName(selectedModel) : props.model; + resolveModelPickerSelectedModel({ + driverKind: activeEntry?.driverKind, + model: props.model, + options: selectedInstanceOptions, + }) ?? + (activeEntry?.driverKind === "opencode" || activeEntry?.driverKind === "antigravity" + ? undefined + : selectedInstanceOptions[0]); + const triggerTitle = selectedModel + ? getTriggerDisplayModelName(selectedModel) + : props.model === ANTIGRAVITY_DEFAULT_MODEL + ? "Choose model" + : props.model || "Choose model"; const triggerLabel = selectedModel ? `${getTriggerDisplayModelLabel(selectedModel)}${selectedModel.isUnavailable ? " (Unavailable)" : ""}` - : props.model; + : triggerTitle; const showInstanceBadge = activeEntry !== null && shouldShowInstanceBadge(activeEntry, props.instanceEntries); @@ -227,6 +239,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { modelOptionsByInstance={props.modelOptionsByInstance} terminalOpen={props.terminalOpen ?? false} onRequestClose={() => setIsMenuOpen(false)} + {...(props.onOpenProviderSetup ? { onOpenProviderSetup: props.onOpenProviderSetup } : {})} {...(props.getModelDisabledReason ? { getModelDisabledReason: props.getModelDisabledReason } : {})} diff --git a/apps/web/src/components/chat/ProviderStatusBanner.test.tsx b/apps/web/src/components/chat/ProviderStatusBanner.test.tsx index 00a8e4690..efb1d185d 100644 --- a/apps/web/src/components/chat/ProviderStatusBanner.test.tsx +++ b/apps/web/src/components/chat/ProviderStatusBanner.test.tsx @@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test"; import { getProviderStatusBannerKey, + getProviderStatusMessage, ProviderStatusBanner, shouldShowProviderStatusBanner, } from "./ProviderStatusBanner"; @@ -27,6 +28,51 @@ function warningProvider(): ServerProvider { } describe("ProviderStatusBanner", () => { + it("waits for an Antigravity auth result before showing a sign-in warning", () => { + const status: ServerProvider = { + ...warningProvider(), + instanceId: ProviderInstanceId.make("google_work"), + driver: ProviderDriverKind.make("antigravity"), + auth: { status: "unknown" }, + message: "Antigravity is installed. Google account access is not checked yet.", + }; + + expect(shouldShowProviderStatusBanner(status, null)).toBe(false); + expect( + renderToStaticMarkup( + {}} + onOpenProviderSetup={() => {}} + />, + ), + ).toBe(""); + expect( + shouldShowProviderStatusBanner( + { + ...status, + auth: { status: "unauthenticated" }, + message: "Sign in with Google to use Antigravity.", + }, + null, + ), + ).toBe(true); + }); + + it("shows Antigravity installation and startup failures before auth is checked", () => { + const status: ServerProvider = { + ...warningProvider(), + driver: ProviderDriverKind.make("antigravity"), + auth: { status: "unknown" }, + }; + + expect(shouldShowProviderStatusBanner({ ...status, installed: false }, null)).toBe(true); + expect(shouldShowProviderStatusBanner({ ...status, status: "error" }, null)).toBe(true); + expect( + shouldShowProviderStatusBanner({ ...status, driver: ProviderDriverKind.make("codex") }, null), + ).toBe(true); + }); + it("stays hidden after its current warning is dismissed", () => { const status = warningProvider(); @@ -100,4 +146,85 @@ describe("ProviderStatusBanner", () => { renderToStaticMarkup( {}} />), ).toBe(""); }); + + it("links to provider setup only for providers with integrated setup", () => { + const antigravity: ServerProvider = { + ...warningProvider(), + instanceId: ProviderInstanceId.make("antigravity"), + driver: ProviderDriverKind.make("antigravity"), + displayName: "Antigravity", + status: "error", + auth: { status: "unauthenticated" }, + message: "", + }; + const withSetup = renderToStaticMarkup( + {}} + onOpenProviderSetup={() => {}} + />, + ); + const withoutSetup = renderToStaticMarkup( + {}} + onOpenProviderSetup={() => {}} + />, + ); + + expect(withSetup).toContain("Open provider setup"); + expect(withoutSetup).not.toContain("Open provider setup"); + }); +}); + +describe("getProviderStatusMessage", () => { + it("preserves the environment's authentication error", () => { + const message = "SUBSCRIPTION_REQUIRED: This Google account cannot use Antigravity."; + expect( + getProviderStatusMessage({ + ...warningProvider(), + driver: ProviderDriverKind.make("antigravity"), + status: "error", + auth: { status: "unauthenticated" }, + message, + }), + ).toBe(message); + }); + + it("points a signed-out Antigravity account to Google sign-in without a CLI command", () => { + expect( + getProviderStatusMessage({ + ...warningProvider(), + driver: ProviderDriverKind.make("antigravity"), + status: "error", + auth: { status: "unauthenticated" }, + message: "", + }), + ).toBe("Open provider setup to sign in with Google."); + }); + + it("requires installation on the environment before sign-in", () => { + expect( + getProviderStatusMessage({ + ...warningProvider(), + driver: ProviderDriverKind.make("antigravity"), + displayName: "Google work account", + installed: false, + status: "error", + auth: { status: "unauthenticated" }, + message: "", + }), + ).toBe("Open provider setup to install Antigravity on this environment."); + }); + + it("keeps CLI sign-in advice for a provider without integrated setup", () => { + expect( + getProviderStatusMessage({ + ...warningProvider(), + status: "error", + auth: { status: "unauthenticated" }, + message: "", + }), + ).toBe("Sign in via the CLI to authenticate again."); + }); }); diff --git a/apps/web/src/components/chat/ProviderStatusBanner.tsx b/apps/web/src/components/chat/ProviderStatusBanner.tsx index 42021a832..c915b19d0 100644 --- a/apps/web/src/components/chat/ProviderStatusBanner.tsx +++ b/apps/web/src/components/chat/ProviderStatusBanner.tsx @@ -1,4 +1,4 @@ -import { type ServerProvider } from "@t3tools/contracts"; +import { type ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; import { memo } from "react"; import { InfoIcon, XIcon } from "lucide-react"; import { cn } from "~/lib/utils"; @@ -11,6 +11,17 @@ export function getProviderStatusBannerKey(status: ServerProvider | null): strin if (!status) return null; const unavailable = getProviderUnavailablePresentation(status); if (!unavailable && (status.status === "ready" || status.status === "disabled")) return null; + // Antigravity checks saved credentials when a session starts. Its local + // health check leaves auth unknown after a restart, which is not a failure. + if ( + !unavailable && + status.driver === "antigravity" && + status.installed && + status.status === "warning" && + status.auth.status === "unknown" + ) { + return null; + } return [ status.instanceId, status.availability ?? "available", @@ -28,16 +39,48 @@ export function shouldShowProviderStatusBanner( return bannerKey !== null && bannerKey !== dismissedBannerKey; } +/** Whether Settings → Providers can install or sign in this provider for the user. */ +export function hasProviderSetup(status: ServerProvider): boolean { + return ( + status.driver === "antigravity" || + status.setup?.canAuthenticate === true || + status.setup?.canInstall === true + ); +} + +/** Keep the environment's error intact in both the banner and model picker. */ +export function getProviderStatusMessage(status: ServerProvider): string { + if (status.message) return status.message; + const providerName = status.displayName?.trim() || formatProviderDriverKindLabel(status.driver); + if (!status.installed && hasProviderSetup(status)) { + return `Open provider setup to install ${formatProviderDriverKindLabel(status.driver)} on this environment.`; + } + if (status.auth.status === "unauthenticated") { + if (hasProviderSetup(status)) { + return status.driver === "antigravity" + ? "Open provider setup to sign in with Google." + : "Open provider setup to sign in."; + } + return "Sign in via the CLI to authenticate again."; + } + return status.status === "ready" + ? "No models are available for this provider." + : status.status === "error" + ? `${providerName} provider is unavailable.` + : `${providerName} provider has limited availability.`; +} + export const ProviderStatusBanner = memo(function ProviderStatusBanner({ onDismiss, + onOpenProviderSetup, status, }: { onDismiss: () => void; + onOpenProviderSetup?: (instanceId: ProviderInstanceId) => void; status: ServerProvider | null; }) { - if (!status) return null; + if (!status || getProviderStatusBannerKey(status) === null) return null; const unavailable = getProviderUnavailablePresentation(status); - if (!unavailable && (status.status === "ready" || status.status === "disabled")) return null; const providerName = status.displayName?.trim() || formatProviderDriverKindLabel(status.driver); const isUnauthenticated = @@ -47,14 +90,7 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({ : isUnauthenticated ? `${providerName} is unauthenticated` : `${providerName} provider status`; - const message = unavailable - ? unavailable.detail - : isUnauthenticated - ? "Sign in via the CLI to authenticate again." - : (status.message ?? - (status.status === "error" - ? `${providerName} provider is unavailable.` - : `${providerName} provider has limited availability.`)); + const message = unavailable ? unavailable.detail : getProviderStatusMessage(status); const severity = unavailable ? "unavailable" : status.status; return ( @@ -84,6 +120,16 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({ )} + {onOpenProviderSetup && hasProviderSetup(status) ? ( + + ) : null} - - - ) : ( - - )} - - -
+ + + ) : ( + + )} + + ); } diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx index a023c8160..291c9d2ac 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -73,8 +73,8 @@ import { Input } from "../ui/input"; import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; import { ScrollArea } from "../ui/scroll-area"; import { Spinner } from "../ui/spinner"; -import { WizardPanel, WizardSteps } from "../ui/wizard"; -import { Dialog, DialogHeader, DialogPopup, DialogTitle } from "../ui/dialog"; +import { WizardPanel, WizardSteps, WizardPopup, WizardHeader } from "../ui/wizard"; +import { Dialog } from "../ui/dialog"; import { toastManager } from "../ui/toast"; import { cn } from "../../lib/utils"; import { formatRelativeTime } from "../../timestampFormat"; @@ -185,71 +185,71 @@ export function WelcomeWizard({ return ( event.cancel()}> - document.getElementById("onboarding-pairing-url") ?? true} > - Set up Pylon -
- + Pylon
- isImporting || index >= stageIndex} - onStepChange={(index) => { - if (isImporting || index > stageIndex) return; - setStep(index === 0 ? "connection" : "agents"); + } + > + isImporting || index >= stageIndex} + onStepChange={(index) => { + if (isImporting || index > stageIndex) return; + setStep(index === 0 ? "connection" : "agents"); + }} + /> + + + + {step === "connection" ? ( + + setSelection((current) => { + const next = new Set(current ?? selectedIds); + if (checked) next.add(environmentId); + else next.delete(environmentId); + return next; + }) + } + onContinue={() => + startSetup( + environments + .filter((environment) => selectedIds.has(environment.environmentId)) + .map((environment) => environment.environmentId), + ) + } + onPaired={(environmentId) => { + setSelection(new Set([...selectedIds, environmentId])); }} /> - - - - {step === "connection" ? ( - - setSelection((current) => { - const next = new Set(current ?? selectedIds); - if (checked) next.add(environmentId); - else next.delete(environmentId); - return next; - }) - } - onContinue={() => - startSetup( - environments - .filter((environment) => selectedIds.has(environment.environmentId)) - .map((environment) => environment.environmentId), - ) - } - onPaired={(environmentId) => { - setSelection(new Set([...selectedIds, environmentId])); - }} - /> - ) : step === "agents" ? ( - setStep("import")} /> - ) : ( - - )} - - -
+ ) : step === "agents" ? ( + setStep("import")} /> + ) : ( + + )} + +
); } diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index 4f5a3147b..1a42a109f 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -16,21 +16,14 @@ import { cn } from "../../lib/utils"; import { normalizeProviderAccentColor } from "../../providerInstances"; import { Button } from "../ui/button"; import { ACPRegistryIcon, Gemini, GithubCopilotIcon, PiAgentIcon, type Icon } from "../Icons"; -import { - Dialog, - DialogDescription, - DialogFooter, - DialogHeader, - DialogPopup, - DialogTitle, -} from "../ui/dialog"; +import { Dialog } from "../ui/dialog"; import { Badge } from "../ui/badge"; import { Input } from "../ui/input"; import { RadioGroup } from "../ui/radio-group"; import { toastManager } from "../ui/toast"; import { DRIVER_OPTION_BY_VALUE, DRIVER_OPTIONS } from "./providerDriverMeta"; import { ProviderSettingsForm, deriveProviderSettingsFields } from "./ProviderSettingsForm"; -import { WizardPanel } from "../ui/wizard"; +import { WizardPanel, WizardPopup, WizardHeader, WizardFooter } from "../ui/wizard"; import { ADD_PROVIDER_WIZARD_STEPS, resolveWizardNavigation, @@ -258,244 +251,242 @@ export function AddProviderInstanceDialog({ return ( - -
- - Add provider instance - + + Configure an additional provider instance on {environmentLabel} — for example, a second Codex install pointed at a different workspace. - - - + + } + > + + - -
-
- Driver -
- setDriver(ProviderDriverKind.make(value))} - aria-labelledby="add-instance-driver-label" - className="grid grid-cols-1 gap-2 sm:grid-cols-2" - > - {DRIVER_OPTIONS.map((option) => { - const IconComponent = option.icon; - const optionSupport = getDriverMultipleInstancePresentation({ - driver: option.value, - providers: serverProviders, - }); - const optionBlocked = - countEnabledConfiguredInstances(settings, option.value) > 0 && - !optionSupport.supported; - return ( - +
+
+ Driver +
+ setDriver(ProviderDriverKind.make(value))} + aria-labelledby="add-instance-driver-label" + className="grid grid-cols-1 gap-2 sm:grid-cols-2" + > + {DRIVER_OPTIONS.map((option) => { + const IconComponent = option.icon; + const optionSupport = getDriverMultipleInstancePresentation({ + driver: option.value, + providers: serverProviders, + }); + const optionBlocked = + countEnabledConfiguredInstances(settings, option.value) > 0 && + !optionSupport.supported; + return ( + + + + {option.label} + + - - - {option.label} - - - - - {option.badgeLabel ? ( - - {option.badgeLabel} - - ) : null} - - ); - })} - {COMING_SOON_DRIVER_OPTIONS.map((option) => { - const IconComponent = option.icon; + + + {option.badgeLabel ? ( + + {option.badgeLabel} + + ) : null} + + ); + })} + {COMING_SOON_DRIVER_OPTIONS.map((option) => { + const IconComponent = option.icon; + return ( + + + + {option.label} + + + Coming Soon + + + ); + })} + + {multipleInstancesBlocked ? ( +

+ {multipleInstanceSupport.reason} +

+ ) : null} +
+ + + + + +
+ Accent color +
+ setAccentColor(event.target.value)} + aria-label="Provider instance accent color" + className="h-8 w-10 cursor-pointer rounded-xl border border-input bg-background p-0.5" + /> +
+ {PROVIDER_ACCENT_SWATCHES.map((swatch) => { + const selected = accentColor.toLowerCase() === swatch; return ( - - - - {option.label} - - - Coming Soon - - + style={{ backgroundColor: swatch }} + onClick={() => setAccentColor(swatch)} + aria-label={`Use ${swatch} accent`} + /> ); })} - - {multipleInstancesBlocked ? ( -

- {multipleInstanceSupport.reason} -

+
+ {accentColor ? ( + ) : null}
+ + Optional marker shown in the picker. + +
-
); } diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx index 8e45ec27d..6abb66329 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -7,6 +7,7 @@ import { type ServerSettingsPatch, } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; +import { useNavigate } from "@tanstack/react-router"; import { useRef, useState } from "react"; import { Trash2Icon } from "lucide-react"; @@ -52,6 +53,7 @@ export function ProjectDefaultsSettings({ }: { environmentId: EnvironmentId | null; }) { + const navigate = useNavigate(); const { environments } = useEnvironments(); const primaryEnvironmentId = usePrimaryEnvironmentId(); const clientSettings = useClientSettings(); @@ -260,6 +262,13 @@ export function ProjectDefaultsSettings({ triggerVariant="outline" triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME} getModelDisabledReason={modelDisabledReason} + onOpenProviderSetup={(instanceId) => { + if (representative) + void navigate({ + to: "/settings/providers", + search: { environmentId: representative.environmentId, instanceId }, + }); + }} onInstanceModelChange={(instanceId, model) => setModel(createModelSelection(instanceId, model)) } diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index fe723a15c..2b3f0544b 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -1124,6 +1124,12 @@ function ProjectDetail({ modelOptionsByInstance={modelOptionsByInstance} triggerVariant="outline" triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME} + onOpenProviderSetup={(instanceId) => { + void navigate({ + to: "/settings/providers", + search: { environmentId: representative.environmentId, instanceId }, + }); + }} onInstanceModelChange={(instanceId, model) => { setDefaultModel(createModelSelection(instanceId, model)); }} diff --git a/apps/web/src/components/settings/ProviderInstanceCard.test.ts b/apps/web/src/components/settings/ProviderInstanceCard.test.ts index 505bc1c55..977eef816 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.test.ts +++ b/apps/web/src/components/settings/ProviderInstanceCard.test.ts @@ -11,6 +11,7 @@ import { import { DEFAULT_TIMESTAMP_FORMAT } from "@t3tools/contracts/settings"; import { deriveProviderModelsForDisplay, ProviderInstanceCard } from "./ProviderInstanceCard"; +import { getDriverOption } from "./providerDriverMeta"; /** * The inner markup of every `tag` element carrying `inert=""`. These tests @@ -130,6 +131,54 @@ describe("deriveProviderModelsForDisplay", () => { }); }); + it.each([ + ["antigravity", false], + ["codex", true], + ] as const)( + "offers custom models only where the driver can send them (%s)", + (driverName, offersCustomModels) => { + const instanceId = ProviderInstanceId.make(`${driverName}_work`); + const driver = ProviderDriverKind.make(driverName); + const liveProvider: ServerProvider = { + instanceId, + driver, + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-08-27T12:00:00.000Z", + models: [ + { slug: "catalog-model", name: "Catalog model", isCustom: false, capabilities: null }, + ], + slashCommands: [], + skills: [], + }; + + const markup = renderToStaticMarkup( + createElement(ProviderInstanceCard, { + instanceId, + instance: { driver, config: { customModels: ["saved-custom-model"] } }, + driverOption: getDriverOption(driver), + liveProvider, + mode: "editor", + timestampFormat: DEFAULT_TIMESTAMP_FORMAT, + onUpdate: () => undefined, + hiddenModels: [], + favoriteModels: [], + modelOrder: [], + onHiddenModelsChange: () => undefined, + onFavoriteModelsChange: () => undefined, + onModelOrderChange: () => undefined, + }), + ); + + expect(markup).toContain("Catalog model"); + expect(markup.includes("Add custom model")).toBe(offersCustomModels); + expect(markup.includes("saved-custom-model")).toBe(offersCustomModels); + }, + ); + it("shows a redacted provider email in the editor header status line", () => { const instanceId = ProviderInstanceId.make("codex"); const driver = ProviderDriverKind.make("codex"); diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index d66b5bc7f..b6e853cba 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -776,7 +776,8 @@ export function ProviderInstanceCard({ const driverKind: ProviderDriverKind | null = isProviderDriverKind(instance.driver) ? instance.driver : null; - const customModels = readConfigCustomModels(instance.config); + const customModels = + instance.driver === "antigravity" ? [] : readConfigCustomModels(instance.config); // Server-returned models may lag behind settings writes. Treat probe // models as the source for built-ins only; custom rows come directly // from the current instance config so add/remove reflects immediately. diff --git a/apps/web/src/components/settings/ProviderModelsSection.tsx b/apps/web/src/components/settings/ProviderModelsSection.tsx index 89aa20f40..28af9d472 100644 --- a/apps/web/src/components/settings/ProviderModelsSection.tsx +++ b/apps/web/src/components/settings/ProviderModelsSection.tsx @@ -206,6 +206,8 @@ export function ProviderModelsSection({ }, [displayModels]); const handleAdd = () => { + // Antigravity serves only its account catalog; custom slugs are never sent. + if (driverKind === "antigravity") return; const normalized = normalizeCustomModelSlug(input); if (!normalized) { setError("Enter a model slug."); @@ -552,7 +554,7 @@ export function ProviderModelsSection({ })} - {isAdding ? ( + {driverKind === "antigravity" ? null : isAdding ? (
)} - {error ?

{error}

: null} + {driverKind !== "antigravity" && error ? ( +

{error}

+ ) : null}
); } diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx index 61747f766..b22085917 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx @@ -132,12 +132,16 @@ function provider(): ServerProvider { function renderPanel(options?: { readonly readOnly?: boolean; + readonly targetInstanceId?: ProviderInstanceId; }): ReactElement> { hooks.beginRender(); return EnvironmentProviderSettings({ environmentId, environmentLabel: "Remote device", ...(options?.readOnly === undefined ? {} : { readOnly: options.readOnly }), + ...(options?.targetInstanceId === undefined + ? {} + : { targetInstanceId: options.targetInstanceId }), }) as ReactElement>; } @@ -193,7 +197,10 @@ describe("EnvironmentProviderSettings routing", () => { (refreshButton?.props.onClick as (() => void) | undefined)?.(); await flushPromises(); - expect(commands.refresh).toHaveBeenCalledWith({ environmentId, input: {} }); + expect(commands.refresh).toHaveBeenCalledWith({ + environmentId, + input: { refreshModels: true }, + }); const providerCard = visitElements( panel, @@ -210,6 +217,26 @@ describe("EnvironmentProviderSettings routing", () => { }); }); + it("opens the requested provider instance instead of the first provider", () => { + settingsState.value = { + ...DEFAULT_UNIFIED_SETTINGS, + providerInstances: { + [customId]: { driver: ProviderDriverKind.make("codex"), enabled: true }, + }, + }; + atoms.providers = [provider()]; + const panel = renderPanel({ targetInstanceId: customId }); + const editor = visitElements(panel, (element) => element.props.mode === "editor"); + expect(editor?.props.instanceId).toBe(customId); + }); + + it("does not substitute another account when the requested instance was removed", () => { + atoms.providers = [provider()]; + const panel = renderPanel({ targetInstanceId: customId }); + expect(visitElements(panel, (element) => element.props.mode === "editor")).toBeNull(); + expect(settingsState.updateSettings).not.toHaveBeenCalled(); + }); + it("keeps provider selection available while write controls are read only", () => { settingsState.value = { ...DEFAULT_UNIFIED_SETTINGS, diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts b/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts index c04db646a..f9bb45cff 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts +++ b/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts @@ -7,6 +7,7 @@ import { isProviderSettingsEnvironmentAvailable, resolvePrimaryOperateAccess, resolveRemoteOperateAccess, + resolveProviderSettingsTargetEnvironment, resolveSelectedProviderEnvironmentId, } from "./ProviderSettingsPanel.logic"; @@ -66,6 +67,42 @@ describe("provider environment selection", () => { ); expect(resolveSelectedProviderEnvironmentId([], null, primaryId)).toBeNull(); }); + + it("keeps a cold-opened setup link loading until the device list is ready", () => { + const listed = [{ environmentId: primaryId, label: "This device" }]; + const target = { + primaryEnvironmentId: primaryId, + selectedEnvironmentId: relayId, + targetEnvironmentId: relayId, + }; + + expect( + resolveProviderSettingsTargetEnvironment({ ...target, environments: [], isReady: false }), + ).toEqual({ effectiveEnvironmentId: relayId, targetEnvironmentState: "loading" }); + // A partially loaded list does not flash another device's providers. + expect( + resolveProviderSettingsTargetEnvironment({ ...target, environments: listed, isReady: false }), + ).toEqual({ effectiveEnvironmentId: relayId, targetEnvironmentState: "loading" }); + expect( + resolveProviderSettingsTargetEnvironment({ ...target, environments: listed, isReady: true }), + ).toEqual({ effectiveEnvironmentId: relayId, targetEnvironmentState: "missing" }); + expect( + resolveProviderSettingsTargetEnvironment({ + ...target, + environments: [...listed, { environmentId: relayId, label: "Alpha Relay" }], + isReady: false, + }), + ).toEqual({ effectiveEnvironmentId: relayId, targetEnvironmentState: "listed" }); + // Choosing another device leaves the link's target behind. + expect( + resolveProviderSettingsTargetEnvironment({ + ...target, + selectedEnvironmentId: primaryId, + environments: listed, + isReady: true, + }), + ).toEqual({ effectiveEnvironmentId: primaryId, targetEnvironmentState: "listed" }); + }); }); describe("provider environment access", () => { diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts b/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts index b415b5f69..645d7c602 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts +++ b/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts @@ -54,6 +54,43 @@ export function resolveSelectedProviderEnvironmentId( return environments[0]?.environmentId ?? null; } +/** + * Where Settings → Providers lands for a provider-setup link. A requested device + * that is not listed yet holds the page on loading until the device list is + * ready; only a loaded list can prove the device is gone. + */ +export function resolveProviderSettingsTargetEnvironment(input: { + readonly environments: ReadonlyArray; + readonly isReady: boolean; + readonly selectedEnvironmentId: EnvironmentId | null; + readonly primaryEnvironmentId: EnvironmentId | null; + readonly targetEnvironmentId: EnvironmentId | undefined; +}): { + readonly effectiveEnvironmentId: EnvironmentId | null; + readonly targetEnvironmentState: "listed" | "loading" | "missing"; +} { + const targetAbsent = + input.targetEnvironmentId !== undefined && + input.selectedEnvironmentId === input.targetEnvironmentId && + !input.environments.some( + (environment) => environment.environmentId === input.targetEnvironmentId, + ); + if (targetAbsent) { + return { + effectiveEnvironmentId: input.targetEnvironmentId ?? null, + targetEnvironmentState: input.isReady ? "missing" : "loading", + }; + } + return { + effectiveEnvironmentId: resolveSelectedProviderEnvironmentId( + input.environments, + input.selectedEnvironmentId, + input.primaryEnvironmentId, + ), + targetEnvironmentState: "listed", + }; +} + export type ProviderEnvironmentAccess = | { readonly kind: "editable" } /** `reason` distinguishes waiting on the device from waiting on permissions. */ diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index a422514e8..2433b0eee 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -108,7 +108,7 @@ import { type ProviderOperateAccess, resolvePrimaryOperateAccess, resolveRemoteOperateAccess, - resolveSelectedProviderEnvironmentId, + resolveProviderSettingsTargetEnvironment, } from "./ProviderSettingsPanel.logic"; import { ProviderSetupSection, readAntigravityAuthMethod } from "./ProviderSetupSection"; @@ -263,15 +263,24 @@ function EnvironmentUnavailablePlaceholder({ ); } -export function ProviderSettingsPanel() { +/** Device and provider instance that a "provider setup" link asks Settings to open. */ +interface ProviderSettingsTarget { + readonly environmentId?: EnvironmentId; + readonly instanceId?: ProviderInstanceId; +} + +export function ProviderSettingsPanel(target: ProviderSettingsTarget) { return ( - + ); } -function ProviderSettingsPanelContent() { +function ProviderSettingsPanelContent(target: ProviderSettingsTarget) { const { environments, isReady } = useEnvironments(); const primaryEnvironmentId = usePrimaryEnvironmentId(); const searchTargetId = useSettingsSearchTargetId(); @@ -283,13 +292,17 @@ function ProviderSettingsPanelContent() { // device that drops out of the catalog falls back without erasing the pick — // if it reappears (e.g. after a reconnect) the selection is restored. const [selectedEnvironmentId, setSelectedEnvironmentId] = useState( - primaryEnvironmentId, - ); - const effectiveEnvironmentId = resolveSelectedProviderEnvironmentId( - options, - selectedEnvironmentId, - primaryEnvironmentId, + target.environmentId ?? primaryEnvironmentId, ); + const { effectiveEnvironmentId, targetEnvironmentState } = + resolveProviderSettingsTargetEnvironment({ + environments: options, + isReady, + selectedEnvironmentId, + primaryEnvironmentId, + targetEnvironmentId: target.environmentId, + }); + const targetEnvironmentMissing = targetEnvironmentState === "missing"; const selectedEnvironment = options.find((environment) => environment.environmentId === effectiveEnvironmentId) ?? null; const selectedEnvironmentCanRenderSettings = @@ -368,7 +381,16 @@ function ProviderSettingsPanelContent() { return ( <> - {options.length === 0 ? ( + {targetEnvironmentMissing ? ( + } + title="Device unavailable" + description="Reconnect this device to set up its provider, or select another device." + /> + ) : null} + {(options.length === 0 || targetEnvironmentState === "loading") && + !targetEnvironmentMissing ? ( ) : null} @@ -395,9 +423,11 @@ function ProviderSettingsPanelContent() { function SelectedEnvironmentProviderSettings({ environment, deviceTabs, + targetInstanceId, }: { readonly environment: EnvironmentPresentation; readonly deviceTabs?: ReactNode; + readonly targetInstanceId?: ProviderInstanceId | undefined; }) { const isPrimary = environment.entry.target._tag === "PrimaryConnectionTarget"; if (isPrimary) { @@ -409,22 +439,35 @@ function SelectedEnvironmentProviderSettings({ environment={environment} operateAccess="granted" deviceTabs={deviceTabs} + targetInstanceId={targetInstanceId} /> ); } return ( - + ); } - return ; + return ( + + ); } function PrimarySessionGatedProviderSettings({ environment, deviceTabs, + targetInstanceId, }: { readonly environment: EnvironmentPresentation; readonly deviceTabs?: ReactNode; + readonly targetInstanceId?: ProviderInstanceId | undefined; }) { const primarySessionState = usePrimarySessionState(); const operateAccess = resolvePrimaryOperateAccess({ @@ -439,6 +482,7 @@ function PrimarySessionGatedProviderSettings({ environment={environment} operateAccess={operateAccess} deviceTabs={deviceTabs} + targetInstanceId={targetInstanceId} /> ); } @@ -446,9 +490,11 @@ function PrimarySessionGatedProviderSettings({ function RemoteSessionGatedProviderSettings({ environment, deviceTabs, + targetInstanceId, }: { readonly environment: EnvironmentPresentation; readonly deviceTabs?: ReactNode; + readonly targetInstanceId?: ProviderInstanceId | undefined; }) { const sessionState = useEnvironmentSessionState(environment.environmentId); const operateAccess = resolveRemoteOperateAccess({ @@ -461,6 +507,7 @@ function RemoteSessionGatedProviderSettings({ environment={environment} operateAccess={operateAccess} deviceTabs={deviceTabs} + targetInstanceId={targetInstanceId} /> ); } @@ -469,10 +516,12 @@ function AccessGatedProviderSettings({ environment, operateAccess, deviceTabs, + targetInstanceId, }: { readonly environment: EnvironmentPresentation; readonly operateAccess: ProviderOperateAccess; readonly deviceTabs?: ReactNode; + readonly targetInstanceId?: ProviderInstanceId | undefined; }) { const access = classifyProviderEnvironmentAccess({ connectionPhase: environment.connection.phase, @@ -494,6 +543,7 @@ function AccessGatedProviderSettings({ environmentLabel={environment.label} readOnly={access.kind === "read-only"} deviceTabs={deviceTabs} + targetInstanceId={targetInstanceId} /> ); } @@ -503,10 +553,13 @@ export function EnvironmentProviderSettings({ environmentLabel, readOnly = false, deviceTabs, + targetInstanceId, }: { readonly environmentId: EnvironmentId; readonly environmentLabel: string; readonly deviceTabs?: ReactNode; + /** Instance to open in the editor instead of the first provider. */ + readonly targetInstanceId?: ProviderInstanceId | undefined; /** * Grey out and freeze every write control when this session's credential * lacks `orchestration:operate` on the environment. Selecting providers and @@ -533,7 +586,9 @@ export function EnvironmentProviderSettings({ }); const [isRefreshingProviders, setIsRefreshingProviders] = useState(false); const [isAddInstanceDialogOpen, setIsAddInstanceDialogOpen] = useState(false); - const [selectedInstanceId, setSelectedInstanceId] = useState(null); + const [selectedInstanceId, setSelectedInstanceId] = useState( + targetInstanceId ?? null, + ); const [updatingProviderInstanceIds, setUpdatingProviderInstanceIds] = useState< ReadonlySet >(() => new Set()); @@ -582,7 +637,7 @@ export function EnvironmentProviderSettings({ void (async () => { const result = await refreshServerProviders({ environmentId, - input: {}, + input: { refreshModels: true }, }); refreshingRef.current = false; setIsRefreshingProviders(false); @@ -741,7 +796,14 @@ export function EnvironmentProviderSettings({ } } - const selectedRow = rows.find((row) => row.instanceId === selectedInstanceId) ?? rows[0] ?? null; + // A removed target shows why nothing opened instead of editing another account. + const targetInstanceMissing = + targetInstanceId !== undefined && + selectedInstanceId === targetInstanceId && + !rows.some((row) => row.instanceId === targetInstanceId); + const selectedRow = + rows.find((row) => row.instanceId === selectedInstanceId) ?? + (targetInstanceMissing ? null : (rows[0] ?? null)); const updateProviderInstance = ( row: InstanceRow, @@ -1074,7 +1136,11 @@ export function EnvironmentProviderSettings({
{renderProviderInstance(selectedRow, "editor")}
) : ( -
No providers configured.
+
+ {targetInstanceMissing + ? "This provider instance is no longer available on this device." + : "No providers configured."} +
)} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index c8befd4c9..b6451f9ff 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -8,6 +8,7 @@ import { type BackgroundActivityProfile, type DesktopUpdateChannel, ProviderDriverKind, + type ProviderInstanceId, type ScopedThreadRef, type SidebarProjectGroupingMode, } from "@t3tools/contracts"; @@ -96,6 +97,7 @@ import { primaryServerProvidersAtom, } from "../../state/server"; import { useProjects } from "../../state/entities"; +import { usePrimaryEnvironmentId } from "../../state/environments"; import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; import { formatRelativeTimeLabel } from "../../timestampFormat"; import { Button } from "../ui/button"; @@ -2003,6 +2005,8 @@ function LegacyFeaturesSection() { export function GeneralSettingsPanel() { const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); + const navigate = useNavigate(); + const environmentId = usePrimaryEnvironmentId(); const [backgroundActivityDialogOpen, setBackgroundActivityDialogOpen] = useState(false); const lastEnabledProjectGroupingMode = useRef( readLastEnabledProjectGroupingMode(), @@ -2785,6 +2789,16 @@ export function GeneralSettingsPanel() { modelOptionsByInstance={textGenerationModelOptionsByInstance} triggerVariant="outline" triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME} + {...(environmentId + ? { + onOpenProviderSetup: (instanceId: ProviderInstanceId) => { + void navigate({ + to: "/settings/providers", + search: { environmentId, instanceId }, + }); + }, + } + : {})} onInstanceModelChange={(instanceId, model) => { updateSettings({ textGenerationModelSelection: resolveAppModelSelectionState( diff --git a/apps/web/src/components/settings/SnapShotSetupDialog.tsx b/apps/web/src/components/settings/SnapShotSetupDialog.tsx index a2d68efef..ed4414b31 100644 --- a/apps/web/src/components/settings/SnapShotSetupDialog.tsx +++ b/apps/web/src/components/settings/SnapShotSetupDialog.tsx @@ -7,16 +7,8 @@ import { CircleCheckIcon } from "lucide-react"; import { useEffect, useId, useState, type ReactNode } from "react"; import { CaptureShortcutConfig } from "./CaptureShortcutConfig"; import { Button } from "../ui/button"; -import { - Dialog, - DialogDescription, - DialogFooter, - DialogHeader, - DialogPanel, - DialogPopup, - DialogTitle, -} from "../ui/dialog"; -import { WizardSteps } from "../ui/wizard-steps"; +import { Dialog, DialogDescription } from "../ui/dialog"; +import { WizardSteps, WizardPopup, WizardHeader, WizardPanel, WizardFooter } from "../ui/wizard"; import { captureSetupAccessReady, captureSetupBackend, @@ -335,21 +327,19 @@ export function SnapShotSetupDialog({ if (!open && !busy) void onClose(false); }} > - - - - {desktop ? `Set up snapshots for ${desktop}` : "Set up snapshots"} - + + ({ ...item, disabled: index > stepIndex }))} - currentStep={step} - disabled={busy} - onStepSelect={(next) => { - if (next !== step) changeStep(next); + steps={SETUP_STEPS.map((item) => item.label)} + currentStep={stepIndex} + isStepDisabled={(index) => busy || index > stepIndex} + onStepChange={(index) => { + const next = SETUP_STEPS[index]; + if (next && next.id !== step) changeStep(next.id); }} /> - - + +

{title}

@@ -479,8 +469,8 @@ export function SnapShotSetupDialog({ ) : null}
- - + + {step !== "access" ? ( ) : null} - - + + ); } diff --git a/apps/web/src/components/settings/SourceControlWritingSettings.tsx b/apps/web/src/components/settings/SourceControlWritingSettings.tsx index b8c4c36dd..13e330193 100644 --- a/apps/web/src/components/settings/SourceControlWritingSettings.tsx +++ b/apps/web/src/components/settings/SourceControlWritingSettings.tsx @@ -1,6 +1,7 @@ import { useAtomValue } from "@effect/atom-react"; +import { useNavigate } from "@tanstack/react-router"; import { useRef } from "react"; -import type { SourceControlWritingStyleMode } from "@t3tools/contracts"; +import type { ProviderInstanceId, SourceControlWritingStyleMode } from "@t3tools/contracts"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; import { createModelSelection } from "@t3tools/shared/model"; import { resolveSourceControlWriterModelSelection } from "@t3tools/shared/serverSettings"; @@ -16,6 +17,7 @@ import { getCustomModelOptionsByInstance, resolveAppModelSelectionState, } from "../../modelSelection"; +import { usePrimaryEnvironmentId } from "../../state/environments"; import { primaryServerProvidersAtom } from "../../state/server"; import { ProviderModelPicker } from "../chat/ProviderModelPicker"; import { TraitsPicker } from "../chat/TraitsPicker"; @@ -51,6 +53,8 @@ const MODE_OPTIONS: Record(null); const style = settings.sourceControlWritingStyle; @@ -224,6 +228,16 @@ export function SourceControlWritingSettingsSection() { triggerVariant="outline" triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME} triggerAriaLabel="Source control writer model" + {...(environmentId + ? { + onOpenProviderSetup: (instanceId: ProviderInstanceId) => { + void navigate({ + to: "/settings/providers", + search: { environmentId, instanceId }, + }); + }, + } + : {})} onInstanceModelChange={(instanceId, model) => { updateSettings({ sourceControlWriterModelSelection: normalizeDedicatedSelection( diff --git a/apps/web/src/components/ui/wizard-steps.tsx b/apps/web/src/components/ui/wizard-steps.tsx deleted file mode 100644 index ccb639dd0..000000000 --- a/apps/web/src/components/ui/wizard-steps.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { CheckIcon } from "lucide-react"; -import { cn } from "~/lib/utils"; - -export function WizardSteps({ - steps, - currentStep, - disabled = false, - onStepSelect, -}: { - steps: readonly { id: Step; label: string; disabled?: boolean }[]; - currentStep: Step; - disabled?: boolean; - onStepSelect: (step: Step) => void; -}) { - const currentIndex = steps.findIndex((step) => step.id === currentStep); - return ( -
    - {steps.map((step, index) => ( -
  1. - -
  2. - ))} -
- ); -} diff --git a/apps/web/src/components/ui/wizard.tsx b/apps/web/src/components/ui/wizard.tsx index 8843e265e..925b263f4 100644 --- a/apps/web/src/components/ui/wizard.tsx +++ b/apps/web/src/components/ui/wizard.tsx @@ -1,19 +1,75 @@ import { CheckIcon } from "lucide-react"; -import type { ComponentProps } from "react"; +import type { ComponentProps, ReactNode } from "react"; import { cn } from "../../lib/utils"; import { AnimatedHeight } from "../AnimatedHeight"; +import { DialogPopup, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "./dialog"; + +/** Compose a wizard from its header, panel, and footer; flow logic stays with the caller. */ +export function WizardPopup({ + children, + ...props +}: Omit, "className" | "style">) { + return ( + +
{children}
+
+ ); +} + +export function WizardHeader({ + title, + description, + identity, + children, +}: { + readonly title: ReactNode; + readonly description?: ReactNode; + /** Optional branding shown in place of the visible title. The title remains accessible. */ + readonly identity?: ReactNode; + readonly children?: ReactNode; +}) { + return ( + + {title} + {identity} + {description ? {description} : null} + {children} + + ); +} + +export function WizardFooter({ + children, + leading, +}: { + readonly children: ReactNode; + readonly leading?: ReactNode; +}) { + return ( + + {leading} + {leading ? ( +
{children}
+ ) : ( + children + )} +
+ ); +} export function WizardSteps({ steps, currentStep, summaries, + showSummaries = false, onStepChange, isStepDisabled, }: { readonly steps: readonly string[]; readonly currentStep: number; readonly summaries?: readonly (string | null)[]; + readonly showSummaries?: boolean; readonly isStepDisabled?: (step: number) => boolean; readonly onStepChange?: (step: number) => void; }) { @@ -61,6 +117,9 @@ export function WizardSteps({ )} > {step} + {showSummaries && index < currentStep && summaries?.[index] + ? `: ${summaries[index]}` + : null}
@@ -70,19 +129,16 @@ export function WizardSteps({ } export function WizardPanel({ - className, children, holdHeight = false, - ...props -}: ComponentProps<"div"> & { readonly holdHeight?: boolean }) { +}: { + readonly children: ReactNode; + readonly holdHeight?: boolean; +}) { return (
{children}
diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 8c8915f6f..4786f3a8e 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1259,6 +1259,8 @@ export function deriveEffectiveComposerModelState(input: { { preserveUnavailableSelection: preserveThreadModel }, ) : null) ?? + // Antigravity has no static model or cross-account catalog fallback. + (input.selectedProvider === "antigravity" && input.selectedInstanceId ? "" : null) ?? resolveAppModelSelection( input.selectedProvider, input.settings, @@ -1275,7 +1277,11 @@ export function deriveEffectiveComposerModelState(input: { ? input.draft?.modelSelectionByProvider?.[input.selectedInstanceId] : undefined; const legacySelection = - input.draft?.modelSelectionByProvider?.[ProviderInstanceId.make(input.selectedProvider)]; + input.selectedProvider === "antigravity" && + input.selectedInstanceId && + input.selectedInstanceId !== defaultInstanceIdForDriver(input.selectedProvider) + ? undefined + : input.draft?.modelSelectionByProvider?.[ProviderInstanceId.make(input.selectedProvider)]; const activeSelection = instanceSelection ?? legacySelection; const activeSelectionInstanceId = instanceSelection ? (input.selectedInstanceId ?? ProviderInstanceId.make(input.selectedProvider)) @@ -1288,6 +1294,7 @@ export function deriveEffectiveComposerModelState(input: { activeSelection.model, { preserveUnavailableSelection: true }, ) ?? + (input.selectedProvider === "antigravity" ? "" : null) ?? resolveAppModelSelection( input.selectedProvider, input.settings, diff --git a/apps/web/src/composerInstanceSelection.test.ts b/apps/web/src/composerInstanceSelection.test.ts index 4a5b0e027..7dd97e985 100644 --- a/apps/web/src/composerInstanceSelection.test.ts +++ b/apps/web/src/composerInstanceSelection.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test"; import { canStartComposerTurn, resolveComposerInstanceSelection, + resolveComposerProviderSettingsAction, } from "./composerInstanceSelection"; import { deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION } from "./providerInstances"; @@ -155,6 +156,8 @@ describe("resolveComposerInstanceSelection", () => { expect(selection.entry?.snapshot.unavailableReason).toBe(PRIME_UNAVAILABLE_REASON); expect(selection.entry?.snapshot.availability).toBe("unavailable"); expect(canStartComposerTurn(selection)).toBe(false); + // Enabling cannot fix explicit unavailability; the picker stays so another provider can be chosen. + expect(selection.unavailableInstanceId).toBeUndefined(); }, ); @@ -294,6 +297,46 @@ describe("resolveComposerInstanceSelection", () => { expect(selection.lockedContinuationGroupKey).toBe("org-a"); expect(selection.instanceId).toBe("claudeAgent"); expect(canStartComposerTurn(selection)).toBe(false); + // The enabled sibling in the same continuation group is picked explicitly, + // so the composer keeps the picker instead of sending the user to settings. + expect(selection.unavailableInstanceId).toBeUndefined(); + expect( + resolveComposerProviderSettingsAction({ + selection, + catalogKnown: true, + lockedProvider: kind("claudeAgent"), + fallbackSetupInstanceId: undefined, + }), + ).toEqual({ visible: false, instanceId: undefined }); + }); + + it("offers settings for a disabled binding whose only peers are in another continuation group", () => { + const work = provider({ + instanceId: "claude_work", + driver: "claudeAgent", + continuationGroupKey: "org-a", + enabled: false, + }); + const personal = provider({ + instanceId: "claudeAgent", + driver: "claudeAgent", + continuationGroupKey: "org-b", + }); + const disabledSibling = provider({ + instanceId: "claude_work_2", + driver: "claudeAgent", + continuationGroupKey: "org-a", + enabled: false, + }); + const selection = resolveComposerInstanceSelection({ + ...base, + entries: entriesOf(work, personal, disabledSibling), + sessionInstanceId: id("claude_work"), + lockedProvider: kind("claudeAgent"), + }); + + expect(selection.instanceId).toBe("claude_work"); + expect(selection.unavailableInstanceId).toBe("claude_work"); }); it("reports no provider when nothing is selectable", () => { @@ -303,5 +346,133 @@ describe("resolveComposerInstanceSelection", () => { expect(selection.entry).toBeUndefined(); expect(selection.driverKind).toBe("unconfigured"); expect(canStartComposerTurn(selection)).toBe(false); + expect(selection.unavailableInstanceId).toBeUndefined(); + }); + + it("names the locked instance to set up when it was removed", () => { + const selection = resolveComposerInstanceSelection({ + ...base, + entries: entriesOf(provider({ instanceId: "antigravity", driver: "antigravity" })), + draftActiveProvider: id("google_personal"), + sessionInstanceId: id("google_work"), + lockedProvider: kind("antigravity"), + }); + + expect(selection.entry).toBeUndefined(); + expect(selection.unavailableInstanceId).toBe("google_work"); + }); + + it("names the requested instance when nothing else can serve an unlocked draft", () => { + const selection = resolveComposerInstanceSelection({ + ...base, + entries: entriesOf( + provider({ instanceId: "antigravity", driver: "antigravity", enabled: false }), + ), + threadInstanceId: id("antigravity"), + }); + + expect(selection.entry).toBeUndefined(); + expect(selection.unavailableInstanceId).toBe("antigravity"); + }); + + it("does not treat the empty draft placeholder as a provider setup target", () => { + const selection = resolveComposerInstanceSelection({ + ...base, + entries: entriesOf( + provider({ instanceId: "antigravity", driver: "antigravity", enabled: false }), + ), + draftActiveProvider: NO_PROVIDER_MODEL_SELECTION.instanceId, + }); + + expect(selection.entry).toBeUndefined(); + expect(selection.unavailableInstanceId).toBeUndefined(); + }); + + it("names a disabled instance the live session is still bound to", () => { + const selection = resolveComposerInstanceSelection({ + ...base, + entries: entriesOf(provider({ instanceId: "codex", driver: "codex", enabled: false })), + sessionInstanceId: id("codex"), + lockedProvider: kind("codex"), + }); + + expect(selection.entry?.instanceId).toBe("codex"); + expect(canStartComposerTurn(selection)).toBe(false); + expect(selection.unavailableInstanceId).toBe("codex"); + }); + + it("reports no setup target while an entry resolved", () => { + const selection = resolveComposerInstanceSelection({ + ...base, + entries: entriesOf(CODEX), + threadInstanceId: id("codex"), + }); + + expect(selection.entry?.instanceId).toBe("codex"); + expect(selection.unavailableInstanceId).toBeUndefined(); + }); +}); + +describe("resolveComposerProviderSettingsAction", () => { + const settings = { + catalogKnown: true, + lockedProvider: null, + fallbackSetupInstanceId: undefined, + } as const; + + it("replaces the picker with settings for a disabled instance the session is bound to", () => { + const selection = resolveComposerInstanceSelection({ + ...base, + entries: entriesOf(provider({ instanceId: "codex", driver: "codex", enabled: false })), + sessionInstanceId: id("codex"), + lockedProvider: kind("codex"), + }); + + expect( + resolveComposerProviderSettingsAction({ + ...settings, + selection, + lockedProvider: kind("codex"), + }), + ).toEqual({ visible: true, instanceId: "codex" }); + }); + + it("keeps the picker for an enabled instance and for explicit unavailability", () => { + for (const selection of [ + resolveComposerInstanceSelection({ ...base, entries: entriesOf(CODEX) }), + resolveComposerInstanceSelection({ + ...base, + entries: entriesOf(PRIME_UNAVAILABLE, CODEX), + threadInstanceId: id("primeAgent"), + }), + ]) { + expect(resolveComposerProviderSettingsAction({ ...settings, selection })).toEqual({ + visible: false, + instanceId: undefined, + }); + } + }); + + it("waits for the catalog and offers setup when nothing resolved", () => { + const selection = resolveComposerInstanceSelection({ ...base, entries: [] }); + + expect( + resolveComposerProviderSettingsAction({ ...settings, selection, catalogKnown: false }), + ).toEqual({ visible: false, instanceId: undefined }); + expect( + resolveComposerProviderSettingsAction({ + ...settings, + selection, + fallbackSetupInstanceId: id("antigravity"), + }), + ).toEqual({ visible: true, instanceId: "antigravity" }); + expect( + resolveComposerProviderSettingsAction({ + ...settings, + selection, + lockedProvider: kind("codex"), + fallbackSetupInstanceId: id("antigravity"), + }), + ).toEqual({ visible: true, instanceId: undefined }); }); }); diff --git a/apps/web/src/composerInstanceSelection.ts b/apps/web/src/composerInstanceSelection.ts index 2d6c2309f..2a6c8f4a2 100644 --- a/apps/web/src/composerInstanceSelection.ts +++ b/apps/web/src/composerInstanceSelection.ts @@ -85,6 +85,16 @@ export interface ComposerInstanceSelection { * thread is unlocked or its instance has no group. */ readonly lockedContinuationGroupKey: string | null; + /** + * The instance whose provider settings the composer should open because the + * turn cannot start there: the resolved entry when it is disabled and no + * enabled, available continuation peer can take the thread, or, when no entry + * resolved, the instance the thread asked for (its locked instance first). + * Undefined while the resolved entry is enabled, when a continuation peer can + * be picked instead, when explicit unavailability blocks it (enabling it cannot + * help; pick another provider), or when nothing was requested. + */ + readonly unavailableInstanceId: ProviderInstanceId | undefined; } export function resolveComposerInstanceSelection( @@ -123,6 +133,15 @@ export function resolveComposerInstanceSelection( const lockedContinuationGroupKey = lockedInstanceId ? (entries.find((entry) => entry.instanceId === lockedInstanceId)?.continuationGroupKey ?? null) : null; + const requestedInstanceId = [ + input.draftActiveProvider, + sessionInstanceId, + input.threadInstanceId, + input.projectInstanceId, + ].find( + (candidate): candidate is ProviderInstanceId => + candidate != null && candidate !== NO_PROVIDER_MODEL_SELECTION.instanceId, + ); const candidates: ReadonlyArray<{ readonly instanceId: ProviderInstanceId | null | undefined; @@ -140,6 +159,20 @@ export function resolveComposerInstanceSelection( { instanceId: input.projectInstanceId, pinned: false }, ]; + // A disabled binding needs settings only when the picker has nothing to + // offer: an enabled, available continuation peer is the deliberate remedy. + const hasEnabledContinuationPeer = (entry: ProviderInstanceEntry) => + entries.some( + (peer) => + peer.instanceId !== entry.instanceId && + peer.enabled && + peer.isAvailable && + resolveProviderContinuationTransition({ + providers, + currentInstanceId: entry.instanceId, + targetInstanceId: peer.instanceId, + }).compatible, + ); const finish = ( instanceId: ProviderInstanceId, entry: ProviderInstanceEntry | undefined, @@ -152,6 +185,12 @@ export function resolveComposerInstanceSelection( blockedByUnavailablePreference, draftConflictsWithSessionBinding, lockedContinuationGroupKey, + unavailableInstanceId: + entry === undefined + ? (lockedInstanceId ?? requestedInstanceId) + : entry.enabled || blockedByUnavailablePreference || hasEnabledContinuationPeer(entry) + ? undefined + : entry.instanceId, }); for (const candidate of candidates) { @@ -210,6 +249,30 @@ export function resolveComposerInstanceSelection( return finish(fallback?.instanceId ?? NO_PROVIDER_MODEL_SELECTION.instanceId, fallback); } +/** + * The composer's provider settings action. It replaces the model picker when + * no provider resolved (once the catalog is known) or when the resolved + * instance is disabled, and names the instance whose settings to open. + */ +export function resolveComposerProviderSettingsAction(input: { + readonly selection: ComposerInstanceSelection; + readonly catalogKnown: boolean; + readonly lockedProvider: ProviderDriverKind | null; + /** First instance with integrated setup, offered when an unlocked thread resolves nothing. */ + readonly fallbackSetupInstanceId: ProviderInstanceId | undefined; +}): { readonly visible: boolean; readonly instanceId: ProviderInstanceId | undefined } { + if (input.selection.entry !== undefined) { + const instanceId = input.selection.unavailableInstanceId; + return { visible: instanceId !== undefined, instanceId }; + } + return { + visible: input.catalogKnown, + instanceId: + input.selection.unavailableInstanceId ?? + (input.lockedProvider === null ? input.fallbackSetupInstanceId : undefined), + }; +} + /** Whether the resolved routing target may be admitted as a provider turn. */ export function canStartComposerTurn(selection: ComposerInstanceSelection): boolean { return ( diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index eee3537ff..057b88de0 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -1,4 +1,9 @@ -import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import { + ANTIGRAVITY_DEFAULT_MODEL, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, +} from "@t3tools/contracts"; import { DEFAULT_UNIFIED_SETTINGS, type UnifiedSettings } from "@t3tools/contracts/settings"; import { describe, expect, it } from "vite-plus/test"; import { createModelSelection } from "@t3tools/shared/model"; @@ -309,6 +314,153 @@ describe("instance-scoped model selection", () => { "claude-opus-4-6", ), ).toBe("claude-sonnet-4-6"); + expect( + resolveAppModelSelectionForInstance( + ProviderInstanceId.make("claudeAgent"), + settings, + providers, + "claude-opus-4-6", + { preserveUnavailableSelection: true }, + ), + ).toBe("claude-sonnet-4-6"); + }); + + it("stays on the instance's own default when every listed model is hidden", () => { + const providers = [ + provider({ instanceId: "claudeAgent", models: ["claude-sonnet-5"] }), + { + ...provider({ + instanceId: "claude_openrouter", + provider: ProviderDriverKind.make("claudeAgent"), + models: ["openrouter/other", "anthropic/claude-sonnet-4.5"], + }), + models: [ + { slug: "openrouter/other", name: "Other", isCustom: false, capabilities: {} }, + { + slug: "anthropic/claude-sonnet-4.5", + name: "Sonnet", + isCustom: false, + isDefault: true, + capabilities: {}, + }, + ], + }, + ]; + const settings: UnifiedSettings = { + ...DEFAULT_UNIFIED_SETTINGS, + providerModelPreferences: { + [ProviderInstanceId.make("claude_openrouter")]: { + hiddenModels: ["openrouter/other", "anthropic/claude-sonnet-4.5"], + modelOrder: [], + }, + }, + }; + + for (const preserveUnavailableSelection of [false, true]) { + expect( + resolveAppModelSelectionForInstance( + ProviderInstanceId.make("claude_openrouter"), + settings, + providers, + "anthropic/claude-sonnet-4.5", + { preserveUnavailableSelection }, + ), + ).toBe("anthropic/claude-sonnet-4.5"); + } + // The composer never borrows the default Claude instance's model for it. + const state = deriveEffectiveComposerModelState({ + draft: null, + providers, + selectedProvider: ProviderDriverKind.make("claudeAgent"), + selectedInstanceId: ProviderInstanceId.make("claude_openrouter"), + threadModelSelection: createModelSelection( + ProviderInstanceId.make("claude_openrouter"), + "anthropic/claude-sonnet-4.5", + ), + projectModelSelection: null, + settings, + }); + expect(state.selectedModel).toBe("anthropic/claude-sonnet-4.5"); + }); + + it("keeps a hidden Claude instance's model when the default Claude instance is disabled", () => { + const providers = [ + provider({ instanceId: "codex", models: ["gpt-5.6-sol"] }), + { + ...provider({ + instanceId: "claude_default", + provider: ProviderDriverKind.make("claudeAgent"), + models: ["claude-sonnet-5"], + }), + enabled: false, + }, + provider({ + instanceId: "claude_personal", + provider: ProviderDriverKind.make("claudeAgent"), + models: ["claude-opus-4-6"], + }), + ]; + const settings: UnifiedSettings = { + ...DEFAULT_UNIFIED_SETTINGS, + providerModelPreferences: { + [ProviderInstanceId.make("claude_personal")]: { + hiddenModels: ["claude-opus-4-6"], + modelOrder: [], + }, + }, + }; + const instanceId = ProviderInstanceId.make("claude_personal"); + for (const draft of [ + null, + { + activeProvider: instanceId, + modelSelectionByProvider: { + [instanceId]: createModelSelection(instanceId, "claude-opus-4-6"), + }, + }, + ]) { + const state = deriveEffectiveComposerModelState({ + draft, + providers, + selectedProvider: ProviderDriverKind.make("claudeAgent"), + selectedInstanceId: instanceId, + threadModelSelection: createModelSelection(instanceId, "claude-opus-4-6"), + projectModelSelection: null, + settings, + }); + expect(state.selectedModel).toBe("claude-opus-4-6"); + } + }); + + it("uses the text generation instance's default when every listed model is hidden", () => { + const instanceId = ProviderInstanceId.make("codex"); + const providers: ServerProvider[] = [ + { + ...provider({ instanceId: "codex", models: [], supportsBackgroundTextGeneration: true }), + models: [ + { slug: "gpt-first", name: "First", isCustom: false, capabilities: {} }, + { + slug: "gpt-default", + name: "Default", + isCustom: false, + isDefault: true, + capabilities: {}, + }, + ], + }, + ]; + const settings: UnifiedSettings = { + ...DEFAULT_UNIFIED_SETTINGS, + textGenerationModelSelection: createModelSelection(instanceId, "gpt-first"), + providerModelPreferences: { + [instanceId]: { hiddenModels: ["gpt-first", "gpt-default"], modelOrder: [] }, + }, + }; + + expect(resolveAppModelSelectionState(settings, providers)).toMatchObject({ + instanceId, + model: "gpt-default", + }); }); it("falls back instead of resolving a custom slug against the wrong instance", () => { @@ -515,26 +667,147 @@ describe("instance-scoped model selection", () => { }); }); - it("adds the selected missing OpenCode model as an unavailable option", () => { - const providers = [ - provider({ - provider: ProviderDriverKind.make("opencode"), - instanceId: "opencode", - models: ["opencode/big-pickle"], - }), - ]; - const entry = deriveProviderInstanceEntries(providers)[0]!; + describe.each([ + { + driverName: "opencode", + availableModel: "opencode/big-pickle", + missingModel: "opencode/kimi-k3", + }, + { + driverName: "antigravity", + availableModel: "gemini-3.1-pro", + missingModel: "gemini-3.1-pro-high", + }, + ])("$driverName catalog gaps", ({ driverName, availableModel, missingModel }) => { + it("preserves a selected model when a catalog refresh no longer contains it", () => { + const providers = [ + provider({ + provider: ProviderDriverKind.make(driverName), + instanceId: driverName, + models: [availableModel], + }), + ]; + + expect( + resolveAppModelSelectionForInstance( + ProviderInstanceId.make(driverName), + settingsWithProviderInstances(), + providers, + missingModel, + { preserveUnavailableSelection: true }, + ), + ).toBe(missingModel); + expect( + resolveAppModelSelectionForInstance( + ProviderInstanceId.make(driverName), + settingsWithProviderInstances(), + providers, + missingModel, + ), + ).toBe(availableModel); + }); - expect( - getAppModelOptionsForInstance(settingsWithProviderInstances(), entry, "opencode/kimi-k3"), - ).toEqual([ - expect.objectContaining({ slug: "opencode/big-pickle" }), - expect.objectContaining({ - slug: "opencode/kimi-k3", - name: "opencode/kimi-k3", - isUnavailable: true, - }), - ]); + it("adds the selected missing model as an unavailable option", () => { + const providers = [ + provider({ + provider: ProviderDriverKind.make(driverName), + instanceId: driverName, + models: [availableModel], + }), + ]; + const entry = deriveProviderInstanceEntries(providers)[0]!; + + expect( + getAppModelOptionsForInstance(settingsWithProviderInstances(), entry, missingModel), + ).toEqual([ + expect.objectContaining({ slug: availableModel }), + expect.objectContaining({ + slug: missingModel, + name: missingModel, + isUnavailable: true, + }), + ]); + }); + + it("keeps a missing option scoped to the selected instance", () => { + const selectedInstanceId = ProviderInstanceId.make(`${driverName}_work`); + const otherInstanceId = ProviderInstanceId.make(`${driverName}_personal`); + const driver = ProviderDriverKind.make(driverName); + const providers = [ + provider({ provider: driver, instanceId: selectedInstanceId, models: [] }), + provider({ provider: driver, instanceId: otherInstanceId, models: [] }), + ]; + const options = getCustomModelOptionsByInstance( + settingsWithProviderInstances(), + providers, + selectedInstanceId, + missingModel, + ); + + expect(options.get(selectedInstanceId)).toEqual([ + expect.objectContaining({ slug: missingModel, isUnavailable: true }), + ]); + expect(options.get(otherInstanceId)).toEqual([]); + }); + + it("replaces the unavailable marker with catalog metadata after recovery", () => { + const instanceId = ProviderInstanceId.make(driverName); + const driver = ProviderDriverKind.make(driverName); + const selectedModel = missingModel; + const pendingProviders = [ + provider({ provider: driver, instanceId, models: [availableModel] }), + ]; + const recoveredProviders = [ + provider({ provider: driver, instanceId, models: [availableModel, selectedModel] }), + ]; + + expect( + getAppModelOptionsForInstance( + settingsWithProviderInstances(), + deriveProviderInstanceEntries(pendingProviders)[0]!, + selectedModel, + ).find((option) => option.slug === selectedModel)?.isUnavailable, + ).toBe(true); + expect( + getAppModelOptionsForInstance( + settingsWithProviderInstances(), + deriveProviderInstanceEntries(recoveredProviders)[0]!, + selectedModel, + ).find((option) => option.slug === selectedModel)?.isUnavailable, + ).toBeUndefined(); + expect( + resolveAppModelSelectionForInstance( + instanceId, + settingsWithProviderInstances(), + recoveredProviders, + selectedModel, + { preserveUnavailableSelection: true }, + ), + ).toBe(selectedModel); + }); + + it("does not resurrect a hidden model when the raw catalog omits it", () => { + const instanceId = ProviderInstanceId.make(driverName); + const driver = ProviderDriverKind.make(driverName); + const settings: UnifiedSettings = { + ...settingsWithProviderInstances(), + providerModelPreferences: { + [instanceId]: { + hiddenModels: [missingModel], + modelOrder: [], + }, + }, + }; + const providers = [provider({ provider: driver, instanceId, models: [] })]; + const entry = deriveProviderInstanceEntries(providers)[0]!; + + expect(getAppModelOptionsForInstance(settings, entry, missingModel)).toEqual([]); + expect( + resolveAppModelSelectionForInstance(instanceId, settings, providers, missingModel, { + preserveUnavailableSelection: true, + }), + ).toBeNull(); + }); }); it("does not add unavailable options for other providers", () => { @@ -563,29 +836,6 @@ describe("instance-scoped model selection", () => { ).toBe("gpt-5.6-sol"); }); - it("does not resurrect a hidden OpenCode model when the raw catalog omits it", () => { - const instanceId = ProviderInstanceId.make("opencode"); - const driver = ProviderDriverKind.make("opencode"); - const settings: UnifiedSettings = { - ...settingsWithProviderInstances(), - providerModelPreferences: { - [instanceId]: { - hiddenModels: ["opencode/kimi-k3"], - modelOrder: [], - }, - }, - }; - const providers = [provider({ provider: driver, instanceId, models: [] })]; - const entry = deriveProviderInstanceEntries(providers)[0]!; - - expect(getAppModelOptionsForInstance(settings, entry, "opencode/kimi-k3")).toEqual([]); - expect( - resolveAppModelSelectionForInstance(instanceId, settings, providers, "opencode/kimi-k3", { - preserveUnavailableSelection: true, - }), - ).toBeNull(); - }); - it("falls back from an explicit non-OpenCode draft with a missing model", () => { const instanceId = ProviderInstanceId.make("codex"); const driver = ProviderDriverKind.make("codex"); @@ -656,55 +906,6 @@ describe("instance-scoped model selection", () => { ).toEqual(createModelSelection(instanceId, "openai/gpt-5.5")); }); - it("keeps a missing OpenCode option scoped to the selected instance", () => { - const selectedInstanceId = ProviderInstanceId.make("opencode_work"); - const otherInstanceId = ProviderInstanceId.make("opencode_personal"); - const driver = ProviderDriverKind.make("opencode"); - const providers = [ - provider({ provider: driver, instanceId: selectedInstanceId, models: [] }), - provider({ provider: driver, instanceId: otherInstanceId, models: [] }), - ]; - const options = getCustomModelOptionsByInstance( - settingsWithProviderInstances(), - providers, - selectedInstanceId, - "openrouter/kimi-k3", - ); - - expect(options.get(selectedInstanceId)).toEqual([ - expect.objectContaining({ slug: "openrouter/kimi-k3", isUnavailable: true }), - ]); - expect(options.get(otherInstanceId)).toEqual([]); - }); - - it("preserves an existing OpenCode model when a catalog refresh no longer contains it", () => { - const providers = [ - provider({ - provider: ProviderDriverKind.make("opencode"), - instanceId: "opencode", - models: ["opencode/big-pickle"], - }), - ]; - - expect( - resolveAppModelSelectionForInstance( - ProviderInstanceId.make("opencode"), - settingsWithProviderInstances(), - providers, - "opencode/kimi-k3", - { preserveUnavailableSelection: true }, - ), - ).toBe("opencode/kimi-k3"); - expect( - resolveAppModelSelectionForInstance( - ProviderInstanceId.make("opencode"), - settingsWithProviderInstances(), - providers, - "opencode/kimi-k3", - ), - ).toBe("opencode/big-pickle"); - }); - it("preserves an explicit draft OpenCode selection while the catalog is empty", () => { const instanceId = ProviderInstanceId.make("opencode_work"); const driver = ProviderDriverKind.make("opencode"); @@ -730,6 +931,159 @@ describe("instance-scoped model selection", () => { expect(state.modelOptions?.[instanceId]).toEqual(draftSelection.options); }); + it("preserves the Antigravity model in drafts and existing threads after sign-out", () => { + const instanceId = ProviderInstanceId.make("antigravity_work"); + const driver = ProviderDriverKind.make("antigravity"); + const saved = createModelSelection(instanceId, "gemini-3.1-pro-high"); + const providers = [ + { + ...provider({ provider: driver, instanceId, models: [] }), + status: "error" as const, + auth: { status: "unauthenticated" as const }, + }, + ]; + for (const draft of [ + null, + { activeProvider: instanceId, modelSelectionByProvider: { [instanceId]: saved } }, + ]) { + const state = deriveEffectiveComposerModelState({ + draft, + providers, + selectedProvider: driver, + selectedInstanceId: instanceId, + threadModelSelection: saved, + projectModelSelection: null, + settings: settingsWithProviderInstances(), + }); + expect(state.selectedModel).toBe(saved.model); + } + }); + + it("does not borrow a default model while a new Antigravity account has no catalog", () => { + const driver = ProviderDriverKind.make("antigravity"); + const instanceId = ProviderInstanceId.make("antigravity_work"); + const providers = [ + provider({ instanceId: "codex", models: ["gpt-5.6-sol"] }), + provider({ provider: driver, instanceId: "antigravity", models: ["gemini-other-account"] }), + provider({ provider: driver, instanceId, models: [] }), + ]; + + const otherAccountId = ProviderInstanceId.make("antigravity"); + for (const draft of [ + null, + { + activeProvider: instanceId, + modelSelectionByProvider: { + [otherAccountId]: createModelSelection(otherAccountId, "gemini-other-account"), + }, + }, + ]) { + const state = deriveEffectiveComposerModelState({ + draft, + providers, + selectedProvider: driver, + selectedInstanceId: instanceId, + threadModelSelection: null, + projectModelSelection: createModelSelection( + ProviderInstanceId.make("codex"), + "gpt-5.6-sol", + ), + settings: settingsWithProviderInstances(), + }); + expect(state.selectedModel).toBe(""); + } + }); + + it("offers only account catalog models for Antigravity despite custom model settings", () => { + const driver = ProviderDriverKind.make("antigravity"); + const customId = ProviderInstanceId.make("antigravity_work"); + const nativeModel = "gemini-3.1-pro"; + const settings: UnifiedSettings = { + ...DEFAULT_UNIFIED_SETTINGS, + providers: { + ...DEFAULT_UNIFIED_SETTINGS.providers, + antigravity: { + ...DEFAULT_UNIFIED_SETTINGS.providers.antigravity, + customModels: ["api-only-model"], + }, + }, + providerInstances: { + [customId]: { driver, config: { customModels: ["unknown-model"] } }, + }, + }; + const entries = deriveProviderInstanceEntries([ + provider({ provider: driver, instanceId: "antigravity", models: [nativeModel] }), + provider({ provider: driver, instanceId: customId, models: [nativeModel] }), + ]); + + for (const entry of entries) { + expect(getAppModelOptionsForInstance(settings, entry).map((model) => model.slug)).toEqual([ + nativeModel, + ]); + } + }); + + it("resolves the Antigravity default marker without creating an unavailable model", () => { + const instanceId = ProviderInstanceId.make("antigravity_work"); + const nativeModel = "gemini-3.1-pro"; + const base = provider({ + provider: ProviderDriverKind.make("antigravity"), + instanceId, + models: [nativeModel], + }); + const liveProvider = { + ...base, + models: base.models.map((model) => ({ + ...model, + isDefault: true, + aliases: [ANTIGRAVITY_DEFAULT_MODEL], + })), + }; + const settings = settingsWithProviderInstances(); + + expect( + getAppModelOptionsForInstance( + settings, + deriveProviderInstanceEntries([liveProvider])[0]!, + ANTIGRAVITY_DEFAULT_MODEL, + ).map((model) => model.slug), + ).toEqual([nativeModel]); + expect( + resolveAppModelSelectionForInstance( + instanceId, + settings, + [liveProvider], + ANTIGRAVITY_DEFAULT_MODEL, + { + preserveUnavailableSelection: true, + }, + ), + ).toBe(nativeModel); + + const hiddenSettings: UnifiedSettings = { + ...settings, + providerModelPreferences: { + [instanceId]: { hiddenModels: [nativeModel], modelOrder: [] }, + }, + }; + expect( + resolveAppModelSelectionForInstance( + instanceId, + hiddenSettings, + [liveProvider], + ANTIGRAVITY_DEFAULT_MODEL, + { preserveUnavailableSelection: true }, + ), + ).toBeNull(); + expect( + getAppModelOptionsForInstance( + settings, + deriveProviderInstanceEntries([{ ...base, models: [] }])[0]!, + ANTIGRAVITY_DEFAULT_MODEL, + ), + ).toEqual([]); + }); + it("preserves saved options through dispatch when the model is absent from the catalog", () => { const instanceId = ProviderInstanceId.make("opencode"); const driver = ProviderDriverKind.make("opencode"); @@ -759,42 +1113,6 @@ describe("instance-scoped model selection", () => { createModelSelection(instanceId, state.selectedModel, dispatch.modelOptionsForDispatch), ).toEqual(saved); }); - - it("replaces the unavailable marker with catalog metadata after recovery", () => { - const instanceId = ProviderInstanceId.make("opencode"); - const driver = ProviderDriverKind.make("opencode"); - const selectedModel = "opencode/kimi-k3"; - const pendingProviders = [ - provider({ provider: driver, instanceId, models: ["opencode/big-pickle"] }), - ]; - const recoveredProviders = [ - provider({ provider: driver, instanceId, models: ["opencode/big-pickle", selectedModel] }), - ]; - - expect( - getAppModelOptionsForInstance( - settingsWithProviderInstances(), - deriveProviderInstanceEntries(pendingProviders)[0]!, - selectedModel, - ).find((option) => option.slug === selectedModel)?.isUnavailable, - ).toBe(true); - expect( - getAppModelOptionsForInstance( - settingsWithProviderInstances(), - deriveProviderInstanceEntries(recoveredProviders)[0]!, - selectedModel, - ).find((option) => option.slug === selectedModel)?.isUnavailable, - ).toBeUndefined(); - expect( - resolveAppModelSelectionForInstance( - instanceId, - settingsWithProviderInstances(), - recoveredProviders, - selectedModel, - { preserveUnavailableSelection: true }, - ), - ).toBe(selectedModel); - }); }); describe("withoutPlanAgentSelection", () => { diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index 94f1765fd..2bb0d6245 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -1,4 +1,5 @@ import { + ANTIGRAVITY_DEFAULT_MODEL, DEFAULT_TEXT_GENERATION_MODEL, DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER, defaultInstanceIdForDriver, @@ -54,6 +55,7 @@ function readInstanceCustomModels( instanceId: ProviderInstanceId, driverKind: ProviderDriverKind, ): ReadonlyArray { + if (driverKind === "antigravity") return []; const instance = settings.providerInstances?.[instanceId]; const config = instance?.config; if (config !== null && typeof config === "object") { @@ -86,20 +88,21 @@ export interface AppModelOption { isUnavailable?: boolean; } -function appendUnavailableOpenCodeSelection( +function appendUnavailableDynamicModelSelection( options: AppModelOption[], rawModels: ReadonlyArray, provider: ProviderDriverKind, selectedModel: string | null | undefined, hiddenModels: ReadonlyArray, ): AppModelOption[] { - if (provider !== "opencode") return options; + if (provider !== "opencode" && provider !== "antigravity") return options; const slug = normalizeCustomModelSlug(selectedModel); if (!slug) return options; + if (provider === "antigravity" && slug === ANTIGRAVITY_DEFAULT_MODEL) return options; // A model that exists in the raw catalog can be absent from `options` // because the user hid it. Keep that preference authoritative. - if (rawModels.some((model) => model.slug === slug)) return options; + if (resolveSelectableModel(provider, slug, rawModels) !== null) return options; if (hiddenModels.includes(slug)) return options; if (options.some((option) => option.slug === slug)) return options; @@ -209,7 +212,7 @@ function getAppModelOptions( } const preferences = readInstanceModelPreferences(settings, defaultInstanceId); - return appendUnavailableOpenCodeSelection( + return appendUnavailableDynamicModelSelection( applyInstanceModelPreferences(options, preferences), rawModels, provider, @@ -257,7 +260,7 @@ export function getAppModelOptionsForInstance( } const preferences = readInstanceModelPreferences(settings, entry.instanceId); - return appendUnavailableOpenCodeSelection( + return appendUnavailableDynamicModelSelection( applyInstanceModelPreferences(options, preferences), entry.models, entry.driverKind, @@ -300,19 +303,31 @@ export function resolveAppModelSelectionForInstance( if (resolvedSelection) { return resolvedSelection; } - if (resolutionOptions?.preserveUnavailableSelection && entry.driverKind === "opencode") { + if ( + resolutionOptions?.preserveUnavailableSelection && + (entry.driverKind === "opencode" || entry.driverKind === "antigravity") + ) { const unavailableSelection = normalizeCustomModelSlug(selectedModel); const hiddenModels = readInstanceModelPreferences(settings, entry.instanceId).hiddenModels; - if (unavailableSelection && !hiddenModels.includes(unavailableSelection)) { + if ( + unavailableSelection && + !hiddenModels.includes(unavailableSelection) && + resolveSelectableModel(entry.driverKind, selectedModel, entry.models) === null && + (entry.driverKind !== "antigravity" || unavailableSelection !== ANTIGRAVITY_DEFAULT_MODEL) + ) { return unavailableSelection; } } + // Hiding models is a picker preference, not unavailability: when every + // listed model is hidden, stay on this instance's own default rather than + // letting a driver-level fallback pick another instance's model. Antigravity + // has no model outside its account catalog, so it resolves to nothing. return ( options.find((option) => option.isDefault)?.slug ?? options[0]?.slug ?? - entry.models.find((model) => model.isDefault)?.slug ?? - entry.models[0]?.slug ?? - null + (entry.driverKind === "antigravity" + ? null + : (entry.models.find((model) => model.isDefault)?.slug ?? entry.models[0]?.slug ?? null)) ); } diff --git a/apps/web/src/routes/settings.providers.tsx b/apps/web/src/routes/settings.providers.tsx index deab01472..bb85b8bb6 100644 --- a/apps/web/src/routes/settings.providers.tsx +++ b/apps/web/src/routes/settings.providers.tsx @@ -1,11 +1,21 @@ import { createFileRoute } from "@tanstack/react-router"; +import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; import { ProviderSettingsPanel } from "../components/settings/ProviderSettingsPanel"; function SettingsProvidersRoute() { - return ; + const target = Route.useSearch(); + return ; } export const Route = createFileRoute("/settings/providers")({ + validateSearch: (raw: Record) => ({ + ...(typeof raw.environmentId === "string" && raw.environmentId.trim() + ? { environmentId: EnvironmentId.make(raw.environmentId) } + : {}), + ...(typeof raw.instanceId === "string" && raw.instanceId.trim() + ? { instanceId: ProviderInstanceId.make(raw.instanceId) } + : {}), + }), component: SettingsProvidersRoute, });