Skip to content
Open
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
24 changes: 24 additions & 0 deletions apps/web/src/appSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
AppSettingsSchema,
DEFAULT_TIMESTAMP_FORMAT,
getAppModelOptions,
getCodexProviderOverrides,
normalizeCustomModelSlugs,
resolveAppModelSelection,
} from "./appSettings";
Expand Down Expand Up @@ -113,3 +114,26 @@ describe("AppSettingsSchema", () => {
});
});
});

describe("getCodexProviderOverrides", () => {
it("returns undefined when both overrides are blank", () => {
expect(
getCodexProviderOverrides({
codexBinaryPath: " ",
codexHomePath: "",
}),
).toBeUndefined();
});

it("returns trimmed override values", () => {
expect(
getCodexProviderOverrides({
codexBinaryPath: " /usr/local/bin/codex ",
codexHomePath: " /Users/test/.codex ",
}),
).toEqual({
binaryPath: "/usr/local/bin/codex",
homePath: "/Users/test/.codex",
});
});
});
29 changes: 28 additions & 1 deletion apps/web/src/appSettings.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { useCallback } from "react";
import { Option, Schema } from "effect";
import { TrimmedNonEmptyString, type ProviderKind } from "@t3tools/contracts";
import {
TrimmedNonEmptyString,
type ProviderKind,
type ProviderStartOptions,
} from "@t3tools/contracts";
import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model";
import { useLocalStorage } from "./hooks/useLocalStorage";
import { EnvMode } from "./components/BranchToolbar.logic";
Expand Down Expand Up @@ -87,6 +91,29 @@ function normalizeAppSettings(settings: AppSettings): AppSettings {
customClaudeModels: normalizeCustomModelSlugs(settings.customClaudeModels, "claudeAgent"),
};
}

function trimToUndefined(value: string | null | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed || undefined;
}

export function getCodexProviderOverrides(settings: {
codexBinaryPath: AppSettings["codexBinaryPath"];
codexHomePath: AppSettings["codexHomePath"];
}): NonNullable<ProviderStartOptions["codex"]> | undefined {
const binaryPath = trimToUndefined(settings.codexBinaryPath);
const homePath = trimToUndefined(settings.codexHomePath);

if (!binaryPath && !homePath) {
return undefined;
}

return {
...(binaryPath ? { binaryPath } : {}),
...(homePath ? { homePath } : {}),
};
}

export function getAppModelOptions(
provider: ProviderKind,
customModels: readonly string[],
Expand Down
50 changes: 48 additions & 2 deletions apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,23 @@
import { ThreadId } from "@t3tools/contracts";
import { ThreadId, type ServerProviderStatus } from "@t3tools/contracts";
import { describe, expect, it } from "vitest";

import { buildExpiredTerminalContextToastCopy, deriveComposerSendState } from "./ChatView.logic";
import {
buildExpiredTerminalContextToastCopy,
deriveComposerSendState,
resolveProviderHealthBannerStatus,
} from "./ChatView.logic";

function makeProviderStatus(overrides: Partial<ServerProviderStatus> = {}): ServerProviderStatus {
return {
provider: "codex",
status: "error",
available: false,
authStatus: "unknown",
checkedAt: "2026-03-20T00:00:00.000Z",
message: "Codex CLI (`codex`) is not installed or not on PATH.",
...overrides,
};
}

describe("deriveComposerSendState", () => {
it("treats expired terminal pills as non-sendable content", () => {
Expand Down Expand Up @@ -67,3 +83,33 @@ describe("buildExpiredTerminalContextToastCopy", () => {
});
});
});

describe("resolveProviderHealthBannerStatus", () => {
it("keeps the server status when no local Codex overrides are configured", () => {
const status = makeProviderStatus();

expect(resolveProviderHealthBannerStatus(status, false)).toEqual(status);
});

it("hides Codex status when a custom binary path is configured", () => {
expect(resolveProviderHealthBannerStatus(makeProviderStatus(), true)).toBeNull();
});

it("hides Codex status when a custom CODEX_HOME is configured", () => {
expect(
resolveProviderHealthBannerStatus(
makeProviderStatus({ status: "warning", available: true }),
true,
),
).toBeNull();
});

it("keeps non-Codex provider status visible even with Codex overrides", () => {
const status = makeProviderStatus({
provider: "claudeAgent",
message: "Claude Agent CLI (`claude`) is not installed or not on PATH.",
});

expect(resolveProviderHealthBannerStatus(status, true)).toEqual(status);
});
});
14 changes: 13 additions & 1 deletion apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { ProjectId, type ProviderKind, type ThreadId } from "@t3tools/contracts";
import {
ProjectId,
type ProviderKind,
type ServerProviderStatus,
type ThreadId,
} from "@t3tools/contracts";
import { type ChatMessage, type Thread } from "../types";
import { randomUUID } from "~/lib/utils";
import { getAppModelOptions } from "../appSettings";
Expand Down Expand Up @@ -131,6 +136,13 @@ export function getCustomModelOptionsByProvider(settings: {
};
}

export function resolveProviderHealthBannerStatus(
status: ServerProviderStatus | null,
hasCodexOverrides: boolean,
): ServerProviderStatus | null {
return status?.provider === "codex" && hasCodexOverrides ? null : status;
}

export function deriveComposerSendState(options: {
prompt: string;
imageCount: number;
Expand Down
43 changes: 27 additions & 16 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,11 @@ import {
import { SidebarTrigger } from "./ui/sidebar";
import { newCommandId, newMessageId, newThreadId } from "~/lib/utils";
import { readNativeApi } from "~/nativeApi";
import { resolveAppModelSelection, useAppSettings } from "../appSettings";
import {
getCodexProviderOverrides,
resolveAppModelSelection,
useAppSettings,
} from "../appSettings";
import { isTerminalFocused } from "../lib/terminalFocus";
import {
type ComposerImageAttachment,
Expand Down Expand Up @@ -173,6 +177,7 @@ import {
LastInvokedScriptByProjectSchema,
PullRequestDialogState,
readFileAsDataUrl,
resolveProviderHealthBannerStatus,
revokeBlobPreviewUrl,
revokeUserMessagePreviewUrls,
SendPhase,
Expand Down Expand Up @@ -662,17 +667,18 @@ export default function ChatView({ threadId }: ChatViewProps) {
}
return undefined;
}, [draftModelOptions, selectedModel, selectedProvider]);
const providerOptionsForDispatch = useMemo(() => {
if (!settings.codexBinaryPath && !settings.codexHomePath) {
return undefined;
}
return {
codex: {
...(settings.codexBinaryPath ? { binaryPath: settings.codexBinaryPath } : {}),
...(settings.codexHomePath ? { homePath: settings.codexHomePath } : {}),
},
};
}, [settings.codexBinaryPath, settings.codexHomePath]);
const codexProviderOverrides = useMemo(
() =>
getCodexProviderOverrides({
codexBinaryPath: settings.codexBinaryPath,
codexHomePath: settings.codexHomePath,
}),
[settings.codexBinaryPath, settings.codexHomePath],
);
const providerOptionsForDispatch = useMemo(
() => (codexProviderOverrides ? { codex: codexProviderOverrides } : undefined),
[codexProviderOverrides],
);
const selectedModelForPicker = selectedModel;
const modelOptionsByProvider = useMemo(
() => getCustomModelOptionsByProvider(settings),
Expand Down Expand Up @@ -1150,9 +1156,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
const availableEditors = serverConfigQuery.data?.availableEditors ?? EMPTY_AVAILABLE_EDITORS;
const providerStatuses = serverConfigQuery.data?.providers ?? EMPTY_PROVIDER_STATUSES;
const activeProvider = activeThread?.session?.provider ?? "codex";
const activeProviderStatus = useMemo(
() => providerStatuses.find((status) => status.provider === activeProvider) ?? null,
[activeProvider, providerStatuses],
const hasCodexProviderOverrides = codexProviderOverrides !== undefined;
const activeProviderBannerStatus = useMemo(
() =>
resolveProviderHealthBannerStatus(
providerStatuses.find((status) => status.provider === activeProvider) ?? null,
hasCodexProviderOverrides,
),
[activeProvider, hasCodexProviderOverrides, providerStatuses],
);
const activeProjectCwd = activeProject?.cwd ?? null;
const activeThreadWorktreePath = activeThread?.worktreePath ?? null;
Expand Down Expand Up @@ -3528,7 +3539,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
</header>

{/* Error banner */}
<ProviderHealthBanner status={activeProviderStatus} />
<ProviderHealthBanner status={activeProviderBannerStatus} />
<ThreadErrorBanner
error={activeThread.error}
onDismiss={() => setThreadError(activeThread.id, null)}
Expand Down