Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions apps/web/src/components/settings/ProviderInstanceCard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,48 @@ describe("deriveProviderModelsForDisplay", () => {
expect(markup).toContain("blur-[2px]");
expect(markup).not.toContain("developer@example.com");
});

it("shows informational Antigravity sign-in copy instead of amber Needs attention", () => {
const instanceId = ProviderInstanceId.make("antigravity");
const driver = ProviderDriverKind.make("antigravity");
const liveProvider: ServerProvider = {
instanceId,
driver,
enabled: true,
installed: true,
version: "1.0.0",
status: "warning",
auth: { status: "unknown" },
checkedAt: "2026-08-27T12:00:00.000Z",
models: [],
slashCommands: [],
skills: [],
message: "Antigravity is installed. Google account access is not checked yet.",
};
const props = {
instanceId,
instance: { driver, enabled: true },
driverOption: undefined,
liveProvider,
signInAction: createElement("button", { type: "button" }, "Sign in with Google"),
onUpdate: () => undefined,
hiddenModels: [],
favoriteModels: [],
modelOrder: [],
onHiddenModelsChange: () => undefined,
onFavoriteModelsChange: () => undefined,
onModelOrderChange: () => undefined,
} as const;

for (const mode of ["list", "editor"] as const) {
const markup = renderToStaticMarkup(createElement(ProviderInstanceCard, { ...props, mode }));
expect(markup).toContain("Installed · Sign-in required");
expect(markup).toContain("Sign in with Google");
expect(markup).not.toContain("Needs attention");
expect(markup).not.toContain("bg-warning");
}
});

it("surfaces a failed probe message in both the list row and the editor", () => {
const instanceId = ProviderInstanceId.make("codex_work");
const driver = ProviderDriverKind.make("codex");
Expand Down
50 changes: 34 additions & 16 deletions apps/web/src/components/settings/ProviderInstanceCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import {
PROVIDER_STATUS_STYLES,
getProviderSummary,
getProviderVersionLabel,
isAntigravityUncheckedAuth,
type ProviderStatusKey,
} from "./providerStatus";

Expand Down Expand Up @@ -372,6 +373,12 @@ interface ProviderInstanceCardProps {
* omit it.
*/
readonly headerAction?: ReactNode | undefined;
/**
* Optional Google sign-in control for Antigravity. Rendered on the list row
* and at the top of the editor so OAuth can start from Settings without a
* chat session.
*/
readonly signInAction?: ReactNode | undefined;
readonly setup?: ReactNode;
readonly hiddenModels: ReadonlyArray<string>;
readonly favoriteModels: ReadonlyArray<string>;
Expand Down Expand Up @@ -414,6 +421,7 @@ export function ProviderInstanceCard({
onUpdate,
onDelete,
headerAction,
signInAction,
setup,
hiddenModels,
favoriteModels,
Expand All @@ -434,6 +442,8 @@ export function ProviderInstanceCard({
const summary = enabled
? getProviderSummary(liveProvider)
: { headline: "Disabled", detail: null };
// Keep the server's warning status, but do not paint this as amber attention.
const informationalSignInRequired = enabled && isAntigravityUncheckedAuth(liveProvider);
const authEmail = liveProvider?.auth.email?.trim();
const isAuthenticated = enabled && liveProvider?.auth.status === "authenticated";
const authLabel =
Expand Down Expand Up @@ -567,14 +577,14 @@ export function ProviderInstanceCard({
) : null;

// Healthy and disabled rows read fine from their text; only trouble gets a dot.
const statusDotNode =
statusKey === "warning" || statusKey === "error" ? (
<span className={cn("size-1.5 shrink-0 rounded-full", statusStyle.dot)} aria-hidden />
) : null;
// Trouble states carry the server's explanation (a failed probe, a shadow
// home entry that is not a symlink, a missing binary). Show it wherever the
// headline shows so the user can act without opening the editor.
const needsAttention = statusKey === "warning" || statusKey === "error";
// Unchecked Antigravity Google auth stays informational, not amber attention.
// Trouble states still carry the server's explanation (a failed probe, a
// shadow home entry that is not a symlink, a missing binary).
const needsAttention =
!informationalSignInRequired && (statusKey === "warning" || statusKey === "error");
const statusDotNode = needsAttention ? (
<span className={cn("size-1.5 shrink-0 rounded-full", statusStyle.dot)} aria-hidden />
) : null;
const editorStatusNode =
isAuthenticated && authEmail ? (
<>
Expand Down Expand Up @@ -670,13 +680,16 @@ export function ProviderInstanceCard({
</span>
</span>
</div>
<span className="flex h-5 shrink-0 items-center">
<Switch
checked={enabled}
disabled={readOnly}
onCheckedChange={(checked) => updateEnabled(Boolean(checked))}
aria-label={`Enable ${displayName}`}
/>
<span className="pointer-events-auto relative flex shrink-0 items-center gap-2">
{signInAction}
<span className="flex h-5 items-center">
<Switch
checked={enabled}
disabled={readOnly}
onCheckedChange={(checked) => updateEnabled(Boolean(checked))}
aria-label={`Enable ${displayName}`}
/>
</span>
</span>
</div>
);
Expand Down Expand Up @@ -810,7 +823,12 @@ export function ProviderInstanceCard({
<SettingsRow
title="Display name"
status={
<div className="flex min-w-0 flex-wrap items-center gap-x-1.5">{editorStatusNode}</div>
<div className="flex min-w-0 flex-col items-start gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-x-1.5">
{editorStatusNode}
</div>
{signInAction}
</div>
}
control={
<div
Expand Down
22 changes: 20 additions & 2 deletions apps/web/src/components/settings/ProviderSettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,12 @@ import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog";
import { ExpandableText } from "./ExpandableText";
import { ProviderInstanceCard } from "./ProviderInstanceCard";
import { UsageProviderSettings } from "./UsageProviderSettings";
import { ProviderSetupSection, readAntigravityAuthMethod } from "./ProviderSetupSection";
import {
AntigravityGoogleSignInButton,
offersAntigravityGoogleSignIn,
ProviderSetupSection,
readAntigravityAuthMethod,
} from "./ProviderSetupSection";
import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta";
import { searchableSetting } from "./settingsSearch";
import {
Expand Down Expand Up @@ -900,6 +905,7 @@ export function EnvironmentProviderSettings({
favorite.provider === row.instanceId ? Result.succeed(favorite.model) : Result.failVoid,
);
const resetLabel = driverOption?.label ?? String(row.driver);
const authMethod = readAntigravityAuthMethod(row.instance.config);

return (
<ProviderInstanceCard
Expand All @@ -912,6 +918,18 @@ export function EnvironmentProviderSettings({
selected={mode === "list" && selectedRow?.instanceId === row.instanceId}
onSelect={mode === "list" ? () => setSelectedInstanceId(row.instanceId) : undefined}
readOnly={readOnly}
signInAction={
!readOnly && offersAntigravityGoogleSignIn(liveProvider, authMethod) ? (
<AntigravityGoogleSignInButton
environmentId={environmentId}
instanceId={row.instanceId}
provider={liveProvider}
authMethod={authMethod}
size={mode === "list" ? "xs" : "sm"}
onStarted={mode === "list" ? () => setSelectedInstanceId(row.instanceId) : undefined}
/>
) : null
}
setup={
mode === "editor" && row.driver === "antigravity" ? (
<ProviderSetupSection
Expand All @@ -920,7 +938,7 @@ export function EnvironmentProviderSettings({
instanceId={row.instanceId}
provider={liveProvider}
binaryPath={configuredBinaryPath(row.instance.config)}
authMethod={readAntigravityAuthMethod(row.instance.config)}
authMethod={authMethod}
enabled={resolveProviderInstanceEnabled(row.instance)}
readOnly={readOnly}
onEnable={() => updateProviderInstance(row, { ...row.instance, enabled: true })}
Expand Down
111 changes: 110 additions & 1 deletion apps/web/src/components/settings/ProviderSetupSection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,11 @@ vi.mock("../../localApi", () => ({
ensureLocalApi: () => ({ dialogs: { confirm: setup.confirm } }),
}));

import { ProviderSetupSection } from "./ProviderSetupSection";
import {
AntigravityGoogleSignInButton,
offersAntigravityGoogleSignIn,
ProviderSetupSection,
} from "./ProviderSetupSection";

const environmentId = EnvironmentId.make("remote-google");
const instanceId = ProviderInstanceId.make("antigravity_work");
Expand Down Expand Up @@ -386,4 +390,109 @@ describe("Antigravity setup", () => {
expect(setup.startAuth).not.toHaveBeenCalled();
},
);

it("keeps Google sign-in in setup when the binary is healthy and auth is unchecked", () => {
setup.auth = authState({ phase: "idle", flowId: null, authorizationUrl: null });
const view = renderSetup({
provider: { ...provider, status: "warning", auth: { status: "unknown" } },
});
expect(button(view, "Sign in with Google")).not.toBeNull();
expect(
visitElements(
view,
(element) => element.props.children === "Sign in with your Google account.",
),
).not.toBeNull();
});
});

function renderCardSignIn(
options: {
provider?: ServerProvider;
authMethod?: "oauth-personal" | "gemini-api-key";
onStarted?: () => void;
} = {},
) {
hooks.beginRender();
return AntigravityGoogleSignInButton({
environmentId,
instanceId,
provider: options.provider ?? { ...provider, status: "warning", auth: { status: "unknown" } },
authMethod: options.authMethod ?? "oauth-personal",
onStarted: options.onStarted,
});
}

describe("Antigravity Google sign-in on the provider card", () => {
beforeEach(() => {
hooks.reset();
vi.clearAllMocks();
setup.auth = authState({ phase: "idle", flowId: null, authorizationUrl: null });
setup.startAuth.mockReset().mockResolvedValue({ _tag: "Success", value: undefined });
});

it("starts Google OAuth from Settings when auth has not been checked", async () => {
const onStarted = vi.fn();
const view = renderCardSignIn({
provider: { ...provider, status: "warning", auth: { status: "unknown" } },
onStarted,
});
click(view, "Sign in with Google");
await flushPromises();

expect(onStarted).toHaveBeenCalledTimes(1);
expect(setup.startAuth).toHaveBeenCalledWith({ environmentId, input: { instanceId } });
});

it("coalesces repeated card sign-in clicks while start is pending", async () => {
let completeStart: (value: { _tag: "Success"; value: undefined }) => void = () => {
throw new Error("Missing start resolver.");
};
const pending = new Promise<{ _tag: "Success"; value: undefined }>((resolve) => {
completeStart = resolve;
});
setup.startAuth.mockReturnValueOnce(pending);
const view = renderCardSignIn();
click(view, "Sign in with Google");
click(view, "Sign in with Google");

expect(setup.startAuth).toHaveBeenCalledTimes(1);
completeStart({ _tag: "Success", value: undefined });
await flushPromises();
});

it("does not offer Google sign-in for an API key method or a signed-in account", () => {
expect(
renderCardSignIn({
provider: { ...provider, status: "ready", auth: { status: "authenticated" } },
}),
).toBeNull();
expect(renderCardSignIn({ authMethod: "gemini-api-key" })).toBeNull();
expect(setup.startAuth).not.toHaveBeenCalled();
});
});

describe("offersAntigravityGoogleSignIn", () => {
it("requires an installed Antigravity instance that can run Google OAuth", () => {
expect(offersAntigravityGoogleSignIn(provider, "oauth-personal")).toBe(true);
expect(
offersAntigravityGoogleSignIn(
{ ...provider, status: "warning", auth: { status: "unknown" } },
"oauth-business",
),
).toBe(true);
expect(offersAntigravityGoogleSignIn({ ...provider, installed: false }, "oauth-personal")).toBe(
false,
);
expect(
offersAntigravityGoogleSignIn(
{ ...provider, auth: { status: "authenticated" } },
"oauth-personal",
),
).toBe(false);
expect(offersAntigravityGoogleSignIn(provider, "gemini-api-key")).toBe(false);
const { setup: _setup, ...withoutSetup } = provider;
expect(offersAntigravityGoogleSignIn(withoutSetup, "oauth-personal")).toBe(false);
expect(offersAntigravityGoogleSignIn(undefined, "oauth-personal")).toBe(false);
});
});
Loading