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
32 changes: 32 additions & 0 deletions src/renderer/components/providers/ProviderIcon.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { ProviderIcon } from "./ProviderIcon";
import "./claude";
import "./codex";
import "./copilot";
import "./gemini";
import "./grok";

describe("ProviderIcon", () => {
it("uses the ACP instance id for generic fallback initials", () => {
Expand All @@ -23,4 +27,32 @@ describe("ProviderIcon", () => {
expect(screen.getByText("P")).toBeInTheDocument();
expect(screen.queryByText("C")).not.toBeInTheDocument();
});

it("uses a home profile label for the base provider icon badge", () => {
render(<ProviderIcon kind="codex:work" fallbackLabel="Codex Work" />);

expect(screen.getByText("W")).toBeInTheDocument();
expect(screen.queryByText("C")).not.toBeInTheDocument();
});

it("uses a home profile id when no display label is provided", () => {
render(<ProviderIcon kind="gemini:enterprise" fallbackLabel="gemini:enterprise" />);

expect(screen.getByText("E")).toBeInTheDocument();
expect(screen.queryByText("G")).not.toBeInTheDocument();
});

it("strips the multiword GitHub Copilot label from a profile badge", () => {
render(<ProviderIcon kind="copilot:work" fallbackLabel="GitHub Copilot Work" />);

expect(screen.getByText("W")).toBeInTheDocument();
expect(screen.queryByText("G")).not.toBeInTheDocument();
});

it("strips the multiword Grok Build label from a profile badge", () => {
render(<ProviderIcon kind="grok:work" fallbackLabel="Grok Build Work" />);

expect(screen.getByText("W")).toBeInTheDocument();
expect(screen.queryByText("G")).not.toBeInTheDocument();
});
});
31 changes: 23 additions & 8 deletions src/renderer/components/providers/ProviderIcon.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import type { CSSProperties, ReactNode } from "react";
import { ACP_GENERIC_KIND_PREFIX, CLAUDE_PROFILE_KIND_PREFIX } from "@/shared/contracts";
import {
ACP_GENERIC_KIND_PREFIX,
baseAgentKind,
isClaudeProfileKind,
isHomeProfileKind,
} from "@/shared/contracts";
import { i18n } from "@/renderer/i18n/i18n";
import type { StatusTone } from "./statusTone";
import { getProviderManifest } from "./providerManifest";
import { syncMaskScanPhase } from "./syncMaskScanPhase";
import { lookupProviderRegistration } from "./providerRegistry";

Expand Down Expand Up @@ -70,14 +77,22 @@ function fallbackInitial(label: string | undefined): string {
return (raw.match(/[A-Za-z0-9]/)?.[0] ?? "?").toUpperCase();
}

function claudeProfileBadgeLabel(kind: string, fallbackLabel: string | undefined): string {
const profileId = kind.slice(CLAUDE_PROFILE_KIND_PREFIX.length);
function profileBadgeLabel(kind: string, fallbackLabel: string | undefined): string {
const profileId = kind.slice(kind.indexOf(":") + 1);
const baseKind = baseAgentKind(kind);
const label = fallbackLabel?.trim();
if (!label) return profileId;
if (label === kind || label.toLowerCase().startsWith(CLAUDE_PROFILE_KIND_PREFIX))
if (label === kind || label.toLowerCase().startsWith(`${baseKind.toLowerCase()}:`)) {
return profileId;
const profileLabel = label.replace(/^claude\s+/i, "").trim();
return profileLabel || profileId;
}
const manifestLabel = getProviderManifest(baseKind)?.label;
const providerLabels = [...(manifestLabel ? [i18n._(manifestLabel)] : []), baseKind];
for (const providerLabel of providerLabels) {
if (label.toLowerCase().startsWith(`${providerLabel.toLowerCase()} `)) {
return label.slice(providerLabel.length).trim() || profileId;
}
}
return label;
}

function GenericProviderIcon(props: { label?: string; tone: StatusTone; className?: string }) {
Expand Down Expand Up @@ -135,12 +150,12 @@ export function ProviderIcon(props: {
const rendered = (
<Icon tone={tone} {...(props.className ? { className: props.className } : {})} />
);
if (props.kind.startsWith(CLAUDE_PROFILE_KIND_PREFIX)) {
if (isClaudeProfileKind(props.kind) || isHomeProfileKind(props.kind)) {
return (
<span className={`relative inline-flex ${props.className ?? ""}`}>
{rendered}
<span className="absolute -bottom-0.5 -right-0.5 flex size-2.5 items-center justify-center rounded-full border border-background bg-surface text-[6px] font-semibold leading-none text-foreground">
{fallbackInitial(claudeProfileBadgeLabel(props.kind, props.fallbackLabel))}
{fallbackInitial(profileBadgeLabel(props.kind, props.fallbackLabel))}
</span>
</span>
);
Expand Down
45 changes: 45 additions & 0 deletions src/renderer/components/providers/usageProviders.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
isClaudeUsageProvider,
pickUsageRings,
resolveDisplayedProviders,
supportsBrowserLogin,
usageProvidersForAgentInstances,
usageRingGroups,
} from "./usageProviders";
Expand All @@ -29,6 +30,24 @@ const agentInstances: AgentInstanceConfigMap = {
enabled: false,
config: { configDir: "~/.poracode/claude-profiles/disabled" },
},
codexWork: {
id: "codex-work",
driver: "codex",
displayName: "Work",
config: { homeDir: "~/.poracode/codex-profiles/work" },
},
geminiTeam: {
id: "gemini-team",
driver: "gemini",
displayName: "Team",
config: { homeDir: "~/.poracode/gemini-profiles/team" },
},
grokWork: {
id: "grok-work",
driver: "grok",
displayName: "Work",
config: { homeDir: "~/.poracode/grok-profiles/work" },
},
};

describe("usageProviders", () => {
Expand All @@ -50,6 +69,32 @@ describe("usageProviders", () => {
expect(providers.find((provider) => provider.id === "claude:home")?.label).toBe("Claude Home");
});

it("adds home-isolated profile providers after their base providers", () => {
const providers = usageProvidersForAgentInstances(agentInstances);
const codexIndex = providers.findIndex((provider) => provider.id === "codex");
const geminiIndex = providers.findIndex((provider) => provider.id === "gemini");

expect(providers[codexIndex + 1]).toMatchObject({
id: "codex:codex-work",
label: "Codex Work",
});
expect(providers[geminiIndex + 1]).toMatchObject({
id: "gemini:gemini-team",
label: "Gemini Team",
});
});

it("does not inherit base-provider browser login for isolated profiles", () => {
const profile = usageProvidersForAgentInstances(agentInstances).find(
(provider) => provider.id === "grok:grok-work",
);

expect(profile).toBeDefined();
expect(profile).not.toHaveProperty("supportsBrowserLogin");
expect(supportsBrowserLogin("grok")).toBe(true);
expect(supportsBrowserLogin("grok:grok-work")).toBe(false);
});

it("orders, disables, and rings Claude profiles like Claude", () => {
const providers = resolveDisplayedProviders(
["claude:work", "claude"],
Expand Down
44 changes: 31 additions & 13 deletions src/renderer/components/providers/usageProviders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import type { UsageWindow } from "@poracode/agents-usage/types";
import {
baseAgentKind,
claudeProfileKind,
homeProfileKind,
isHomeProfileDriver,
parseClaudeProfileInstanceConfig,
parseHomeProfileInstanceConfig,
type AgentInstanceConfigMap,
} from "@/shared/contracts";

Expand Down Expand Up @@ -127,23 +130,40 @@ export function isClaudeUsageProvider(providerId: string): boolean {
return baseAgentKind(providerId) === "claude";
}

function claudeProfileUsageProviders(
function profileUsageProviders(
agentInstances: AgentInstanceConfigMap | undefined,
): UsageProvider[] {
if (!agentInstances) return [];
const profiles: UsageProvider[] = [];
for (const instance of Object.values(agentInstances)) {
if (instance.enabled === false || instance.driver !== "claude") continue;
if (
instance.enabled === false ||
(instance.driver !== "claude" && !isHomeProfileDriver(instance.driver))
) {
continue;
}
try {
parseClaudeProfileInstanceConfig(instance.config);
if (instance.driver === "claude") {
parseClaudeProfileInstanceConfig(instance.config);
} else {
parseHomeProfileInstanceConfig(instance.config);
}
} catch {
continue;
}
const label = instance.displayName ?? instance.id;
const baseProvider = STATIC_USAGE_PROVIDERS.find((provider) => provider.id === instance.driver);
if (!baseProvider) continue;
const profileLabel = instance.displayName ?? instance.id;
const meta = rendererMeta(instance.driver);
profiles.push({
id: claudeProfileKind(instance.id),
label: `Claude ${label}`,
...rendererMeta("claude"),
id:
instance.driver === "claude"
? claudeProfileKind(instance.id)
: homeProfileKind(instance.driver, instance.id),
label: `${baseProvider.label} ${profileLabel}`,
...(meta?.sharedWindowReset ? { sharedWindowReset: true } : {}),
...(meta?.rings ? { rings: meta.rings } : {}),
...(meta?.ringGroups ? { ringGroups: meta.ringGroups } : {}),
});
}
profiles.sort((a, b) => a.label.localeCompare(b.label));
Expand All @@ -153,26 +173,24 @@ function claudeProfileUsageProviders(
export function usageProvidersForAgentInstances(
agentInstances: AgentInstanceConfigMap | undefined,
): UsageProvider[] {
const profiles = claudeProfileUsageProviders(agentInstances);
const profiles = profileUsageProviders(agentInstances);
if (profiles.length === 0) return [...STATIC_USAGE_PROVIDERS];
const out: UsageProvider[] = [];
for (const provider of STATIC_USAGE_PROVIDERS) {
out.push(provider);
if (provider.id === "claude") {
out.push(...profiles);
}
out.push(...profiles.filter((profile) => baseAgentKind(profile.id) === provider.id));
}
return out;
}

/** Providers that expose the browser-overlay login (cookie or device flow). */
export function supportsBrowserLogin(providerId: string): boolean {
return rendererMeta(providerId)?.supportsBrowserLogin === true;
return RENDERER_META[providerId]?.supportsBrowserLogin === true;
}

/** Providers that sign in by pasting an API key (no browser step, e.g. z.ai). */
export function supportsApiKeyLogin(providerId: string): boolean {
return rendererMeta(providerId)?.supportsApiKeyLogin === true;
return RENDERER_META[providerId]?.supportsApiKeyLogin === true;
}

/** Providers whose windows share one reset clock (one header countdown, no per-window resets). */
Expand Down
10 changes: 10 additions & 0 deletions src/renderer/components/providers/useUsageProviderLogin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,16 @@ describe("useUsageProviderLogin", () => {
expect(result.current.canSignIn).toBe(true);
});

it("does not offer the base provider's browser login for an auth-missing profile", () => {
useProviderUsageStore.getState().mergeSnapshot(authMissingSnapshot("grok:work"));

const { result } = renderHook(() => useUsageProviderLogin("grok:work"));

expect(result.current.supportsLogin).toBe(false);
expect(result.current.canSignIn).toBe(false);
expect(result.current.canSignOut).toBe(false);
});

it("hides usage login and sign-out controls in remote sessions", () => {
bridgeMock.isRemoteSession.mockReturnValue(true);
useProviderUsageStore.getState().mergeSnapshot(authMissingSnapshot("grok"));
Expand Down
Loading