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
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";

import type { OrchestrationReadModel, ProviderRuntimeEvent } from "@t3tools/contracts";
import type { OrchestrationReadModel, ProviderKind, ProviderRuntimeEvent } from "@t3tools/contracts";
import {
ApprovalRequestId,
CommandId,
Expand Down Expand Up @@ -45,7 +45,7 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
type LegacyProviderRuntimeEvent = {
readonly type: string;
readonly eventId: EventId;
readonly provider: "codex";
readonly provider: ProviderKind;
readonly createdAt: string;
readonly threadId: ThreadId;
readonly turnId?: string | undefined;
Expand Down
22 changes: 22 additions & 0 deletions apps/web/src/appSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export type TimestampFormat = (typeof TIMESTAMP_FORMAT_OPTIONS)[number];
export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale";
const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record<ProviderKind, ReadonlySet<string>> = {
codex: new Set(getModelOptions("codex").map((option) => option.slug)),
claudeCode: new Set(getModelOptions("claudeCode").map((option) => option.slug)),
};

const AppSettingsSchema = Schema.Struct({
Expand All @@ -21,6 +22,27 @@ const AppSettingsSchema = Schema.Struct({
codexHomePath: Schema.String.check(Schema.isMaxLength(4096)).pipe(
Schema.withConstructorDefault(() => Option.some("")),
),
claudeCodeBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(
Schema.withConstructorDefault(() => Option.some("")),
),
claudeCodeUseBedrock: Schema.Boolean.pipe(
Schema.withConstructorDefault(() => Option.some(false)),
),
claudeCodeAwsRegion: Schema.String.check(Schema.isMaxLength(64)).pipe(
Schema.withConstructorDefault(() => Option.some("")),
),
claudeCodeAwsProfile: Schema.String.check(Schema.isMaxLength(256)).pipe(
Schema.withConstructorDefault(() => Option.some("")),
),
claudeCodeBedrockArnHaiku: Schema.String.check(Schema.isMaxLength(2048)).pipe(
Schema.withConstructorDefault(() => Option.some("")),
),
claudeCodeBedrockArnSonnet: Schema.String.check(Schema.isMaxLength(2048)).pipe(
Schema.withConstructorDefault(() => Option.some("")),
),
claudeCodeBedrockArnOpus: Schema.String.check(Schema.isMaxLength(2048)).pipe(
Schema.withConstructorDefault(() => Option.some("")),
),
defaultThreadEnvMode: Schema.Literals(["local", "worktree"]).pipe(
Schema.withConstructorDefault(() => Option.some("local")),
),
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,5 +121,6 @@ export function getCustomModelOptionsByProvider(settings: {
}): Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>> {
return {
codex: getAppModelOptions("codex", settings.customCodexModels),
claudeCode: getAppModelOptions("claudeCode", []),
};
}
35 changes: 20 additions & 15 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ import {
hasToolActivityForTurn,
isLatestTurnSettled,
formatElapsed,
isAvailableProviderOption,
resolveProviderOptions,
} from "../session-logic";
import { isScrollContainerNearBottom } from "../chat-scroll";
import {
Expand Down Expand Up @@ -134,7 +136,7 @@ import { PullRequestThreadDialog } from "./PullRequestThreadDialog";
import { MessagesTimeline } from "./chat/MessagesTimeline";
import { ChatHeader } from "./chat/ChatHeader";
import { buildExpandedImagePreview, ExpandedImagePreview } from "./chat/ExpandedImagePreview";
import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./chat/ProviderModelPicker";
import { ProviderModelPicker } from "./chat/ProviderModelPicker";
import { ComposerCommandItem, ComposerCommandMenu } from "./chat/ComposerCommandMenu";
import { ComposerPendingApprovalActions } from "./chat/ComposerPendingApprovalActions";
import { CodexTraitsPicker } from "./chat/CodexTraitsPicker";
Expand Down Expand Up @@ -548,22 +550,25 @@ export default function ChatView({ threadId }: ChatViewProps) {
? selectedModelForPicker
: (normalizeModelSlug(selectedModelForPicker, selectedProvider) ?? selectedModelForPicker);
}, [modelOptionsByProvider, selectedModelForPicker, selectedProvider]);
const claudeCodeConfigured =
settings.claudeCodeBinaryPath.trim() !== "" || settings.claudeCodeUseBedrock;
const searchableModelOptions = useMemo(
() =>
AVAILABLE_PROVIDER_OPTIONS.filter(
(option) => lockedProvider === null || option.value === lockedProvider,
).flatMap((option) =>
modelOptionsByProvider[option.value].map(({ slug, name }) => ({
provider: option.value,
providerLabel: option.label,
slug,
name,
searchSlug: slug.toLowerCase(),
searchName: name.toLowerCase(),
searchProvider: option.label.toLowerCase(),
})),
),
[lockedProvider, modelOptionsByProvider],
resolveProviderOptions(claudeCodeConfigured)
.filter(isAvailableProviderOption)
.filter((option) => lockedProvider === null || option.value === lockedProvider)
.flatMap((option) =>
modelOptionsByProvider[option.value].map(({ slug, name }) => ({
provider: option.value,
providerLabel: option.label,
slug,
name,
searchSlug: slug.toLowerCase(),
searchName: name.toLowerCase(),
searchProvider: option.label.toLowerCase(),
})),
),
[claudeCodeConfigured, lockedProvider, modelOptionsByProvider],
);
const phase = derivePhase(activeThread?.session ?? null);
const isSendBusy = sendPhase !== "idle";
Expand Down
34 changes: 18 additions & 16 deletions apps/web/src/components/chat/ProviderModelPicker.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { type ModelSlug, type ProviderKind } from "@t3tools/contracts";
import { normalizeModelSlug } from "@t3tools/shared/model";
import { memo, useState } from "react";
import { type ProviderPickerKind, PROVIDER_OPTIONS } from "../../session-logic";
import {
type ProviderPickerKind,
isAvailableProviderOption,
resolveProviderOptions,
} from "../../session-logic";
import { useAppSettings } from "../../appSettings";
import { ChevronDownIcon } from "lucide-react";
import { Button } from "../ui/button";
import {
Expand All @@ -20,14 +25,6 @@ import {
import { ClaudeAI, CursorIcon, Gemini, Icon, OpenAI, OpenCodeIcon } from "../Icons";
import { cn } from "~/lib/utils";

function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is {
value: ProviderKind;
label: string;
available: true;
} {
return option.available && option.value !== "claudeCode";
}

function resolveModelForProviderPicker(
provider: ProviderKind,
value: string,
Expand Down Expand Up @@ -67,8 +64,7 @@ const PROVIDER_ICON_BY_PROVIDER: Record<ProviderPickerKind, Icon> = {
cursor: CursorIcon,
};

export const AVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter(isAvailableProviderOption);
const UNAVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter((option) => !option.available);
// Derived at runtime from settings — see ProviderModelPicker component below.
const COMING_SOON_PROVIDER_OPTIONS = [
{ id: "opencode", label: "OpenCode", icon: OpenCodeIcon },
{ id: "gemini", label: "Gemini", icon: Gemini },
Expand All @@ -84,6 +80,12 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void;
}) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const { settings } = useAppSettings();
const claudeCodeConfigured =
settings.claudeCodeBinaryPath.trim() !== "" || settings.claudeCodeUseBedrock;
const providerOptions = resolveProviderOptions(claudeCodeConfigured);
const availableProviderOptions = providerOptions.filter(isAvailableProviderOption);
const unavailableProviderOptions = providerOptions.filter((option) => !option.available);
const selectedProviderOptions = props.modelOptionsByProvider[props.provider];
const selectedModelLabel =
selectedProviderOptions.find((option) => option.slug === props.model)?.name ?? props.model;
Expand Down Expand Up @@ -122,7 +124,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
</span>
</MenuTrigger>
<MenuPopup align="start">
{AVAILABLE_PROVIDER_OPTIONS.map((option) => {
{availableProviderOptions.map((option) => {
const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value];
const isDisabledByProviderLock =
props.lockedProvider !== null && props.lockedProvider !== option.value;
Expand Down Expand Up @@ -168,8 +170,8 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
</MenuSub>
);
})}
{UNAVAILABLE_PROVIDER_OPTIONS.length > 0 && <MenuDivider />}
{UNAVAILABLE_PROVIDER_OPTIONS.map((option) => {
{unavailableProviderOptions.length > 0 && <MenuDivider />}
{unavailableProviderOptions.map((option) => {
const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value];
return (
<MenuItem key={option.value} disabled>
Expand All @@ -182,12 +184,12 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
/>
<span>{option.label}</span>
<span className="ms-auto text-[11px] text-muted-foreground/80 uppercase tracking-[0.08em]">
Coming soon
{option.value === "claudeCode" ? "Configure in Settings" : "Coming soon"}
</span>
</MenuItem>
);
})}
{UNAVAILABLE_PROVIDER_OPTIONS.length === 0 && <MenuDivider />}
{unavailableProviderOptions.length === 0 && <MenuDivider />}
{COMING_SOON_PROVIDER_OPTIONS.map((option) => {
const OptionIcon = option.icon;
return (
Expand Down
157 changes: 157 additions & 0 deletions apps/web/src/routes/_chat.settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ function SettingsRouteView() {
Record<ProviderKind, string>
>({
codex: "",
claudeCode: "",
});
const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState<
Partial<Record<ProviderKind, string | null>>
Expand Down Expand Up @@ -369,6 +370,162 @@ function SettingsRouteView() {
</div>
</section>

<section className="rounded-2xl border border-border bg-card p-5">
<div className="mb-4">
<h2 className="text-sm font-medium text-foreground">Claude Code</h2>
<p className="mt-1 text-xs text-muted-foreground">
Configure Claude Code for use with AWS Bedrock. Once enabled, Claude Code will
appear as an available provider in the chat model picker.
</p>
</div>

<div className="space-y-4">
<label htmlFor="claude-code-binary-path" className="block space-y-1">
<span className="text-xs font-medium text-foreground">
Claude Code binary path
</span>
<Input
id="claude-code-binary-path"
value={settings.claudeCodeBinaryPath}
onChange={(event) =>
updateSettings({ claudeCodeBinaryPath: event.target.value })
}
placeholder="claude"
spellCheck={false}
/>
<span className="text-xs text-muted-foreground">
Leave blank to use <code>claude</code> from your PATH.
</span>
</label>

<div className="flex items-center justify-between rounded-lg border border-border bg-background px-3 py-2">
<div>
<p className="text-sm font-medium text-foreground">Use AWS Bedrock</p>
<p className="text-xs text-muted-foreground">
Route Claude Code requests through your AWS account via{" "}
<code>CLAUDE_CODE_USE_BEDROCK</code>. Requires AWS credentials and Bedrock
model access.
</p>
</div>
<Switch
checked={settings.claudeCodeUseBedrock}
onCheckedChange={(checked) =>
updateSettings({ claudeCodeUseBedrock: Boolean(checked) })
}
aria-label="Use AWS Bedrock for Claude Code"
/>
</div>

{settings.claudeCodeUseBedrock && (
<div className="space-y-4 rounded-xl border border-border bg-background/50 p-4">
<label htmlFor="claude-code-aws-region" className="block space-y-1">
<span className="text-xs font-medium text-foreground">AWS region</span>
<Input
id="claude-code-aws-region"
value={settings.claudeCodeAwsRegion}
onChange={(event) =>
updateSettings({ claudeCodeAwsRegion: event.target.value })
}
placeholder="us-east-1"
spellCheck={false}
/>
<span className="text-xs text-muted-foreground">
The AWS region where your Bedrock models are available.
</span>
</label>

<label htmlFor="claude-code-aws-profile" className="block space-y-1">
<span className="text-xs font-medium text-foreground">
AWS profile{" "}
<span className="font-normal text-muted-foreground">(optional)</span>
</span>
<Input
id="claude-code-aws-profile"
value={settings.claudeCodeAwsProfile}
onChange={(event) =>
updateSettings({ claudeCodeAwsProfile: event.target.value })
}
placeholder="default"
spellCheck={false}
/>
<span className="text-xs text-muted-foreground">
Named AWS profile from <code>~/.aws/credentials</code>. Leave blank to use
the default credential chain.
</span>
</label>

<div className="space-y-1">
<p className="text-xs font-medium text-foreground">
Model ARN overrides{" "}
<span className="font-normal text-muted-foreground">(optional)</span>
</p>
<p className="text-xs text-muted-foreground">
Override the default Bedrock model IDs with specific inference profile ARNs.
Leave blank to use the default model IDs.
</p>
</div>

{(
[
{
id: "claude-code-bedrock-arn-haiku",
label: "Haiku ARN",
settingKey: "claudeCodeBedrockArnHaiku",
placeholder: "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-haiku-...",
},
{
id: "claude-code-bedrock-arn-sonnet",
label: "Sonnet ARN",
settingKey: "claudeCodeBedrockArnSonnet",
placeholder: "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-...",
},
{
id: "claude-code-bedrock-arn-opus",
label: "Opus ARN",
settingKey: "claudeCodeBedrockArnOpus",
placeholder: "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-opus-...",
},
] as const
).map((field) => (
<label key={field.id} htmlFor={field.id} className="block space-y-1">
<span className="text-xs font-medium text-foreground">{field.label}</span>
<Input
id={field.id}
value={settings[field.settingKey]}
onChange={(event) =>
updateSettings({ [field.settingKey]: event.target.value })
}
placeholder={field.placeholder}
spellCheck={false}
className="font-mono text-xs"
/>
</label>
))}
</div>
)}

<div className="flex justify-end">
<Button
size="xs"
variant="outline"
onClick={() =>
updateSettings({
claudeCodeBinaryPath: defaults.claudeCodeBinaryPath,
claudeCodeUseBedrock: defaults.claudeCodeUseBedrock,
claudeCodeAwsRegion: defaults.claudeCodeAwsRegion,
claudeCodeAwsProfile: defaults.claudeCodeAwsProfile,
claudeCodeBedrockArnHaiku: defaults.claudeCodeBedrockArnHaiku,
claudeCodeBedrockArnSonnet: defaults.claudeCodeBedrockArnSonnet,
claudeCodeBedrockArnOpus: defaults.claudeCodeBedrockArnOpus,
})
}
>
Reset Claude Code overrides
</Button>
</div>
</div>
</section>

<section className="rounded-2xl border border-border bg-card p-5">
<div className="mb-4">
<h2 className="text-sm font-medium text-foreground">Models</h2>
Expand Down
Loading