authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-03 19:34:50-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-03 21:03:16-08:00
log99e44f2c23462f9ccf80a9d199bb424ae130bf33
treed638de85335c576c32eaa76b2109d1ce8e7b1ad7
parent7a0f2d9b3058d7c7da4d49fd31e308c98f2e5e2a

Add Codex provider health status to server config and chat banner

- add startup ProviderHealth service/layer to probe Codex install and auth state - extend server contracts and WS payloads to include provider statuses - show non-ready provider warnings in ChatView and cover with server/web tests

9 files changed, 499 insertions(+), 4 deletions(-)

apps/server/src/main.ts+2
......@@ -21,6 +21,7 @@ import { fixPath, resolveStateDir } from "./os-jank";
2121import { Open } from "./open";
2222import * as SqlitePersistence from "./persistence/Layers/Sqlite";
2323import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers";
24import { ProviderHealthLive } from "./provider/Layers/ProviderHealth";
2425import { Server } from "./wsServer";
2526
2627export class StartupError extends Data.TaggedError("StartupError")<{
......@@ -197,6 +198,7 @@ const LayerLive = (input: CliInput) =>
197198 Layer.empty.pipe(
198199 Layer.provideMerge(makeServerRuntimeServicesLayer()),
199200 Layer.provideMerge(makeServerProviderLayer()),
201 Layer.provideMerge(ProviderHealthLive),
200202 Layer.provideMerge(SqlitePersistence.layerConfig),
201203 Layer.provideMerge(ServerConfigLive(input)),
202204 );
apps/server/src/provider/Layers/ProviderHealth.test.ts created+107
......@@ -0,0 +1,107 @@
1import { describe, expect, it } from "vitest";
2
3import type { ProcessRunResult } from "../../processRunner";
4import { checkProviderStatusesOnStartup } from "./ProviderHealth";
5
6function result(input: Partial<ProcessRunResult>): ProcessRunResult {
7 return {
8 stdout: "",
9 stderr: "",
10 code: 0,
11 signal: null,
12 timedOut: false,
13 stdoutTruncated: false,
14 stderrTruncated: false,
15 ...input,
16 };
17}
18
19describe("checkProviderStatusesOnStartup", () => {
20 it("returns ready when codex is installed and authenticated", async () => {
21 const status = await checkProviderStatusesOnStartup(async (_command, args) => {
22 if (args.join(" ") === "--version") {
23 return result({ stdout: "codex 1.0.0\n" });
24 }
25 if (args.join(" ") === "auth status --json") {
26 return result({ stdout: '{"authenticated":true}\n' });
27 }
28 throw new Error(`Unexpected command: ${args.join(" ")}`);
29 });
30
31 expect(status).toEqual([
32 expect.objectContaining({
33 provider: "codex",
34 status: "ready",
35 available: true,
36 authStatus: "authenticated",
37 }),
38 ]);
39 });
40
41 it("returns unavailable when codex is missing", async () => {
42 const status = await checkProviderStatusesOnStartup(async () => {
43 throw new Error("Command not found: codex");
44 });
45
46 expect(status).toEqual([
47 expect.objectContaining({
48 provider: "codex",
49 status: "error",
50 available: false,
51 authStatus: "unknown",
52 message: "Codex CLI (`codex`) is not installed or not on PATH.",
53 }),
54 ]);
55 });
56
57 it("returns unauthenticated when auth probe reports login required", async () => {
58 const status = await checkProviderStatusesOnStartup(async (_command, args) => {
59 if (args.join(" ") === "--version") {
60 return result({ stdout: "codex 1.0.0\n" });
61 }
62 if (args.join(" ") === "auth status --json") {
63 return result({
64 code: 1,
65 stderr: "Not logged in. Run codex login.",
66 });
67 }
68 throw new Error(`Unexpected command: ${args.join(" ")}`);
69 });
70
71 expect(status).toEqual([
72 expect.objectContaining({
73 provider: "codex",
74 status: "error",
75 available: true,
76 authStatus: "unauthenticated",
77 message: "Codex CLI is not authenticated. Run `codex login` and try again.",
78 }),
79 ]);
80 });
81
82 it("returns warning when auth status command is unsupported", async () => {
83 const status = await checkProviderStatusesOnStartup(async (_command, args) => {
84 if (args.join(" ") === "--version") {
85 return result({ stdout: "codex 1.0.0\n" });
86 }
87 if (args.join(" ") === "auth status --json") {
88 return result({
89 code: 2,
90 stderr: "error: unknown command 'auth'",
91 });
92 }
93 throw new Error(`Unexpected command: ${args.join(" ")}`);
94 });
95
96 expect(status).toEqual([
97 expect.objectContaining({
98 provider: "codex",
99 status: "warning",
100 available: true,
101 authStatus: "unknown",
102 message:
103 "Codex CLI authentication status command is unavailable in this Codex version.",
104 }),
105 ]);
106 });
107});
apps/server/src/provider/Layers/ProviderHealth.ts created+246
......@@ -0,0 +1,246 @@
1/**
2 * ProviderHealthLive - Startup-time provider health checks.
3 *
4 * Performs one-time provider readiness probes when the server starts and
5 * keeps the resulting snapshot in memory for `server.getConfig`.
6 *
7 * @module ProviderHealthLive
8 */
9import type {
10 ServerProviderAuthStatus,
11 ServerProviderStatus,
12 ServerProviderStatusState,
13} from "@t3tools/contracts";
14import { Effect, Layer } from "effect";
15
16import { type ProcessRunOptions, type ProcessRunResult, runProcess } from "../../processRunner";
17import { ProviderHealth, type ProviderHealthShape } from "../Services/ProviderHealth";
18
19type CommandRunner = (
20 command: string,
21 args: readonly string[],
22 options?: ProcessRunOptions,
23) => Promise<ProcessRunResult>;
24
25const DEFAULT_TIMEOUT_MS = 4_000;
26const CODEX_PROVIDER = "codex" as const;
27
28function nonEmptyMessage(value: string | undefined): string | undefined {
29 if (!value) return undefined;
30 const trimmed = value.trim();
31 return trimmed.length > 0 ? trimmed : undefined;
32}
33
34function isCommandMissingError(error: unknown): boolean {
35 if (!(error instanceof Error)) return false;
36 const lower = error.message.toLowerCase();
37 return (
38 lower.includes("command not found: codex") ||
39 lower.includes("spawn codex enoent") ||
40 lower.includes("enoent")
41 );
42}
43
44function detailFromResult(result: ProcessRunResult): string | undefined {
45 if (result.timedOut) return "Timed out while running command.";
46 const stderr = nonEmptyMessage(result.stderr);
47 if (stderr) return stderr;
48 const stdout = nonEmptyMessage(result.stdout);
49 if (stdout) return stdout;
50 if (result.code !== null && result.code !== 0) {
51 return `Command exited with code ${result.code}.`;
52 }
53 return undefined;
54}
55
56function extractAuthBoolean(value: unknown): boolean | undefined {
57 if (!value || typeof value !== "object" || Array.isArray(value)) {
58 return undefined;
59 }
60
61 const record = value as Record<string, unknown>;
62 const directCandidates = [
63 "authenticated",
64 "isAuthenticated",
65 "loggedIn",
66 "isLoggedIn",
67 ] as const;
68 for (const key of directCandidates) {
69 const candidate = record[key];
70 if (typeof candidate === "boolean") {
71 return candidate;
72 }
73 }
74
75 const nestedCandidates = ["auth", "status", "session", "account"] as const;
76 for (const key of nestedCandidates) {
77 const nested = extractAuthBoolean(record[key]);
78 if (nested !== undefined) {
79 return nested;
80 }
81 }
82
83 return undefined;
84}
85
86function parseAuthStatusFromOutput(
87 result: ProcessRunResult,
88): {
89 readonly status: ServerProviderStatusState;
90 readonly authStatus: ServerProviderAuthStatus;
91 readonly message?: string;
92} {
93 const lowerOutput = `${result.stdout}\n${result.stderr}`.toLowerCase();
94 const unknownCommand =
95 lowerOutput.includes("unknown command") ||
96 lowerOutput.includes("unrecognized command") ||
97 lowerOutput.includes("unexpected argument");
98 if (unknownCommand) {
99 return {
100 status: "warning",
101 authStatus: "unknown",
102 message:
103 "Codex CLI authentication status command is unavailable in this Codex version.",
104 };
105 }
106
107 const unauthenticatedSignal =
108 lowerOutput.includes("not logged in") ||
109 lowerOutput.includes("login required") ||
110 lowerOutput.includes("authentication required") ||
111 lowerOutput.includes("run `codex login`") ||
112 lowerOutput.includes("run codex login");
113 if (unauthenticatedSignal) {
114 return {
115 status: "error",
116 authStatus: "unauthenticated",
117 message: "Codex CLI is not authenticated. Run `codex login` and try again.",
118 };
119 }
120
121 const parsedAuth =
122 (() => {
123 const trimmed = result.stdout.trim();
124 if (!trimmed || (!trimmed.startsWith("{") && !trimmed.startsWith("["))) {
125 return undefined;
126 }
127 try {
128 return extractAuthBoolean(JSON.parse(trimmed));
129 } catch {
130 return undefined;
131 }
132 })() ?? undefined;
133
134 if (parsedAuth === true) {
135 return { status: "ready", authStatus: "authenticated" };
136 }
137 if (parsedAuth === false) {
138 return {
139 status: "error",
140 authStatus: "unauthenticated",
141 message: "Codex CLI is not authenticated. Run `codex login` and try again.",
142 };
143 }
144
145 if (result.code === 0) {
146 // Successful command with no explicit auth marker is still a pass.
147 return { status: "ready", authStatus: "authenticated" };
148 }
149
150 const detail = detailFromResult(result);
151 return {
152 status: "warning",
153 authStatus: "unknown",
154 ...(detail
155 ? { message: `Could not verify Codex authentication status. ${detail}` }
156 : { message: "Could not verify Codex authentication status." }),
157 };
158}
159
160async function checkCodexProviderStatus(run: CommandRunner): Promise<ServerProviderStatus> {
161 const checkedAt = new Date().toISOString();
162
163 try {
164 const version = await run("codex", ["--version"], {
165 timeoutMs: DEFAULT_TIMEOUT_MS,
166 allowNonZeroExit: true,
167 outputMode: "truncate",
168 });
169
170 if (version.code !== 0 || version.timedOut) {
171 const detail = detailFromResult(version);
172 return {
173 provider: CODEX_PROVIDER,
174 status: "error",
175 available: false,
176 authStatus: "unknown",
177 checkedAt,
178 message:
179 detail !== undefined
180 ? `Codex CLI is installed but failed to run. ${detail}`
181 : "Codex CLI is installed but failed to run.",
182 };
183 }
184 } catch (error) {
185 return {
186 provider: CODEX_PROVIDER,
187 status: "error",
188 available: false,
189 authStatus: "unknown",
190 checkedAt,
191 message: isCommandMissingError(error)
192 ? "Codex CLI (`codex`) is not installed or not on PATH."
193 : `Failed to execute Codex CLI health check: ${error instanceof Error ? error.message : String(error)}.`,
194 };
195 }
196
197 try {
198 const auth = await run("codex", ["auth", "status", "--json"], {
199 timeoutMs: DEFAULT_TIMEOUT_MS,
200 allowNonZeroExit: true,
201 outputMode: "truncate",
202 });
203 const parsed = parseAuthStatusFromOutput(auth);
204 return {
205 provider: CODEX_PROVIDER,
206 status: parsed.status,
207 available: true,
208 authStatus: parsed.authStatus,
209 checkedAt,
210 ...(parsed.message ? { message: parsed.message } : {}),
211 };
212 } catch (error) {
213 return {
214 provider: CODEX_PROVIDER,
215 status: "warning",
216 available: true,
217 authStatus: "unknown",
218 checkedAt,
219 message:
220 error instanceof Error
221 ? `Could not verify Codex authentication status: ${error.message}.`
222 : "Could not verify Codex authentication status.",
223 };
224 }
225}
226
227export async function checkProviderStatusesOnStartup(
228 run: CommandRunner = runProcess,
229): Promise<ReadonlyArray<ServerProviderStatus>> {
230 return [await checkCodexProviderStatus(run)];
231}
232
233const makeProviderHealth = (run: CommandRunner = runProcess) =>
234 Effect.gen(function* () {
235 const statuses = yield* Effect.promise(() => checkProviderStatusesOnStartup(run));
236
237 return {
238 getStatuses: Effect.succeed(statuses),
239 } satisfies ProviderHealthShape;
240 });
241
242export const ProviderHealthLive = Layer.effect(ProviderHealth, makeProviderHealth());
243
244export function makeProviderHealthLive(run?: CommandRunner) {
245 return Layer.effect(ProviderHealth, makeProviderHealth(run));
246}
apps/server/src/provider/Services/ProviderHealth.ts created+22
......@@ -0,0 +1,22 @@
1/**
2 * ProviderHealth - Provider readiness snapshot service.
3 *
4 * Owns startup-time provider health checks (install/auth reachability) and
5 * exposes the cached results to transport layers.
6 *
7 * @module ProviderHealth
8 */
9import type { ServerProviderStatus } from "@t3tools/contracts";
10import { ServiceMap } from "effect";
11import type { Effect } from "effect";
12
13export interface ProviderHealthShape {
14 /**
15 * Read provider health statuses computed at server startup.
16 */
17 readonly getStatuses: Effect.Effect<ReadonlyArray<ServerProviderStatus>>;
18}
19
20export class ProviderHealth extends ServiceMap.Service<ProviderHealth, ProviderHealthShape>()(
21 "t3/provider/Services/ProviderHealth",
22) {}
apps/server/src/wsServer.test.ts+31-1
......@@ -25,6 +25,7 @@ import {
2525 WS_METHODS,
2626 type WebSocketResponse,
2727 type ProviderRuntimeEvent,
28 type ServerProviderStatus,
2829 type KeybindingsConfig,
2930 type ResolvedKeybindingsConfig,
3031 type WsPush,
......@@ -43,6 +44,7 @@ import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/
4344import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "./persistence/Layers/Sqlite";
4445import { SqlClient, SqlError } from "effect/unstable/sql";
4546import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService";
47import { ProviderHealth, type ProviderHealthShape } from "./provider/Services/ProviderHealth";
4648import { Open, type OpenShape } from "./open";
4749import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts";
4850import type { GitCoreShape } from "./git/Services/GitCore.ts";
......@@ -69,6 +71,20 @@ const defaultOpenService: OpenShape = {
6971 openInEditor: () => Effect.void,
7072};
7173
74const defaultProviderStatuses: ReadonlyArray<ServerProviderStatus> = [
75 {
76 provider: "codex",
77 status: "ready",
78 available: true,
79 authStatus: "authenticated",
80 checkedAt: "2026-01-01T00:00:00.000Z",
81 },
82];
83
84const defaultProviderHealthService: ProviderHealthShape = {
85 getStatuses: Effect.succeed(defaultProviderStatuses),
86};
87
7288class MockTerminalManager implements TerminalManagerShape {
7389 private readonly sessions = new Map<string, TerminalSessionSnapshot>();
7490 private readonly listeners = new Set<(event: TerminalEvent) => void>();
......@@ -377,6 +393,7 @@ describe("WebSocket Server", () => {
377393 stateDir?: string;
378394 staticDir?: string;
379395 providerLayer?: Layer.Layer<ProviderService, never>;
396 providerHealth?: ProviderHealthShape;
380397 open?: OpenShape;
381398 gitManager?: GitManagerShape;
382399 gitCore?: Pick<
......@@ -394,6 +411,10 @@ describe("WebSocket Server", () => {
394411 const scope = await Effect.runPromise(Scope.make("sequential"));
395412 const persistenceLayer = options.persistenceLayer ?? SqlitePersistenceMemory;
396413 const providerLayer = options.providerLayer ?? makeServerProviderLayer();
414 const providerHealthLayer = Layer.succeed(
415 ProviderHealth,
416 options.providerHealth ?? defaultProviderHealthService,
417 );
397418 const openLayer = Layer.succeed(Open, options.open ?? defaultOpenService);
398419 const serverConfigLayer = Layer.succeed(ServerConfig, {
399420 mode: "web",
......@@ -428,6 +449,7 @@ describe("WebSocket Server", () => {
428449 );
429450 const dependenciesLayer = Layer.empty.pipe(
430451 Layer.provideMerge(runtimeLayer),
452 Layer.provideMerge(providerHealthLayer),
431453 Layer.provideMerge(openLayer),
432454 Layer.provideMerge(serverConfigLayer),
433455 Layer.provideMerge(NodeServices.layer),
......@@ -736,6 +758,7 @@ describe("WebSocket Server", () => {
736758 keybindingsConfigPath: keybindingsPath,
737759 keybindings: DEFAULT_RESOLVED_KEYBINDINGS,
738760 issues: [],
761 providers: defaultProviderStatuses,
739762 availableEditors: expect.any(Array),
740763 });
741764 expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors);
......@@ -761,6 +784,7 @@ describe("WebSocket Server", () => {
761784 keybindingsConfigPath: keybindingsPath,
762785 keybindings: DEFAULT_RESOLVED_KEYBINDINGS,
763786 issues: [],
787 providers: defaultProviderStatuses,
764788 availableEditors: expect.any(Array),
765789 });
766790 expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors);
......@@ -796,6 +820,7 @@ describe("WebSocket Server", () => {
796820 message: expect.stringContaining("expected JSON array"),
797821 },
798822 ],
823 providers: defaultProviderStatuses,
799824 availableEditors: expect.any(Array),
800825 });
801826 expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors);
......@@ -830,6 +855,7 @@ describe("WebSocket Server", () => {
830855 keybindingsConfigPath: string;
831856 keybindings: ResolvedKeybindingsConfig;
832857 issues: Array<{ kind: string; index?: number; message: string }>;
858 providers: ReadonlyArray<ServerProviderStatus>;
833859 availableEditors: unknown;
834860 };
835861 expect(result.cwd).toBe("/my/workspace");
......@@ -849,6 +875,7 @@ describe("WebSocket Server", () => {
849875 expect(result.keybindings).toHaveLength(DEFAULT_RESOLVED_KEYBINDINGS.length);
850876 expect(result.keybindings.some((entry) => entry.command === "terminal.toggle")).toBe(true);
851877 expect(result.keybindings.some((entry) => entry.command === "terminal.new")).toBe(true);
878 expect(result.providers).toEqual(defaultProviderStatuses);
852879 expectAvailableEditors(result.availableEditors);
853880 });
854881
......@@ -877,6 +904,7 @@ describe("WebSocket Server", () => {
877904 );
878905 expect(malformedPush.data).toEqual({
879906 issues: [{ kind: "keybindings.malformed-config", message: expect.any(String) }],
907 providers: defaultProviderStatuses,
880908 });
881909
882910 fs.writeFileSync(keybindingsPath, "[]", "utf8");
......@@ -887,7 +915,7 @@ describe("WebSocket Server", () => {
887915 Array.isArray((push.data as { issues?: unknown[] }).issues) &&
888916 (push.data as { issues: unknown[] }).issues.length === 0,
889917 );
890 expect(successPush.data).toEqual({ issues: [] });
918 expect(successPush.data).toEqual({ issues: [], providers: defaultProviderStatuses });
891919 });
892920
893921 it("routes shell.openInEditor through the injected open service", async () => {
......@@ -947,6 +975,7 @@ describe("WebSocket Server", () => {
947975 keybindingsConfigPath: keybindingsPath,
948976 keybindings: compileKeybindings(persistedConfig),
949977 issues: [],
978 providers: defaultProviderStatuses,
950979 availableEditors: expect.any(Array),
951980 });
952981 expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors);
......@@ -994,6 +1023,7 @@ describe("WebSocket Server", () => {
9941023 keybindingsConfigPath: keybindingsPath,
9951024 keybindings: compileKeybindings(persistedConfig),
9961025 issues: [],
1026 providers: defaultProviderStatuses,
9971027 availableEditors: expect.any(Array),
9981028 });
9991029 expectAvailableEditors(
apps/server/src/wsServer.ts+11-2
......@@ -52,6 +52,7 @@ import { OrchestrationEngineService } from "./orchestration/Services/Orchestrati
5252import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery";
5353import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor";
5454import { ProviderService } from "./provider/Services/ProviderService";
55import { ProviderHealth } from "./provider/Services/ProviderHealth";
5556import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery";
5657import { clamp } from "effect/Number";
5758import { Open, resolveAvailableEditors } from "./open";
......@@ -154,7 +155,8 @@ export type ServerCoreRuntimeServices =
154155 | ProjectionSnapshotQuery
155156 | CheckpointDiffQuery
156157 | OrchestrationReactor
157 | ProviderService;
158 | ProviderService
159 | ProviderHealth;
158160
159161export type ServerRuntimeServices =
160162 | ServerCoreRuntimeServices
......@@ -198,6 +200,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
198200 const gitManager = yield* GitManager;
199201 const terminalManager = yield* TerminalManager;
200202 const keybindingsManager = yield* Keybindings;
203 const providerHealth = yield* ProviderHealth;
201204 const git = yield* GitCore;
202205 const fileSystem = yield* FileSystem.FileSystem;
203206 const path = yield* Path.Path;
......@@ -212,6 +215,8 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
212215 ),
213216 );
214217
218 const providerStatuses = yield* providerHealth.getStatuses;
219
215220 const clients = yield* Ref.make(new Set<WebSocket>());
216221 const logger = createLogger("ws");
217222
......@@ -537,7 +542,10 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
537542 broadcastPush({
538543 type: "push",
539544 channel: WS_CHANNELS.serverConfigUpdated,
540 data: event,
545 data: {
546 issues: event.issues,
547 providers: providerStatuses,
548 },
541549 }),
542550 ).pipe(Effect.forkIn(subscriptionsScope));
543551
......@@ -735,6 +743,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
735743 keybindingsConfigPath,
736744 keybindings: keybindingsConfig.keybindings,
737745 issues: keybindingsConfig.issues,
746 providers: providerStatuses,
738747 availableEditors,
739748 };
740749
apps/web/src/components/ChatView.tsx+38
......@@ -15,6 +15,7 @@ import {
1515 type ReasoningEffort,
1616 type ResolvedKeybindingsConfig,
1717 type ProviderApprovalDecision,
18 type ServerProviderStatus,
1819 type ThreadId,
1920 type TurnId,
2021 normalizeModelSlug,
......@@ -148,6 +149,7 @@ const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];
148149const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
149150const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = [];
150151const EMPTY_AVAILABLE_EDITORS: EditorId[] = [];
152const EMPTY_PROVIDER_STATUSES: ServerProviderStatus[] = [];
151153const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120;
152154const SCRIPT_TERMINAL_COLS = 120;
153155const SCRIPT_TERMINAL_ROWS = 30;
......@@ -936,6 +938,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
936938 );
937939 const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS;
938940 const availableEditors = serverConfigQuery.data?.availableEditors ?? EMPTY_AVAILABLE_EDITORS;
941 const providerStatuses = serverConfigQuery.data?.providers ?? EMPTY_PROVIDER_STATUSES;
942 const activeProvider = activeThread?.session?.provider ?? "codex";
943 const activeProviderStatus = useMemo(
944 () => providerStatuses.find((status) => status.provider === activeProvider) ?? null,
945 [activeProvider, providerStatuses],
946 );
939947 const activeProjectCwd = activeProject?.cwd ?? null;
940948 const activeThreadWorktreePath = activeThread?.worktreePath ?? null;
941949 const threadTerminalRuntimeEnv = useMemo(() => {
......@@ -2497,6 +2505,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
24972505 </header>
24982506
24992507 {/* Error banner */}
2508 <ProviderHealthBanner status={activeProviderStatus} />
25002509 <ThreadErrorBanner error={activeThread.error} />
25012510 <PendingApprovalsPanel
25022511 pendingApprovals={pendingApprovals}
......@@ -2997,6 +3006,35 @@ const ThreadErrorBanner = memo(function ThreadErrorBanner({ error }: { error: st
29973006 );
29983007});
29993008
3009const ProviderHealthBanner = memo(function ProviderHealthBanner({
3010 status,
3011}: {
3012 status: ServerProviderStatus | null;
3013}) {
3014 if (!status || status.status === "ready") {
3015 return null;
3016 }
3017
3018 const defaultMessage =
3019 status.status === "error"
3020 ? `${status.provider} provider is unavailable.`
3021 : `${status.provider} provider has limited availability.`;
3022
3023 return (
3024 <div className="pt-3 mx-auto max-w-3xl">
3025 <Alert variant={status.status === "error" ? "error" : "warning"}>
3026 <CircleAlertIcon />
3027 <AlertTitle>
3028 {status.provider === "codex" ? "Codex provider status" : `${status.provider} status`}
3029 </AlertTitle>
3030 <AlertDescription className="line-clamp-3" title={status.message ?? defaultMessage}>
3031 {status.message ?? defaultMessage}
3032 </AlertDescription>
3033 </Alert>
3034 </div>
3035 );
3036});
3037
30003038interface PendingApprovalsPanelProps {
30013039 pendingApprovals: PendingApproval[];
30023040 respondingRequestIds: ApprovalRequestId[];
apps/web/src/wsNativeApi.test.ts+15
......@@ -6,6 +6,7 @@ import {
66 ProjectId,
77 ThreadId,
88 WS_CHANNELS,
9 type ServerProviderStatus,
910} from "@t3tools/contracts";
1011import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
1112
......@@ -62,6 +63,16 @@ function getWindowForTest(): Window & typeof globalThis & { desktopBridge?: unkn
6263 return testGlobal.window;
6364}
6465
66const defaultProviders: ReadonlyArray<ServerProviderStatus> = [
67 {
68 provider: "codex",
69 status: "ready",
70 available: true,
71 authStatus: "authenticated",
72 checkedAt: "2026-01-01T00:00:00.000Z",
73 },
74];
75
6576beforeEach(() => {
6677 vi.resetModules();
6778 requestMock.mockReset();
......@@ -162,6 +173,7 @@ describe("wsNativeApi", () => {
162173 message: "Entry at index 1 is invalid.",
163174 },
164175 ],
176 providers: defaultProviders,
165177 } as const;
166178 emitPush(WS_CHANNELS.serverConfigUpdated, payload);
167179
......@@ -185,14 +197,17 @@ describe("wsNativeApi", () => {
185197
186198 emitPush(WS_CHANNELS.serverConfigUpdated, {
187199 issues: [{ kind: "keybindings.invalid-entry", message: "missing index" }],
200 providers: defaultProviders,
188201 });
189202 emitPush(WS_CHANNELS.serverConfigUpdated, {
190203 issues: [{ kind: "keybindings.malformed-config", message: "bad json" }],
204 providers: defaultProviders,
191205 });
192206
193207 expect(listener).toHaveBeenCalledTimes(1);
194208 expect(listener).toHaveBeenCalledWith({
195209 issues: [{ kind: "keybindings.malformed-config", message: "bad json" }],
210 providers: defaultProviders,
196211 });
197212 expect(warnSpy).toHaveBeenCalledTimes(1);
198213 });
packages/contracts/src/server.ts+27-1
......@@ -1,7 +1,8 @@
11import { Schema } from "effect";
2import { TrimmedNonEmptyString } from "./baseSchemas";
2import { IsoDateTime, TrimmedNonEmptyString } from "./baseSchemas";
33import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings";
44import { EditorId } from "./editor";
5import { ProviderKind } from "./orchestration";
56
67export const KeybindingsMalformedConfigIssue = Schema.Struct({
78 kind: Schema.Literal("keybindings.malformed-config"),
......@@ -25,11 +26,35 @@ export type ServerConfigIssue = typeof ServerConfigIssue.Type;
2526export const ServerConfigIssues = Schema.Array(ServerConfigIssue);
2627export type ServerConfigIssues = typeof ServerConfigIssues.Type;
2728
29export const ServerProviderStatusState = Schema.Literals(["ready", "warning", "error"]);
30export type ServerProviderStatusState = typeof ServerProviderStatusState.Type;
31
32export const ServerProviderAuthStatus = Schema.Literals([
33 "authenticated",
34 "unauthenticated",
35 "unknown",
36]);
37export type ServerProviderAuthStatus = typeof ServerProviderAuthStatus.Type;
38
39export const ServerProviderStatus = Schema.Struct({
40 provider: ProviderKind,
41 status: ServerProviderStatusState,
42 available: Schema.Boolean,
43 authStatus: ServerProviderAuthStatus,
44 checkedAt: IsoDateTime,
45 message: Schema.optional(TrimmedNonEmptyString),
46});
47export type ServerProviderStatus = typeof ServerProviderStatus.Type;
48
49export const ServerProviderStatuses = Schema.Array(ServerProviderStatus);
50export type ServerProviderStatuses = typeof ServerProviderStatuses.Type;
51
2852export const ServerConfig = Schema.Struct({
2953 cwd: TrimmedNonEmptyString,
3054 keybindingsConfigPath: TrimmedNonEmptyString,
3155 keybindings: ResolvedKeybindingsConfig,
3256 issues: ServerConfigIssues,
57 providers: ServerProviderStatuses,
3358 availableEditors: Schema.Array(EditorId),
3459});
3560export type ServerConfig = typeof ServerConfig.Type;
......@@ -45,5 +70,6 @@ export type ServerUpsertKeybindingResult = typeof ServerUpsertKeybindingResult.T
4570
4671export const ServerConfigUpdatedPayload = Schema.Struct({
4772 issues: ServerConfigIssues,
73 providers: ServerProviderStatuses,
4874});
4975export type ServerConfigUpdatedPayload = typeof ServerConfigUpdatedPayload.Type;