Skip to content

Commit 3b20ad4

Browse files
authored
Centralize provider status copy and setup guidance (#125)
* Centralize provider status copy and setup guidance - Extract shared provider status presentation helpers - Reuse headings, descriptions, and setup phase labels across chat UI - Add tests for phase classification and auth-specific copy * Pin Effect smol deps and fix custom theme parsing - Add the shared Effect catalog override - Merge theme vars directly when parsing Tweakcn JSON - Remove an unused theme dialog import
1 parent 5180d2b commit 3b20ad4

9 files changed

Lines changed: 204 additions & 41 deletions

apps/web/src/components/CustomThemeDialog.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import {
77
parseThemeInput,
88
setStoredCustomTheme,
99
} from "../lib/customTheme";
10-
import { cn } from "../lib/utils";
1110
import { Button } from "./ui/button";
1211
import {
1312
Dialog,

apps/web/src/components/chat/MobileThreadAttentionBar.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ import { type ServerProviderStatus } from "@okcode/contracts";
55
import { Alert, AlertAction, AlertDescription, AlertTitle } from "../ui/alert";
66
import { Button } from "../ui/button";
77
import { type PendingApproval, type PendingUserInput } from "../../session-logic";
8+
import {
9+
getProviderStatusDescription,
10+
getProviderStatusHeading,
11+
} from "./providerStatusPresentation";
812

913
function summarizeApproval(approval: PendingApproval): string {
1014
return approval.requestKind === "command"
@@ -85,11 +89,8 @@ export const MobileThreadAttentionBar = memo(function MobileThreadAttentionBar({
8589
className="rounded-2xl"
8690
>
8791
<CircleAlertIcon />
88-
<AlertTitle>Provider needs attention</AlertTitle>
89-
<AlertDescription>
90-
{providerStatus.message ??
91-
"The selected provider is degraded. The thread may pause until the provider recovers."}
92-
</AlertDescription>
92+
<AlertTitle>{getProviderStatusHeading(providerStatus)}</AlertTitle>
93+
<AlertDescription>{getProviderStatusDescription(providerStatus)}</AlertDescription>
9394
</Alert>
9495
);
9596
}

apps/web/src/components/chat/ProviderHealthBanner.tsx

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ import { type ServerProviderStatus } from "@okcode/contracts";
22
import { memo } from "react";
33
import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
44
import { CircleAlertIcon } from "lucide-react";
5+
import {
6+
getProviderStatusDescription,
7+
getProviderStatusHeading,
8+
} from "./providerStatusPresentation";
59

610
export const ProviderHealthBanner = memo(function ProviderHealthBanner({
711
status,
@@ -12,25 +16,16 @@ export const ProviderHealthBanner = memo(function ProviderHealthBanner({
1216
return null;
1317
}
1418

15-
const providerLabel =
16-
status.provider === "codex"
17-
? "Codex"
18-
: status.provider === "claudeAgent"
19-
? "Anthropic"
20-
: status.provider;
21-
const defaultMessage =
22-
status.status === "error"
23-
? `${providerLabel} provider is unavailable.`
24-
: `${providerLabel} provider has limited availability.`;
25-
const title = `${providerLabel} provider status`;
19+
const title = getProviderStatusHeading(status);
20+
const description = getProviderStatusDescription(status);
2621

2722
return (
2823
<div className="pt-3 mx-auto max-w-7xl">
2924
<Alert variant={status.status === "error" ? "error" : "warning"}>
3025
<CircleAlertIcon />
3126
<AlertTitle>{title}</AlertTitle>
32-
<AlertDescription className="line-clamp-3" title={status.message ?? defaultMessage}>
33-
{status.message ?? defaultMessage}
27+
<AlertDescription className="line-clamp-3" title={description}>
28+
{description}
3429
</AlertDescription>
3530
</Alert>
3631
</div>

apps/web/src/components/chat/ProviderSetupCard.tsx

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,20 @@ import {
1111
XCircleIcon,
1212
} from "lucide-react";
1313
import { Button } from "../ui/button";
14+
import {
15+
getProviderLabel,
16+
getProviderSetupPhase,
17+
getProviderStatusDescription,
18+
getProviderStatusHeading,
19+
} from "./providerStatusPresentation";
1420

1521
const PROVIDER_CONFIG = {
1622
codex: {
17-
label: "OpenAI (Codex CLI)",
1823
installCmd: "npm install -g @openai/codex",
1924
authCmd: "codex login",
2025
verifyCmd: "codex login status",
2126
},
2227
claudeAgent: {
23-
label: "Anthropic (Claude Code)",
2428
installCmd: "npm install -g @anthropic-ai/claude-code",
2529
authCmd: "claude auth login",
2630
verifyCmd: "claude auth status",
@@ -42,6 +46,9 @@ function ProviderRow({ status }: { status: ServerProviderStatus }) {
4246
const [expanded, setExpanded] = useState(status.status !== "ready");
4347
const config = PROVIDER_CONFIG[status.provider as keyof typeof PROVIDER_CONFIG];
4448
if (!config) return null;
49+
const setupPhase = getProviderSetupPhase(status);
50+
const heading = getProviderStatusHeading(status);
51+
const description = getProviderStatusDescription(status);
4552

4653
return (
4754
<div className="rounded-lg border border-border bg-card/50 p-3">
@@ -51,28 +58,38 @@ function ProviderRow({ status }: { status: ServerProviderStatus }) {
5158
onClick={() => setExpanded((v) => !v)}
5259
>
5360
<StatusIcon status={status.status} />
54-
<span className="flex-1 font-medium text-foreground">{config.label}</span>
61+
<span className="flex-1 font-medium text-foreground">
62+
{getProviderLabel(status.provider)}
63+
</span>
64+
{status.status !== "ready" ? (
65+
<span className="rounded-full border border-border bg-background px-2 py-0.5 text-[10px] font-medium tracking-[0.08em] text-muted-foreground uppercase">
66+
{setupPhase}
67+
</span>
68+
) : null}
5569
{expanded ? (
5670
<ChevronDownIcon className="size-3.5 text-muted-foreground" />
5771
) : (
5872
<ChevronRightIcon className="size-3.5 text-muted-foreground" />
5973
)}
6074
</button>
6175

62-
{status.status !== "ready" && status.message && (
63-
<p className="mt-1.5 ml-6.5 text-xs text-muted-foreground">{status.message}</p>
76+
{status.status !== "ready" && (
77+
<div className="mt-1.5 ml-6.5 space-y-1">
78+
<p className="text-xs font-medium text-foreground">{heading}</p>
79+
<p className="text-xs text-muted-foreground">{description}</p>
80+
</div>
6481
)}
6582

6683
{expanded && status.status !== "ready" && (
6784
<div className="mt-3 ml-6.5 space-y-2">
6885
<div className="space-y-1.5">
69-
<Step n={1} label="Install">
86+
<Step n={1} label="Install" active={setupPhase === "install"}>
7087
<Code>{config.installCmd}</Code>
7188
</Step>
72-
<Step n={2} label="Authenticate">
89+
<Step n={2} label="Authenticate" active={setupPhase === "authenticate"}>
7390
<Code>{config.authCmd}</Code>
7491
</Step>
75-
<Step n={3} label="Verify">
92+
<Step n={3} label="Verify" active={setupPhase === "verify"}>
7693
<Code>{config.verifyCmd}</Code>
7794
</Step>
7895
</div>
@@ -86,10 +103,24 @@ function ProviderRow({ status }: { status: ServerProviderStatus }) {
86103
);
87104
}
88105

89-
function Step({ n, label, children }: { n: number; label: string; children: React.ReactNode }) {
106+
function Step({
107+
n,
108+
label,
109+
active,
110+
children,
111+
}: {
112+
n: number;
113+
label: string;
114+
active: boolean;
115+
children: React.ReactNode;
116+
}) {
90117
return (
91118
<div className="flex items-baseline gap-2 text-xs">
92-
<span className="shrink-0 text-muted-foreground">
119+
<span
120+
className={
121+
active ? "shrink-0 font-medium text-foreground" : "shrink-0 text-muted-foreground"
122+
}
123+
>
93124
{n}. {label}:
94125
</span>
95126
{children}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { describe, expect, it } from "vitest";
2+
import type { ServerProviderStatus } from "@okcode/contracts";
3+
import {
4+
getProviderSetupPhase,
5+
getProviderStatusDescription,
6+
getProviderStatusHeading,
7+
} from "./providerStatusPresentation";
8+
9+
function makeStatus(overrides: Partial<ServerProviderStatus> = {}): ServerProviderStatus {
10+
return {
11+
provider: "codex",
12+
status: "ready",
13+
available: true,
14+
authStatus: "authenticated",
15+
checkedAt: "2026-03-31T12:00:00.000Z",
16+
...overrides,
17+
};
18+
}
19+
20+
describe("getProviderSetupPhase", () => {
21+
it("prioritizes install when the provider is unavailable", () => {
22+
expect(
23+
getProviderSetupPhase(
24+
makeStatus({
25+
available: false,
26+
status: "error",
27+
authStatus: "unknown",
28+
}),
29+
),
30+
).toBe("install");
31+
});
32+
33+
it("classifies available but signed-out providers as authenticate", () => {
34+
expect(
35+
getProviderSetupPhase(
36+
makeStatus({
37+
status: "error",
38+
authStatus: "unauthenticated",
39+
}),
40+
),
41+
).toBe("authenticate");
42+
});
43+
44+
it("uses verify for degraded providers with unknown auth state", () => {
45+
expect(
46+
getProviderSetupPhase(
47+
makeStatus({
48+
status: "warning",
49+
authStatus: "unknown",
50+
}),
51+
),
52+
).toBe("verify");
53+
});
54+
});
55+
56+
describe("provider auth copy", () => {
57+
it("produces auth-specific headings", () => {
58+
expect(
59+
getProviderStatusHeading(
60+
makeStatus({
61+
provider: "claudeAgent",
62+
status: "error",
63+
authStatus: "unauthenticated",
64+
}),
65+
),
66+
).toBe("Anthropic (Claude Code) needs authentication");
67+
});
68+
69+
it("preserves explicit provider detail messages", () => {
70+
expect(
71+
getProviderStatusDescription(
72+
makeStatus({
73+
status: "warning",
74+
authStatus: "unknown",
75+
message: "Codex CLI authentication status command is unavailable in this Codex version.",
76+
}),
77+
),
78+
).toBe("Codex CLI authentication status command is unavailable in this Codex version.");
79+
});
80+
});
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { type ServerProviderStatus } from "@okcode/contracts";
2+
3+
export type ProviderSetupPhase = "install" | "authenticate" | "verify" | "ready";
4+
5+
const PROVIDER_LABELS = {
6+
codex: "OpenAI (Codex CLI)",
7+
claudeAgent: "Anthropic (Claude Code)",
8+
} as const;
9+
10+
export function getProviderLabel(provider: ServerProviderStatus["provider"]): string {
11+
return PROVIDER_LABELS[provider] ?? provider;
12+
}
13+
14+
export function getProviderSetupPhase(status: ServerProviderStatus): ProviderSetupPhase {
15+
if (!status.available) {
16+
return "install";
17+
}
18+
if (status.authStatus === "unauthenticated") {
19+
return "authenticate";
20+
}
21+
if (status.status === "ready") {
22+
return "ready";
23+
}
24+
return "verify";
25+
}
26+
27+
export function getProviderStatusHeading(status: ServerProviderStatus): string {
28+
const label = getProviderLabel(status.provider);
29+
const phase = getProviderSetupPhase(status);
30+
31+
switch (phase) {
32+
case "install":
33+
return `${label} is not installed`;
34+
case "authenticate":
35+
return `${label} needs authentication`;
36+
case "verify":
37+
return `${label} needs verification`;
38+
case "ready":
39+
return `${label} is ready`;
40+
}
41+
}
42+
43+
export function getProviderStatusDescription(status: ServerProviderStatus): string {
44+
if (status.message) {
45+
return status.message;
46+
}
47+
48+
const label = getProviderLabel(status.provider);
49+
const phase = getProviderSetupPhase(status);
50+
51+
switch (phase) {
52+
case "install":
53+
return `Install ${label} to use this provider.`;
54+
case "authenticate":
55+
return `Authenticate ${label} before starting or resuming turns.`;
56+
case "verify":
57+
return `Verify ${label} setup before continuing.`;
58+
case "ready":
59+
return `${label} is ready.`;
60+
}
61+
}

apps/web/src/lib/customTheme.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -225,13 +225,13 @@ export function parseTweakcnJSON(json: unknown): CustomThemeData {
225225
}
226226

227227
const light = filterSupported({
228-
...(cssVars.theme ?? {}),
229-
...(cssVars.light ?? {}),
228+
...cssVars.theme,
229+
...cssVars.light,
230230
});
231231

232232
const dark = filterSupported({
233-
...(cssVars.theme ?? {}),
234-
...(cssVars.dark ?? {}),
233+
...cssVars.theme,
234+
...cssVars.dark,
235235
});
236236

237237
if (Object.keys(light).length === 0 && Object.keys(dark).length === 0) {

bun.lock

Lines changed: 2 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"catalog": {
1212
"effect": "https://pkg.pr.new/Effect-TS/effect-smol/effect@8881a9b",
1313
"@effect/platform-node": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@8881a9b",
14+
"@effect/platform-node-shared": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node-shared@8881a9b606d84a6f5eb6615279138322984f5368",
1415
"@effect/sql-sqlite-bun": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/sql-sqlite-bun@8881a9b",
1516
"@effect/vitest": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/vitest@8881a9b",
1617
"@effect/language-service": "0.75.1",
@@ -66,6 +67,7 @@
6667
"vitest": "catalog:"
6768
},
6869
"overrides": {
70+
"effect": "https://pkg.pr.new/Effect-TS/effect-smol/effect@8881a9b",
6971
"vite": "^8.0.0"
7072
},
7173
"lint-staged": {

0 commit comments

Comments
 (0)