diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 0130f416..dea9530c 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -13,7 +13,7 @@ Add exactly one matching GitHub label before merge. ## Release Notes -- Write the changelog line you want if the PR title is not good enough. +- Write a single sentence or bullet to use in the automated changelog if the PR title is not good enough. ## Validation diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 4d34f973..7fbc93c1 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -1,6 +1,9 @@ name: Prepare Release on: + push: + branches: + - main workflow_dispatch: concurrency: @@ -13,6 +16,7 @@ permissions: jobs: prepare: + if: github.event_name != 'push' || !startsWith(github.event.head_commit.message, 'chore(release): ') runs-on: ubuntu-latest timeout-minutes: 20 steps: diff --git a/scripts/release/prepare-release.mjs b/scripts/release/prepare-release.mjs index d8fca2d8..2350f2c0 100644 --- a/scripts/release/prepare-release.mjs +++ b/scripts/release/prepare-release.mjs @@ -117,6 +117,37 @@ function groupPullRequestsByLabel(pullRequests) { return groups; } +function extractReleaseNotesOverride(body) { + if (typeof body !== "string" || !body.trim()) { + return null; + } + + const match = body.match( + /(^|\n)##\s+Release Notes\s*\n([\s\S]*?)(?=\n##\s+|\n#\s+|$)/i, + ); + + if (!match?.[2]) { + return null; + } + + const lines = match[2] + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => line.replace(/^[-*]\s+/, "").trim()) + .filter( + (line) => + line && + line !== "Write the changelog line you want if the PR title is not good enough.", + ); + + if (lines.length === 0) { + return null; + } + + return lines.join(" "); +} + function formatChangelogSection({ pullRequests, repositoryUrl, version }) { const date = new Date().toISOString().slice(0, 10); const groups = groupPullRequestsByLabel(pullRequests); @@ -138,7 +169,7 @@ function formatChangelogSection({ pullRequests, repositoryUrl, version }) { for (const pullRequest of entries) { sections.push( - `- ${pullRequest.title} ([#${pullRequest.number}](${repositoryUrl}/pull/${pullRequest.number}))`, + `- ${pullRequest.changelogLine ?? pullRequest.title} ([#${pullRequest.number}](${repositoryUrl}/pull/${pullRequest.number}))`, ); } @@ -295,6 +326,8 @@ for (const pullRequest of uniquePullRequests) { } pullRequest.releaseLabel = releaseLabels[0]; + pullRequest.changelogLine = + extractReleaseNotesOverride(pullRequest.body) ?? pullRequest.title; } if (invalidPullRequests.length > 0) { diff --git a/src/app/(app)/settings/models/page.tsx b/src/app/(app)/settings/models/page.tsx index f10400a7..03fdbcc2 100644 --- a/src/app/(app)/settings/models/page.tsx +++ b/src/app/(app)/settings/models/page.tsx @@ -87,7 +87,15 @@ function ModelsSkeleton() { export default function ModelsPage() { const { data: models, isPending } = api.models.list.useQuery(); + const enginesQuery = api.engines.list.useQuery(); const utils = api.useUtils(); + const codexEngine = enginesQuery.data?.find( + (engine) => engine.engine === "codex", + ); + const codexStatus = + codexEngine?.engine === "codex" && "status" in codexEngine + ? codexEngine.status + : null; const enable = api.models.enable.useMutation({ onMutate: async ({ modelId, provider }) => { @@ -276,6 +284,140 @@ export default function ModelsPage() { ) : null}
+
+
+
+

+ Codex Runtime +

+

+ Sentinel reads the local Codex CLI state from this machine. No + Codex credentials are stored here. +

+
+ + {codexEngine?.isAvailable ? "Ready" : "Setup needed"} + +
+ +
+
+ + CLI + + + {codexStatus?.cliDetected + ? (codexStatus.cliVersion ?? "Detected") + : "Not detected"} + +
+
+ + Auth + + + {codexStatus?.authReady + ? "Ready" + : codexStatus?.requiresOpenaiAuth + ? "Run Codex login outside Sentinel" + : "Unavailable"} + +
+
+ + Models + + + {codexStatus?.availableModels.length ?? 0} available + +
+
+ + Account + + + {codexStatus?.account?.type === "chatgpt" + ? codexStatus.account.email + : codexStatus?.account?.type === "apiKey" + ? "API key" + : "Not authenticated"} + +
+
+ + {!codexEngine?.isAvailable && codexEngine?.error ? ( +

+ {codexEngine.error} +

+ ) : null} +
+ + {codexStatus?.availableModels && + codexStatus.availableModels.length > 0 && ( +
+
+
+ +
+

+ Codex Models +

+
+ +
+ {codexStatus.availableModels.map((model) => ( +
+
+
+ +
+
+
+ + {model.displayName} + + {model.isDefault && ( + + Default + + )} +
+

+ {model.description} +

+
+ {model.inputModalities.map((modality) => ( + + {modality} + + ))} + {model.supportsPersonality && ( + + Personality + + )} +
+
+
+
+ ))} +
+
+ )} + {grouped.map(([provider, providerModels]) => (
diff --git a/src/app/(app)/skills/[skillName]/page.tsx b/src/app/(app)/skills/[skillName]/page.tsx index 80671920..2c25dd0d 100644 --- a/src/app/(app)/skills/[skillName]/page.tsx +++ b/src/app/(app)/skills/[skillName]/page.tsx @@ -2,10 +2,18 @@ import { SkillDetailScreen } from "@/components/skills/skill-detail-screen"; export default async function SkillDetailPage({ params, + searchParams, }: { params: Promise<{ skillName: string }>; + searchParams: Promise<{ target?: string }>; }) { const { skillName } = await params; + const { target } = await searchParams; - return ; + return ( + + ); } diff --git a/src/app/(app)/skills/page.tsx b/src/app/(app)/skills/page.tsx index e7bc797f..9a162a83 100644 --- a/src/app/(app)/skills/page.tsx +++ b/src/app/(app)/skills/page.tsx @@ -1,6 +1,13 @@ "use client"; -import { Button, Chip, Input, Skeleton, Spinner } from "@heroui/react"; +import { + Button, + Chip, + Dropdown, + Input, + Skeleton, + Spinner, +} from "@heroui/react"; import { AiIdeaIcon, ArrowRight01Icon, @@ -18,7 +25,6 @@ import { PaintBoardIcon, PlusSignIcon, QuillWrite01Icon, - RefreshIcon, ShareKnowledgeIcon, SparklesIcon, TestTube01Icon, @@ -30,6 +36,7 @@ import type { ComponentType, SVGProps } from "react"; import { useCallback, useMemo, useState } from "react"; import { sileo } from "sileo"; +import { type SelectOption } from "@/components/forms/controlled-fields"; import { SettingsPageWrapper } from "@/components/settings/settings-page-wrapper"; import { CustomSkillInstallSidebar } from "@/components/skills/custom-skill-install-sidebar"; import { @@ -44,19 +51,39 @@ import { VercelIcon, WordIcon, } from "@/components/skills/skill-icons"; -import { api, type RouterOutputs } from "@/trpc/react"; import { SidebarToggle, useRightSidebar, useShell } from "@/components/shell"; +import { api, type RouterOutputs } from "@/trpc/react"; type SkillListItem = RouterOutputs["skills"]["list"]["skills"][number]; type RegistryItem = RouterOutputs["skills"]["registry"][number]; -type InstalledSkillAction = Pick; +type SkillInstallTarget = "codex" | "sentinel"; +type InstalledSkillAction = Pick; const SOURCE_LABEL = { agents: "Agents", claude: "Claude", + codex: "Codex", sentinel: "Sentinel", } as const; +const TARGET_LABEL = { + codex: "Codex", + sentinel: "Sentinel", +} as const; + +const TARGET_OPTIONS: SelectOption[] = [ + { + description: "Install into Sentinel's local skill directories.", + label: "Sentinel", + value: "sentinel", + }, + { + description: "Install into the Codex home skills directory.", + label: "Codex", + value: "codex", + }, +] as const; + function formatSkillTitle(name: string) { return name .split(/[\s\-_]+/) @@ -66,7 +93,10 @@ function formatSkillTitle(name: string) { } function matchesSkill( - skill: Pick, + skill: Pick< + SkillListItem, + "description" | "name" | "scope" | "sourceKind" | "target" + >, query: string, ) { if (!query) { @@ -74,7 +104,7 @@ function matchesSkill( } const haystack = - `${skill.name} ${skill.description} ${skill.scope} ${skill.sourceKind}`.toLowerCase(); + `${skill.name} ${skill.description} ${skill.scope} ${skill.sourceKind} ${skill.target}`.toLowerCase(); return haystack.includes(query.toLowerCase()); } @@ -131,7 +161,7 @@ function SkillIcon({ name, size = 24 }: { name: string; size?: number }) { if (entry?.type === "brand") { const BrandComponent = entry.component; - return ; + return ; } const hugeIcon = @@ -154,6 +184,18 @@ function repoLabel(repoUrl: string) { } } +function getInstallStateKey(name: string, target: SkillInstallTarget) { + return `${name}:${target}`; +} + +function getInstalledDetailHref(skill: SkillListItem) { + if (skill.target === "codex") { + return `/skills/${encodeURIComponent(skill.name)}?target=codex`; + } + + return `/skills/${encodeURIComponent(skill.name)}`; +} + function SkillsSkeleton() { return ( <> @@ -186,8 +228,8 @@ function InstalledSkillRow({ return (
@@ -199,13 +241,20 @@ function InstalledSkillRow({ {formatSkillTitle(skill.name)} - {SOURCE_LABEL[skill.sourceKind]} + + {TARGET_LABEL[skill.target]} + */}

{skill.description} @@ -213,13 +262,17 @@ function InstalledSkillRow({

-
+
{onUninstall ? ( ) : null} - +
void; - isInstalling: boolean; + codexAvailable: boolean; + entry: RegistryItem; installError: string | null; + isInstalling: boolean; + onInstall: (entry: RegistryItem, target: SkillInstallTarget) => void; }) { + const installStatusLabel = + entry.installedTargets.codex && entry.installedTargets.sentinel + ? "Installed in Sentinel and Codex" + : entry.installedTargets.codex + ? "Installed in Codex" + : entry.installedTargets.sentinel + ? "Installed in Sentinel" + : null; + const allTargetsInstalled = + entry.installedTargets.codex && entry.installedTargets.sentinel; + return ( -
-
+
+
@@ -277,11 +343,11 @@ function RegistrySkillRow({ {entry.displayName}
-

+

{entry.description}

{repoLabel(entry.repoUrl)} + {installStatusLabel ? ( +

{installStatusLabel}

+ ) : null}
+
-
+
+ -
+ + + onInstall(entry, String(key) as SkillInstallTarget) + } + > + {TARGET_OPTIONS.map((option) => { + const target = option.value as SkillInstallTarget; + const optionInstalled = entry.installedTargets[target]; + const optionDisabled = + optionInstalled || + (target === "codex" && !codexAvailable) || + isInstalling; + + return ( + +
+ + {optionInstalled + ? `${option.label} installed` + : `Install in ${option.label}`} + + + {target === "codex" && !codexAvailable + ? "Codex is currently unavailable." + : option.description} + +
+
+ ); + })} +
+
+
{installError ? ( @@ -342,14 +454,17 @@ export default function SkillsPage() { const skills = api.skills.list.useQuery(undefined, { refetchInterval: 2_000, }); - const registry = api.skills.registry.useQuery(); + const engines = api.engines.list.useQuery(); const installMutation = api.skills.install.useMutation(); const uninstallMutation = api.skills.uninstall.useMutation(); const allSkills = skills.data?.skills ?? []; const registryEntries = registry.data ?? []; + const codexAvailable = Boolean( + engines.data?.find((engine) => engine.engine === "codex")?.isAvailable, + ); const registryByName = useMemo( () => new Map(registryEntries.map((e) => [e.name.trim().toLowerCase(), e])), @@ -362,19 +477,18 @@ export default function SkillsPage() { ); const availableRegistry = useMemo( - () => - registryEntries - .filter((e) => !e.installed) - .filter((e) => matchesRegistrySkill(e, query)), + () => registryEntries.filter((e) => matchesRegistrySkill(e, query)), [registryEntries, query], ); const handleInstall = useCallback( - async (entry: RegistryItem) => { - setInstallingSkills((prev) => new Set(prev).add(entry.name)); + async (entry: RegistryItem, target: SkillInstallTarget) => { + const installKey = getInstallStateKey(entry.name, target); + + setInstallingSkills((prev) => new Set(prev).add(installKey)); setInstallErrors((prev) => { const next = { ...prev }; - delete next[entry.name]; + delete next[installKey]; return next; }); @@ -382,6 +496,7 @@ export default function SkillsPage() { await installMutation.mutateAsync({ name: entry.name, scope: "global", + target, }); void utils.skills.list.invalidate(); void utils.skills.registry.invalidate(); @@ -389,11 +504,11 @@ export default function SkillsPage() { } catch (error) { const message = error instanceof Error ? error.message : "Installation failed."; - setInstallErrors((prev) => ({ ...prev, [entry.name]: message })); + setInstallErrors((prev) => ({ ...prev, [installKey]: message })); } finally { setInstallingSkills((prev) => { const next = new Set(prev); - next.delete(entry.name); + next.delete(installKey); return next; }); } @@ -402,12 +517,12 @@ export default function SkillsPage() { ); const handleUninstall = useCallback( - async ({ name, scope }: InstalledSkillAction) => { - const uninstallKey = `${scope}:${name}`; + async ({ name, scope, target }: InstalledSkillAction) => { + const uninstallKey = `${target}:${scope}:${name}`; setUninstallingSkills((prev) => new Set(prev).add(uninstallKey)); try { - await uninstallMutation.mutateAsync({ name, scope }); + await uninstallMutation.mutateAsync({ name, scope, target }); void utils.skills.list.invalidate(); void utils.skills.registry.invalidate(); sileo.success({ description: "Skill uninstalled." }); @@ -437,59 +552,34 @@ export default function SkillsPage() { const handleOpenInstallSidebar = useCallback(() => { openRightSidebar( - , + , ); - }, [newSkillHref, openRightSidebar]); + }, [codexAvailable, newSkillHref, openRightSidebar]); return ( - {/* */} - - + } subtitle={ - Browse discovered skills and install recommended skills from the - registry. + Browse discovered skills across Sentinel and Codex, and install + recommended skills into either runtime. } title={ @@ -528,9 +618,9 @@ export default function SkillsPage() { return ( @@ -546,13 +636,15 @@ export default function SkillsPage() {

) : ( -
+

No skills installed

Install a recommended skill below, or add one under{" "} - .sentinel/skills. + .sentinel/skills{" "} + or ~/.codex/skills + .

)} @@ -567,27 +659,48 @@ export default function SkillsPage() { {registry.isPending && !registry.data ? ( ) : availableRegistry.length ? ( - availableRegistry.map((entry) => ( - - )) + availableRegistry.map((entry) => { + return ( + + ); + }) ) : registryEntries.length && - registryEntries.every((e) => e.installed) ? ( -
+ registryEntries.every( + (entry) => + entry.installedTargets.codex && + entry.installedTargets.sentinel, + ) ? ( +

All recommended skills installed

- You have installed every skill from the curated registry. + You have installed every skill from the curated registry for + both runtimes.

) : ( -
+

No matching skills

diff --git a/src/components/automations/automation-detail-screen.tsx b/src/components/automations/automation-detail-screen.tsx index 8111bd39..32baf238 100644 --- a/src/components/automations/automation-detail-screen.tsx +++ b/src/components/automations/automation-detail-screen.tsx @@ -35,16 +35,16 @@ import { } from "@/components/forms/controlled-fields"; import { SidebarToggle, useShell } from "@/components/shell"; import { SettingsPageWrapper } from "@/components/settings/settings-page-wrapper"; +import type { ReasoningEffort } from "@/lib/ai/providers/models"; +import { AUTOMATION_SCHEDULE_TYPES, type ChatEngine } from "@/server/db/enums"; import { - getCompositeModelId, - normalizeSelectedModelId, -} from "@/lib/ai/providers/model-selection"; -import { - getDefaultReasoningEffort, - getSupportedReasoningEfforts, - type ReasoningEffort, -} from "@/lib/ai/providers/models"; -import { AUTOMATION_SCHEDULE_TYPES } from "@/server/db/enums"; + getAvailableAutomationModels, + getAutomationEngineOptions, + getAutomationModelOptions, + getAutomationModelsForEngine, + getAutomationReasoningOptions, + resolveAutomationSelection, +} from "@/components/automations/automation-form-helpers"; import { isLikelyCronExpression } from "@/schemas/automation.schema"; import { sileo } from "sileo"; import { api } from "@/trpc/react"; @@ -115,6 +115,7 @@ const editFormSchema = z scheduleDayOfWeek: z.string(), scheduleTime: z.string(), scheduleCron: z.string(), + chatEngine: z.enum(["sentinel", "codex"]), modelId: z.string().trim().min(1, "Model is required."), reasoningEffort: z.string(), }) @@ -216,10 +217,6 @@ function DetailSkeleton() { ); } -function getReasoningEffortLabel(effort: ReasoningEffort) { - return effort.charAt(0).toUpperCase() + effort.slice(1); -} - export function AutomationDetailScreen({ automationId, }: { @@ -239,40 +236,56 @@ export function AutomationDetailScreen({ const updateMutation = api.automations.update.useMutation(); const workspacesQuery = api.workspaces.list.useQuery(); - const modelsQuery = api.models.list.useQuery(); + const enginesQuery = api.engines.list.useQuery(); + const sentinelModelsQuery = api.engines.models.useQuery({ + engine: "sentinel", + }); + const codexModelsQuery = api.engines.models.useQuery({ + engine: "codex", + }); const automation = automationQuery.data ?? null; const statusTone = automation?.status === "active" ? "success" : "warning"; const [submitError, setSubmitError] = useState(""); - const availableModels = useMemo( - () => - (modelsQuery.data ?? []).filter( - (model) => model.isConnected && model.isEnabled, - ), - [modelsQuery.data], + const availableSentinelModels = useMemo( + () => getAvailableAutomationModels(sentinelModelsQuery.data ?? []), + [sentinelModelsQuery.data], + ); + const availableCodexModels = useMemo( + () => getAvailableAutomationModels(codexModelsQuery.data ?? []), + [codexModelsQuery.data], ); const formDefaults = useMemo(() => { if (!automation) return null; - const compositeModel = automation.modelId - ? (normalizeSelectedModelId(automation.modelId, availableModels) ?? - automation.modelId) - : "__default__"; + const engine = (automation.chatEngine ?? "sentinel") as ChatEngine; + const selection = resolveAutomationSelection( + getAutomationModelsForEngine(engine, { + codex: availableCodexModels, + sentinel: availableSentinelModels, + }), + automation.modelId ?? null, + (automation.reasoningEffort as ReasoningEffort | null) ?? null, + ); return { title: automation.title, prompt: automation.prompt, + chatEngine: engine, workspaceId: automation.workspaceId ?? "__current__", scheduleType: automation.scheduleType, scheduleDayOfWeek: String(automation.scheduleDayOfWeek ?? 1), scheduleTime: automation.scheduleTime ?? "09:00", scheduleCron: automation.scheduleCron ?? "", - modelId: compositeModel, - reasoningEffort: automation.reasoningEffort ?? "", + modelId: automation.modelId ?? selection.modelId, + reasoningEffort: + (automation.reasoningEffort as ReasoningEffort | null) ?? + selection.reasoningEffort ?? + "", }; - }, [automation, availableModels]); + }, [automation, availableCodexModels, availableSentinelModels]); const form = useForm({ defaultValues: formDefaults ?? undefined, @@ -300,6 +313,7 @@ export function AutomationDetailScreen({ }, [automation, form, formDefaults, form.formState.isDirty]); const scheduleType = form.watch("scheduleType"); + const selectedEngine = form.watch("chatEngine"); const selectedModelKey = form.watch("modelId"); const workspaceOptions = useMemo(() => { @@ -318,77 +332,87 @@ export function AutomationDetailScreen({ ]; }, [workspacesQuery.data]); + const engineOptions = useMemo( + () => getAutomationEngineOptions(enginesQuery.data ?? []), + [enginesQuery.data], + ); + const availableModels = useMemo( + () => + getAutomationModelsForEngine(selectedEngine, { + codex: availableCodexModels, + sentinel: availableSentinelModels, + }), + [availableCodexModels, availableSentinelModels, selectedEngine], + ); const modelOptions = useMemo(() => { - const options: SelectOption[] = availableModels.map((model) => ({ - description: model.provider, - label: model.displayName, - value: getCompositeModelId(model.provider, model.modelId), - })); - return [ - { - description: "Use default model behavior.", - label: "Use default model", - value: "__default__", - }, - ...options, - ]; - }, [availableModels]); + return getAutomationModelOptions(availableModels, selectedModelKey); + }, [availableModels, selectedModelKey]); const selectedModel = useMemo(() => { if (!selectedModelKey || selectedModelKey === "__default__") return null; return ( - availableModels.find( - (m) => getCompositeModelId(m.provider, m.modelId) === selectedModelKey, - ) ?? null + availableModels.find((model) => model.modelId === selectedModelKey) ?? + null ); }, [availableModels, selectedModelKey]); const supportedReasoningEfforts = useMemo(() => { if (!selectedModel) return []; - return getSupportedReasoningEfforts( - selectedModel.provider, - selectedModel.modelId, - ); + return selectedModel.supportedReasoningEfforts; }, [selectedModel]); const reasoningOptions = useMemo( - () => - supportedReasoningEfforts.map((effort) => ({ - description: "Matches the selected model's supported reasoning levels.", - label: getReasoningEffortLabel(effort), - value: effort, - })), + () => getAutomationReasoningOptions(supportedReasoningEfforts), [supportedReasoningEfforts], ); useEffect(() => { - if (!selectedModel) { - form.setValue("reasoningEffort", ""); + const currentModelKey = form.getValues("modelId"); + if (!currentModelKey || currentModelKey === "__default__") { return; } - const currentEffort = form.getValues("reasoningEffort"); - if ( - currentEffort && - supportedReasoningEfforts.includes(currentEffort as ReasoningEffort) - ) { + + const modelStillSelectable = modelOptions.some( + (option) => option.value === currentModelKey, + ); + if (modelStillSelectable) { return; } - if (!currentEffort && !form.formState.dirtyFields.modelId) return; - const defaultEffort = getDefaultReasoningEffort( - selectedModel.provider, - selectedModel.modelId, + + const nextSelection = resolveAutomationSelection( + availableModels, + null, + null, ); - if (defaultEffort && supportedReasoningEfforts.includes(defaultEffort)) { - form.setValue("reasoningEffort", defaultEffort); + form.setValue("modelId", nextSelection.modelId); + form.setValue("reasoningEffort", nextSelection.reasoningEffort ?? ""); + }, [availableModels, form, modelOptions]); + + useEffect(() => { + if (!selectedModelKey || selectedModelKey === "__default__") { + if (form.getValues("reasoningEffort")) { + form.setValue("reasoningEffort", ""); + } + return; + } + + if (!selectedModel) { + return; + } + + const currentEffort = form.getValues("reasoningEffort"); + const nextReasoningEffort = resolveAutomationSelection( + [selectedModel], + selectedModel.modelId, + (currentEffort as ReasoningEffort | null) ?? null, + ).reasoningEffort; + + if ((currentEffort || "") === (nextReasoningEffort ?? "")) { return; } - form.setValue("reasoningEffort", supportedReasoningEfforts[0] ?? ""); - }, [ - form, - form.formState.dirtyFields.modelId, - selectedModel, - supportedReasoningEfforts, - ]); + + form.setValue("reasoningEffort", nextReasoningEffort ?? ""); + }, [form, selectedModel, selectedModelKey]); const handleSave = useCallback( async (values: EditFormValues) => { @@ -418,6 +442,7 @@ export function AutomationDetailScreen({ id: automation.id, title: values.title, prompt: values.prompt, + chatEngine: values.chatEngine, workspaceId: values.workspaceId === "__current__" ? null : values.workspaceId, scheduleType: values.scheduleType, @@ -871,6 +896,14 @@ export function AutomationDetailScreen({ options={workspaceOptions} /> + + ({ + description: engine.description, + isDisabled: !engine.isAvailable, + label: engine.label, + value: engine.engine, + })); +} + +export function getAvailableAutomationModels( + models: AutomationEngineModel[], +): AutomationEngineModel[] { + return models.filter((model) => model.isConnected && model.isEnabled); +} + +export function getAutomationModelsForEngine( + engine: ChatEngine, + queries: { + codex: AutomationEngineModel[]; + sentinel: AutomationEngineModel[]; + }, +) { + return engine === "codex" ? queries.codex : queries.sentinel; +} + +export function getAutomationModelOptions( + models: AutomationEngineModel[], + selectedModelId?: string | null, +): SelectOption[] { + const options: SelectOption[] = [ + { + description: "Use default model behavior.", + label: "Use default model", + value: "__default__", + }, + ...models.map((model) => ({ + description: + model.provider ?? + (model.engine === "codex" ? "Codex runtime" : "Built-in model"), + label: model.displayName, + value: model.modelId, + })), + ]; + + if ( + selectedModelId && + selectedModelId !== "__default__" && + !options.some((option) => option.value === selectedModelId) + ) { + options.push({ + description: "Currently saved model is unavailable.", + isDisabled: true, + label: selectedModelId, + value: selectedModelId, + }); + } + + return options; +} + +export function getAutomationReasoningOptions( + efforts: ReasoningEffort[], +): SelectOption[] { + return efforts.map((effort) => ({ + description: "Matches the selected model's supported reasoning levels.", + label: getReasoningEffortLabel(effort), + value: effort, + })); +} + +export function resolveAutomationSelection( + models: AutomationEngineModel[], + preferredModelId?: string | null, + preferredReasoningEffort?: ReasoningEffort | null, +) { + const selectedModel = + models.find((model) => model.modelId === preferredModelId) ?? + models[0] ?? + null; + + return { + modelId: selectedModel?.modelId ?? "__default__", + reasoningEffort: selectedModel + ? resolveReasoningEffort(selectedModel, preferredReasoningEffort) + : null, + }; +} diff --git a/src/components/automations/new-automation-modal.tsx b/src/components/automations/new-automation-modal.tsx index 0abe56b1..3622443d 100644 --- a/src/components/automations/new-automation-modal.tsx +++ b/src/components/automations/new-automation-modal.tsx @@ -19,23 +19,22 @@ import { z } from "zod"; import { ControlledSelectField, - type SelectOption, ControlledTextAreaField, ControlledTextField, } from "@/components/forms/controlled-fields"; import { getErrorMessage } from "@/lib/errors"; import { sileo } from "sileo"; -import { AUTOMATION_SCHEDULE_TYPES } from "@/server/db/enums"; -import { - getDefaultReasoningEffort, - getSupportedReasoningEfforts, - type ReasoningEffort, -} from "@/lib/ai/providers/models"; -import { - getCompositeModelId, - normalizeSelectedModelId, -} from "@/lib/ai/providers/model-selection"; +import type { ReasoningEffort } from "@/lib/ai/providers/models"; +import { AUTOMATION_SCHEDULE_TYPES, type ChatEngine } from "@/server/db/enums"; import type { AutomationTemplate } from "@/components/automations/automation-templates"; +import { + getAvailableAutomationModels, + getAutomationEngineOptions, + getAutomationModelOptions, + getAutomationModelsForEngine, + getAutomationReasoningOptions, + resolveAutomationSelection, +} from "@/components/automations/automation-form-helpers"; import { createAutomationSchema, type CreateAutomationInput, @@ -56,6 +55,7 @@ const automationFormSchema = z scheduleDayOfWeek: z.string(), scheduleTime: z.string(), scheduleCron: z.string(), + chatEngine: z.enum(["sentinel", "codex"]), modelId: z.string().trim().min(1, "Model is required."), reasoningEffort: z.string(), }) @@ -145,10 +145,12 @@ const DAY_OPTIONS = [ function createDefaultValues( template?: AutomationTemplate, globalDefaults?: { + chatEngine?: ChatEngine | null; modelId?: string | null; reasoningEffort?: ReasoningEffort | null; }, ): AutomationFormValues { + const defaultChatEngine = globalDefaults?.chatEngine ?? "sentinel"; const defaultModelId = globalDefaults?.modelId ?? template?.defaults.modelId ?? "__default__"; const defaultReasoningEffort = globalDefaults?.reasoningEffort ?? ""; @@ -162,6 +164,7 @@ function createDefaultValues( scheduleDayOfWeek: String(template.defaults.scheduleDayOfWeek ?? 1), scheduleTime: template.defaults.scheduleTime ?? "09:00", scheduleCron: template.defaults.scheduleCron ?? "", + chatEngine: defaultChatEngine, modelId: defaultModelId, reasoningEffort: defaultReasoningEffort, }; @@ -175,6 +178,7 @@ function createDefaultValues( scheduleDayOfWeek: "1", scheduleTime: "09:00", scheduleCron: "", + chatEngine: defaultChatEngine, modelId: defaultModelId, reasoningEffort: defaultReasoningEffort, }; @@ -204,6 +208,7 @@ function normalizeCreateInput( return { title: values.title, prompt: values.prompt, + chatEngine: values.chatEngine, workspaceId: values.workspaceId === "__current__" ? null : values.workspaceId, scheduleType: values.scheduleType, @@ -222,10 +227,6 @@ function parseTimeString(value: string | null | undefined): Time | null { return new Time(Number(match[1]), Number(match[2])); } -function getReasoningEffortLabel(effort: ReasoningEffort) { - return effort.charAt(0).toUpperCase() + effort.slice(1); -} - interface NewAutomationModalProps { isOpen: boolean; onOpenChange: (open: boolean) => void; @@ -243,36 +244,74 @@ export function NewAutomationModal({ const [submitError, setSubmitError] = useState(""); const workspacesQuery = api.workspaces.list.useQuery(); - const modelsQuery = api.models.list.useQuery(); + const enginesQuery = api.engines.list.useQuery(); + const sentinelModelsQuery = api.engines.models.useQuery({ + engine: "sentinel", + }); + const codexModelsQuery = api.engines.models.useQuery({ + engine: "codex", + }); const chatPreferencesQuery = api.chatPreferences.get.useQuery(); const createMutation = api.automations.create.useMutation(); - const availableModels = useMemo( - () => - (modelsQuery.data ?? []).filter( - (model) => model.isConnected && model.isEnabled, - ), - [modelsQuery.data], + const availableSentinelModels = useMemo( + () => getAvailableAutomationModels(sentinelModelsQuery.data ?? []), + [sentinelModelsQuery.data], ); - const normalizedGlobalModelId = useMemo( - () => - normalizeSelectedModelId( - chatPreferencesQuery.data?.modelId ?? null, - availableModels, - ), - [availableModels, chatPreferencesQuery.data?.modelId], + const availableCodexModels = useMemo( + () => getAvailableAutomationModels(codexModelsQuery.data ?? []), + [codexModelsQuery.data], ); + const globalDefaults = useMemo(() => { + const preferredEngine = chatPreferencesQuery.data?.engine ?? "sentinel"; + const preferredReasoningEffort = + (chatPreferencesQuery.data?.reasoningEffort as ReasoningEffort | null) ?? + null; + const preferredModelId = chatPreferencesQuery.data?.modelId ?? null; + + const preferredModels = getAutomationModelsForEngine(preferredEngine, { + codex: availableCodexModels, + sentinel: availableSentinelModels, + }); + + const fallbackEngine: ChatEngine = "sentinel"; + const engine = + preferredModels.length > 0 || preferredEngine === fallbackEngine + ? preferredEngine + : fallbackEngine; + const models = getAutomationModelsForEngine(engine, { + codex: availableCodexModels, + sentinel: availableSentinelModels, + }); + const selection = resolveAutomationSelection( + models, + engine === preferredEngine ? preferredModelId : null, + preferredReasoningEffort, + ); + + return { + chatEngine: engine, + modelId: selection.modelId, + reasoningEffort: selection.reasoningEffort, + }; + }, [ + availableCodexModels, + availableSentinelModels, + chatPreferencesQuery.data?.engine, + chatPreferencesQuery.data?.modelId, + chatPreferencesQuery.data?.reasoningEffort, + ]); const initialValues = useMemo( () => createDefaultValues(template, { - modelId: normalizedGlobalModelId, - reasoningEffort: - (chatPreferencesQuery.data - ?.reasoningEffort as ReasoningEffort | null) ?? null, + chatEngine: globalDefaults.chatEngine, + modelId: globalDefaults.modelId, + reasoningEffort: globalDefaults.reasoningEffort, }), [ - chatPreferencesQuery.data?.reasoningEffort, - normalizedGlobalModelId, + globalDefaults.chatEngine, + globalDefaults.modelId, + globalDefaults.reasoningEffort, template, ], ); @@ -285,6 +324,7 @@ export function NewAutomationModal({ }); const scheduleType = form.watch("scheduleType"); + const selectedEngine = form.watch("chatEngine"); const selectedModelKey = form.watch("modelId"); useEffect(() => { @@ -323,93 +363,96 @@ export function NewAutomationModal({ ]; }, [workspacesQuery.data]); + const engineOptions = useMemo( + () => getAutomationEngineOptions(enginesQuery.data ?? []), + [enginesQuery.data], + ); + const availableModels = useMemo( + () => + getAutomationModelsForEngine(selectedEngine, { + codex: availableCodexModels, + sentinel: availableSentinelModels, + }), + [availableCodexModels, availableSentinelModels, selectedEngine], + ); const modelOptions = useMemo(() => { - const options: SelectOption[] = availableModels.map((model) => ({ - description: model.provider, - label: model.displayName, - value: getCompositeModelId(model.provider, model.modelId), - })); - - return [ - { - description: "Use default model behavior.", - label: "Use default model", - value: "__default__", - }, - ...options, - ]; - }, [availableModels]); + return getAutomationModelOptions(availableModels, selectedModelKey); + }, [availableModels, selectedModelKey]); const selectedModel = useMemo(() => { if (!selectedModelKey || selectedModelKey === "__default__") return null; return ( - availableModels.find( - (model) => - getCompositeModelId(model.provider, model.modelId) === - selectedModelKey, - ) ?? null + availableModels.find((model) => model.modelId === selectedModelKey) ?? + null ); }, [availableModels, selectedModelKey]); const supportedReasoningEfforts = useMemo(() => { if (!selectedModel) return []; - return getSupportedReasoningEfforts( - selectedModel.provider, - selectedModel.modelId, - ); + return selectedModel.supportedReasoningEfforts; }, [selectedModel]); const reasoningOptions = useMemo( - () => - supportedReasoningEfforts.map((effort) => ({ - description: "Matches the selected model's supported reasoning levels.", - label: getReasoningEffortLabel(effort), - value: effort, - })), + () => getAutomationReasoningOptions(supportedReasoningEfforts), [supportedReasoningEfforts], ); useEffect(() => { - if (!selectedModel) { - form.setValue("reasoningEffort", ""); + const currentModelKey = form.getValues("modelId"); + if (!currentModelKey || currentModelKey === "__default__") { return; } - const currentEffort = form.getValues("reasoningEffort"); - if ( - currentEffort && - supportedReasoningEfforts.includes(currentEffort as ReasoningEffort) - ) { + const modelStillSelectable = modelOptions.some( + (option) => option.value === currentModelKey, + ); + if (modelStillSelectable) { return; } - if (!currentEffort && !form.formState.dirtyFields.modelId) { + const nextSelection = resolveAutomationSelection( + availableModels, + null, + null, + ); + form.setValue("modelId", nextSelection.modelId); + form.setValue("reasoningEffort", nextSelection.reasoningEffort ?? ""); + }, [availableModels, form, modelOptions]); + + useEffect(() => { + if (!selectedModelKey || selectedModelKey === "__default__") { + if (form.getValues("reasoningEffort")) { + form.setValue("reasoningEffort", ""); + } return; } - const defaultEffort = getDefaultReasoningEffort( - selectedModel.provider, + if (!selectedModel) { + return; + } + + const currentEffort = form.getValues("reasoningEffort"); + const nextReasoningEffort = resolveAutomationSelection( + [selectedModel], selectedModel.modelId, - ); - if (defaultEffort && supportedReasoningEfforts.includes(defaultEffort)) { - form.setValue("reasoningEffort", defaultEffort); + (currentEffort as ReasoningEffort | null) ?? null, + ).reasoningEffort; + + if ((currentEffort || "") === (nextReasoningEffort ?? "")) { return; } - form.setValue("reasoningEffort", supportedReasoningEfforts[0] ?? ""); - }, [ - form, - form.formState.dirtyFields.modelId, - selectedModel, - supportedReasoningEfforts, - ]); + form.setValue("reasoningEffort", nextReasoningEffort ?? ""); + }, [form, selectedModel, selectedModelKey]); const isBusy = form.formState.isSubmitting || createMutation.isPending || chatPreferencesQuery.isPending || workspacesQuery.isLoading || - modelsQuery.isLoading; + enginesQuery.isLoading || + sentinelModelsQuery.isLoading || + codexModelsQuery.isLoading; const handleCreate = async (values: AutomationFormValues) => { setSubmitError(""); @@ -503,6 +546,14 @@ export function NewAutomationModal({ options={SCHEDULE_OPTIONS} /> + + {scheduleType === "weekly" ? (
{ + it("renders attachment chips without any model capability warning copy", () => { + const markup = renderToStaticMarkup( + {}} + onPreviewClose={() => {}} + onPreviewOpen={() => {}} + onRemoveAttachment={() => {}} + previewAttachment={null} + />, + ); + + expect(markup).toContain("spec.pdf"); + expect(markup).not.toContain("may not support"); + }); + + it("still renders attachment errors", () => { + const markup = renderToStaticMarkup( + {}} + onPreviewClose={() => {}} + onPreviewOpen={() => {}} + onRemoveAttachment={() => {}} + previewAttachment={null} + />, + ); + + expect(markup).toContain("Unable to attach one or more selected files."); + }); +}); diff --git a/src/components/chat/attachment-manager.tsx b/src/components/chat/attachment-manager.tsx index 16658523..dcd6cb30 100644 --- a/src/components/chat/attachment-manager.tsx +++ b/src/components/chat/attachment-manager.tsx @@ -10,7 +10,6 @@ import { ImagePreviewModal } from "./image-preview-modal"; type AttachmentManagerProps = { attachmentError: string; - attachmentWarning: string; attachments: ComposerAttachment[]; fileInputRef: RefObject; onFileInputChange: (event: ChangeEvent) => void; @@ -22,7 +21,6 @@ type AttachmentManagerProps = { export function AttachmentManager({ attachmentError, - attachmentWarning, attachments, fileInputRef, onFileInputChange, @@ -63,12 +61,6 @@ export function AttachmentManager({
) : null} - {attachmentWarning && !attachmentError ? ( -
-

{attachmentWarning}

-
- ) : null} - {previewAttachment?.previewUrl ? ( , -) { - switch (kind) { - case "image": - return capabilities.supportsImages; - case "document": - return capabilities.supportsDocuments; - case "code-text": - return capabilities.supportsCodeTextFiles; - default: - return false; - } + return model.defaultReasoningEffort; } diff --git a/src/components/chat/chat-composer/index.tsx b/src/components/chat/chat-composer/index.tsx index 1d8fbb29..6806f93f 100644 --- a/src/components/chat/chat-composer/index.tsx +++ b/src/components/chat/chat-composer/index.tsx @@ -3,8 +3,6 @@ import { EditorContent } from "@tiptap/react"; import Link from "next/link"; import { useCallback, useEffect, useRef, useState } from "react"; - -import { useOutsideClick } from "@/hooks/use-outside-click"; import { DEFAULT_FOLLOW_UP_BEHAVIOR } from "@/schemas/general-settings.schema"; import { getExactContextWindowUsage } from "@/lib/ai/chat/context-window"; import { api } from "@/trpc/react"; @@ -33,6 +31,7 @@ export function ChatComposer({ onCancelEdit, onQueueFollowUp, onRemoveQueuedFollowUp, + onSelectionChange, onSend, onStop, onSteerFollowUp, @@ -47,8 +46,6 @@ export function ChatComposer({ threadSelection = null, }: ChatComposerProps) { const handleSendRef = useRef<() => void>(() => {}); - const composerMenuRef = useRef(null); - const [composerMenuOpen, setComposerMenuOpen] = useState(false); const utils = api.useUtils(); const hasWorkspace = Boolean(activeWorkspace); @@ -64,6 +61,7 @@ export function ChatComposer({ const { globalSelectionQuery, + persistEngineSelection, persistSelection, updateGlobalSelection, updateThreadSelection, @@ -89,36 +87,37 @@ export function ChatComposer({ } = useAttachments({ attachmentSeed, promptSeedKey }); const { - attachmentWarning, availableModels, + enginesQuery, + handleSelectEngine, handleSelectModel, handleSelectReasoningEffort, - modelMenuOpen, - modelMenuRef, modelsQuery, - reasoningLabel, - reasoningMenuOpen, - reasoningMenuRef, + selectedEngine, + selectedEngineStatus, selectedModel, selectedModelKey, selectedReasoningEffort, - setModelMenuOpen, - setReasoningMenuOpen, supportedReasoningEfforts, threadPersistenceReadyRef, } = useModelSelection({ - attachments, globalSelectionQuery, + onSelectionChange, + persistEngineSelection, persistSelection, selectionScopeKey, threadSelection, }); + const planModeAvailable = true; const { handleTogglePlanMode, planMode } = usePlanMode({ canPersistThreadSelection, draftMode, globalSelectionQuery, + onSelectionChange, + planModeAvailable, persistSelection, + selectedEngine, selectedModelKey, selectedReasoningEffort, selectionScopeKey, @@ -134,6 +133,7 @@ export function ChatComposer({ const { editor, placeholderText } = useComposerEditor({ isBusy, isLocked, + isThread: threadId != null, onAddBrowserFiles: addBrowserFiles, onSendRef: handleSendRef, promptSeed, @@ -157,18 +157,6 @@ export function ChatComposer({ }) : null; - useOutsideClick([ - { onOutsideClick: () => setModelMenuOpen(false), ref: modelMenuRef }, - { - onOutsideClick: () => setReasoningMenuOpen(false), - ref: reasoningMenuRef, - }, - { - onOutsideClick: () => setComposerMenuOpen(false), - ref: composerMenuRef, - }, - ]); - useEffect(() => { if (!canPersistThreadSelection || !threadSelection) { threadPersistenceReadyRef.current = false; @@ -181,6 +169,7 @@ export function ChatComposer({ const persistedReasoningEffort = threadSelection.reasoningEffort ?? null; const selectedMode = planMode ? "plan" : "chat"; if ( + (threadSelection.engine ?? "sentinel") === selectedEngine && threadSelection.modelId === selectedModelKey && persistedReasoningEffort === selectedReasoningEffort && threadSelection.mode === selectedMode @@ -189,13 +178,16 @@ export function ChatComposer({ } persistSelection(selectedModelKey, selectedReasoningEffort, { + engine: selectedEngine, mode: selectedMode, skipGlobal: true, }); }, [ canPersistThreadSelection, planMode, + planModeAvailable, persistSelection, + selectedEngine, selectedModelKey, selectedReasoningEffort, threadPersistenceReadyRef, @@ -216,6 +208,7 @@ export function ChatComposer({ } const messagePayload = { + engine: selectedEngine, ...(files.length > 0 ? { files } : {}), modelId: selectedModelKey, reasoningEffort: selectedReasoningEffort, @@ -250,6 +243,8 @@ export function ChatComposer({ onSend, onSteerFollowUp, planMode, + planModeAvailable, + selectedEngine, selectedModelKey, selectedReasoningEffort, setAttachmentError, @@ -263,21 +258,113 @@ export function ChatComposer({ const disabledMessage = !modelsQuery.isLoading && !hasModels ? ( <> - Connect a provider in{" "} - - Settings - - . + {selectedEngine === "codex" ? ( + (selectedEngineStatus?.error ?? + "Codex is unavailable in this Sentinel runtime.") + ) : ( + <> + Connect a provider in{" "} + + Settings + + . + + )} ) : null; + const engineOptions = + enginesQuery.data?.map((engine) => ({ + engine: engine.engine, + error: engine.error, + isAvailable: engine.isAvailable, + label: engine.label, + })) ?? []; + const showEngineSelector = !canPersistThreadSelection; + + const [isDraggingOver, setIsDraggingOver] = useState(false); + const dragCounterRef = useRef(0); + + useEffect(() => { + if (isLocked) return; + + const handleDragEnter = (e: DragEvent) => { + if (!e.dataTransfer?.types.includes("Files")) return; + e.preventDefault(); + dragCounterRef.current += 1; + if (dragCounterRef.current === 1) setIsDraggingOver(true); + }; + + const handleDragLeave = (e: DragEvent) => { + if (!e.dataTransfer?.types.includes("Files")) return; + e.preventDefault(); + dragCounterRef.current -= 1; + if (dragCounterRef.current <= 0) { + dragCounterRef.current = 0; + setIsDraggingOver(false); + } + }; + + const handleDragOver = (e: DragEvent) => { + if (!e.dataTransfer?.types.includes("Files")) return; + e.preventDefault(); + }; + + const handleDrop = (e: DragEvent) => { + e.preventDefault(); + dragCounterRef.current = 0; + setIsDraggingOver(false); + const files = e.dataTransfer?.files; + if (files && files.length > 0) { + addBrowserFiles(Array.from(files)); + } + }; + + window.addEventListener("dragenter", handleDragEnter); + window.addEventListener("dragleave", handleDragLeave); + window.addEventListener("dragover", handleDragOver); + window.addEventListener("drop", handleDrop); + + return () => { + window.removeEventListener("dragenter", handleDragEnter); + window.removeEventListener("dragleave", handleDragLeave); + window.removeEventListener("dragover", handleDragOver); + window.removeEventListener("drop", handleDrop); + }; + }, [addBrowserFiles, isLocked]); + return ( <> -
-
+ {isDraggingOver ? ( +
+
+ + + +

+ Drop files to attach +

+
+
+ ) : null} + +
+
-
+
{!editor ? (
{placeholderText} @@ -326,11 +413,13 @@ export function ChatComposer({ )} } - onComposerMenuOpenChange={setComposerMenuOpen} onPickFiles={() => { void handlePickFiles(); }} @@ -378,13 +459,14 @@ export function ChatComposer({ }} onStop={onStop} onTogglePlanMode={handleTogglePlanMode} + planModeAvailable={planModeAvailable} planMode={planMode} selectedModelKey={selectedModelKey} />
{activeWorkspace ? ( -
+
Promise | void; onRemoveQueuedFollowUp?: (id: string) => Promise | void; + onSelectionChange?: (input: { + engine?: ChatEngine; + modelId?: string | null; + mode?: "chat" | "plan"; + reasoningEffort?: ReasoningEffort | null; + }) => void; onStop?: () => void; onSend?: (input: ComposerSendInput) => void; onSteerFollowUp?: (input: ComposerSendInput) => Promise | void; @@ -38,6 +46,7 @@ export type ChatComposerProps = { persistThreadSelection?: boolean; threadId?: string; threadSelection?: { + engine?: ChatEngine; modelId: string | null; mode?: "chat" | "plan"; reasoningEffort?: ReasoningEffort | null; diff --git a/src/components/chat/chat-composer/use-composer-editor.ts b/src/components/chat/chat-composer/use-composer-editor.ts index c0934360..160e5014 100644 --- a/src/components/chat/chat-composer/use-composer-editor.ts +++ b/src/components/chat/chat-composer/use-composer-editor.ts @@ -3,11 +3,10 @@ import StarterKit from "@tiptap/starter-kit"; import { useEditor } from "@tiptap/react"; import { useEffect, useRef } from "react"; -const PLACEHOLDER_TEXT = "Ask follow-up changes"; - export function useComposerEditor({ isBusy, isLocked, + isThread, onAddBrowserFiles, onSendRef, promptSeed, @@ -15,11 +14,13 @@ export function useComposerEditor({ }: { isBusy: boolean; isLocked: boolean; + isThread: boolean; onAddBrowserFiles: (files: File[]) => void; onSendRef: React.RefObject<() => void>; promptSeed?: string; promptSeedKey?: string | number; }) { + const placeholderText = isThread ? "Ask follow-up changes" : "Ask anything"; const addBrowserFilesRef = useRef(onAddBrowserFiles); addBrowserFilesRef.current = onAddBrowserFiles; @@ -71,7 +72,7 @@ export function useComposerEditor({ heading: false, horizontalRule: false, }), - Placeholder.configure({ placeholder: PLACEHOLDER_TEXT }), + Placeholder.configure({ placeholder: placeholderText }), ], immediatelyRender: false, }); @@ -85,10 +86,10 @@ export function useComposerEditor({ if (placeholderExt) { placeholderExt.options.placeholder = isBusy ? "Generating..." - : PLACEHOLDER_TEXT; + : placeholderText; editor.view.dispatch(editor.state.tr); } - }, [editor, isLocked, isBusy]); + }, [editor, isLocked, isBusy, placeholderText]); useEffect(() => { if (!editor || promptSeedKey === undefined) return; @@ -111,5 +112,5 @@ export function useComposerEditor({ editor.commands.focus("end"); }, [editor, promptSeed, promptSeedKey]); - return { editor, placeholderText: PLACEHOLDER_TEXT }; + return { editor, placeholderText }; } diff --git a/src/components/chat/chat-composer/use-model-selection.ts b/src/components/chat/chat-composer/use-model-selection.ts index 8a1acfb0..314ddec2 100644 --- a/src/components/chat/chat-composer/use-model-selection.ts +++ b/src/components/chat/chat-composer/use-model-selection.ts @@ -1,70 +1,57 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - getModelAttachmentCapabilities, - type ReasoningEffort, - getSupportedReasoningEfforts, -} from "@/lib/ai/providers/models"; -import { - getCompositeModelId, - normalizeSelectedModelId, -} from "@/lib/ai/providers/model-selection"; +import type { ReasoningEffort } from "@/lib/ai/providers/models"; +import type { ChatEngine } from "@/server/db/enums"; import { api } from "@/trpc/react"; -import { - getAttachmentKindLabel, - getReasoningEffortLabel, - resolveReasoningEffort, - supportsAttachmentKind, -} from "../chat-composer-helpers"; -import type { ComposerAttachment } from "../chat-attachments"; +import { resolveReasoningEffort } from "../chat-composer-helpers"; import type { usePersistSelection } from "./use-persist-selection"; type PersistSelectionReturn = ReturnType; export function useModelSelection({ - attachments, globalSelectionQuery, + onSelectionChange, + persistEngineSelection, persistSelection, selectionScopeKey, threadSelection, }: { - attachments: ComposerAttachment[]; globalSelectionQuery: PersistSelectionReturn["globalSelectionQuery"]; + onSelectionChange?: (input: { + engine?: ChatEngine; + modelId?: string | null; + mode?: "chat" | "plan"; + reasoningEffort?: ReasoningEffort | null; + }) => void; + persistEngineSelection: PersistSelectionReturn["persistEngineSelection"]; persistSelection: PersistSelectionReturn["persistSelection"]; selectionScopeKey: string; threadSelection?: { + engine?: ChatEngine; modelId: string | null; mode?: "chat" | "plan"; reasoningEffort?: ReasoningEffort | null; } | null; }) { - const modelsQuery = api.models.list.useQuery(); + const enginesQuery = api.engines.list.useQuery(); + const sentinelModelsQuery = api.engines.models.useQuery({ + engine: "sentinel", + }); + const codexModelsQuery = api.engines.models.useQuery({ + engine: "codex", + }); + const [selectedEngine, setSelectedEngine] = useState("sentinel"); const [selectedModelKey, setSelectedModelKey] = useState(null); const [selectedReasoningEffort, setSelectedReasoningEffort] = useState(null); - const [modelMenuOpen, setModelMenuOpen] = useState(false); - const [reasoningMenuOpen, setReasoningMenuOpen] = useState(false); - const modelMenuRef = useRef(null); - const reasoningMenuRef = useRef(null); const initializedSelectionScopeRef = useRef(null); const threadPersistenceReadyRef = useRef(false); + const manualEngineSelectionRef = useRef(null); - const availableModels = useMemo( - () => - (modelsQuery.data ?? []).filter( - (model) => model.isConnected && model.isEnabled, - ), - [modelsQuery.data], - ); - - const selectedModel = - availableModels.find( - (model) => - getCompositeModelId(model.provider, model.modelId) === selectedModelKey, - ) ?? null; - + const preferredEngine = + threadSelection?.engine ?? globalSelectionQuery.data?.engine ?? "sentinel"; const hasThreadSelection = Boolean(threadSelection?.modelId); const preferredModelId = hasThreadSelection ? (threadSelection?.modelId ?? null) @@ -74,49 +61,33 @@ export function useModelSelection({ : ((globalSelectionQuery.data?.reasoningEffort as ReasoningEffort | null) ?? null); const preferencesReady = - hasThreadSelection || !globalSelectionQuery.isLoading; - - const supportedReasoningEfforts = selectedModel - ? getSupportedReasoningEfforts( - selectedModel.provider, - selectedModel.modelId, - ) - : []; - - const reasoningLabel = selectedReasoningEffort - ? getReasoningEffortLabel(selectedReasoningEffort) - : null; - - const attachmentCapabilities = selectedModel - ? getModelAttachmentCapabilities( - selectedModel.provider, - selectedModel.modelId, - ) - : { - supportsCodeTextFiles: false, - supportsDocuments: false, - supportsImages: false, - }; - - const unsupportedAttachmentKinds = useMemo(() => { - return Array.from( - new Set( - attachments - .map((a) => a.fileType.kind) - .filter( - (kind) => !supportsAttachmentKind(kind, attachmentCapabilities), - ), + Boolean(threadSelection?.engine) || + hasThreadSelection || + !globalSelectionQuery.isLoading; + + const engineOptions = enginesQuery.data ?? []; + const selectedEngineStatus = + engineOptions.find((engine) => engine.engine === selectedEngine) ?? null; + const selectedEngineModels = + selectedEngine === "codex" + ? (codexModelsQuery.data ?? []) + : (sentinelModelsQuery.data ?? []); + const modelsQuery = + selectedEngine === "codex" ? codexModelsQuery : sentinelModelsQuery; + + const availableModels = useMemo( + () => + selectedEngineModels.filter( + (model) => model.isConnected && model.isEnabled, ), - ); - }, [attachmentCapabilities, attachments]); + [selectedEngineModels], + ); - const attachmentWarning = useMemo(() => { - if (!selectedModel || unsupportedAttachmentKinds.length === 0) { - return ""; - } - const labels = unsupportedAttachmentKinds.map(getAttachmentKindLabel); - return `${selectedModel.displayName} may not support ${labels.join(", ")} as chat attachments.`; - }, [selectedModel, unsupportedAttachmentKinds]); + const selectedModel = + availableModels.find((model) => model.modelId === selectedModelKey) ?? null; + + const supportedReasoningEfforts = + selectedModel?.supportedReasoningEfforts ?? []; useEffect(() => { if (initializedSelectionScopeRef.current !== selectionScopeKey) { @@ -126,60 +97,90 @@ export function useModelSelection({ }, [selectionScopeKey]); useEffect(() => { - if (availableModels.length === 0) { + if (!preferencesReady) { + return; + } + + if (initializedSelectionScopeRef.current !== selectionScopeKey) { + setSelectedEngine(preferredEngine); + } + }, [preferredEngine, preferencesReady, selectionScopeKey]); + + useEffect(() => { + if ( + !preferencesReady || + initializedSelectionScopeRef.current !== selectionScopeKey + ) { + return; + } + if (manualEngineSelectionRef.current) { + if (manualEngineSelectionRef.current === preferredEngine) { + manualEngineSelectionRef.current = null; + } + return; + } + setSelectedEngine(preferredEngine); + // eslint-disable-next-line react-hooks/exhaustive-deps -- sync only when preferredEngine changes, not selectedEngine + }, [preferredEngine]); + + useEffect(() => { + if (!preferencesReady) { + return; + } + + if (initializedSelectionScopeRef.current === selectionScopeKey) { + return; + } + + if (selectedEngine !== preferredEngine) { + return; + } + + if (modelsQuery.isLoading) { + return; + } + + if (selectedEngineModels.length === 0) { setSelectedModelKey(null); setSelectedReasoningEffort(null); - initializedSelectionScopeRef.current = null; + initializedSelectionScopeRef.current = selectionScopeKey; return; } - if (!preferencesReady) return; - if (initializedSelectionScopeRef.current === selectionScopeKey) return; - - const normalizedPreferredModelId = normalizeSelectedModelId( - preferredModelId, - availableModels, + const preferredModel = selectedEngineModels.find( + (model) => model.modelId === preferredModelId, ); + const nextModel = preferredModel ?? selectedEngineModels[0] ?? null; - const preferredModel = normalizedPreferredModelId - ? (availableModels.find( - (model) => - getCompositeModelId(model.provider, model.modelId) === - normalizedPreferredModelId, - ) ?? null) - : null; - const nextModel = preferredModel ?? availableModels[0] ?? null; - const nextModelKey = nextModel - ? getCompositeModelId(nextModel.provider, nextModel.modelId) - : null; - - setSelectedModelKey(nextModelKey); + setSelectedModelKey(nextModel?.modelId ?? null); setSelectedReasoningEffort( nextModel - ? resolveReasoningEffort( - nextModel.provider, - nextModel.modelId, - preferredModel ? preferredReasoningEffort : null, - ) + ? resolveReasoningEffort(nextModel, preferredReasoningEffort) : null, ); initializedSelectionScopeRef.current = selectionScopeKey; }, [ - availableModels, + preferredEngine, preferredModelId, preferredReasoningEffort, preferencesReady, + modelsQuery.isLoading, + selectedEngine, + selectedEngineModels, selectionScopeKey, ]); useEffect(() => { - if (!selectedModelKey || availableModels.length === 0) return; + if (!selectedModelKey) { + return; + } const stillAvailable = availableModels.some( - (model) => - getCompositeModelId(model.provider, model.modelId) === selectedModelKey, + (model) => model.modelId === selectedModelKey, ); - if (stillAvailable) return; + if (stillAvailable) { + return; + } const fallbackModel = availableModels[0]; if (!fallbackModel) { @@ -188,20 +189,24 @@ export function useModelSelection({ return; } - const fallbackModelKey = getCompositeModelId( - fallbackModel.provider, - fallbackModel.modelId, - ); - const fallbackReasoningEffort = resolveReasoningEffort( - fallbackModel.provider, - fallbackModel.modelId, - null, - ); - - setSelectedModelKey(fallbackModelKey); - setSelectedReasoningEffort(fallbackReasoningEffort); - persistSelection(fallbackModelKey, fallbackReasoningEffort); - }, [availableModels, persistSelection, selectedModelKey]); + const fallbackEffort = resolveReasoningEffort(fallbackModel, null); + setSelectedModelKey(fallbackModel.modelId); + setSelectedReasoningEffort(fallbackEffort); + onSelectionChange?.({ + engine: selectedEngine, + modelId: fallbackModel.modelId, + reasoningEffort: fallbackEffort, + }); + persistSelection(fallbackModel.modelId, fallbackEffort, { + engine: selectedEngine, + }); + }, [ + availableModels, + onSelectionChange, + persistSelection, + selectedEngine, + selectedModelKey, + ]); useEffect(() => { if (!selectedModel) { @@ -212,8 +217,7 @@ export function useModelSelection({ } const nextReasoningEffort = resolveReasoningEffort( - selectedModel.provider, - selectedModel.modelId, + selectedModel, selectedReasoningEffort, ); @@ -222,54 +226,125 @@ export function useModelSelection({ } }, [selectedModel, selectedReasoningEffort]); + const handleSelectEngine = useCallback( + (engine: ChatEngine) => { + manualEngineSelectionRef.current = engine; + setSelectedEngine(engine); + initializedSelectionScopeRef.current = null; + + const nextModels = + engine === "codex" + ? (codexModelsQuery.data ?? []) + : (sentinelModelsQuery.data ?? []); + const nextModel = nextModels.find( + (model) => model.isConnected && model.isEnabled, + ); + const nextMode = undefined; + + if (!nextModel) { + setSelectedModelKey(null); + setSelectedReasoningEffort(null); + onSelectionChange?.({ + engine, + modelId: null, + mode: nextMode, + reasoningEffort: null, + }); + persistEngineSelection( + engine, + nextMode ? { mode: nextMode } : undefined, + ); + return; + } + + const nextReasoningEffort = resolveReasoningEffort(nextModel, null); + setSelectedModelKey(nextModel.modelId); + setSelectedReasoningEffort(nextReasoningEffort); + onSelectionChange?.({ + engine, + modelId: nextModel.modelId, + mode: nextMode, + reasoningEffort: nextReasoningEffort, + }); + persistSelection(nextModel.modelId, nextReasoningEffort, { + engine, + ...(nextMode ? { mode: nextMode } : {}), + }); + }, + [ + codexModelsQuery.data, + onSelectionChange, + persistEngineSelection, + persistSelection, + sentinelModelsQuery.data, + ], + ); + const handleSelectModel = useCallback( (modelKey: string) => { const nextModel = availableModels.find( - (model) => - getCompositeModelId(model.provider, model.modelId) === modelKey, + (model) => model.modelId === modelKey, ); - if (!nextModel) return; + if (!nextModel) { + return; + } const nextReasoningEffort = resolveReasoningEffort( - nextModel.provider, - nextModel.modelId, + nextModel, selectedReasoningEffort, ); setSelectedModelKey(modelKey); setSelectedReasoningEffort(nextReasoningEffort); - setModelMenuOpen(false); - persistSelection(modelKey, nextReasoningEffort); + onSelectionChange?.({ + engine: selectedEngine, + modelId: modelKey, + reasoningEffort: nextReasoningEffort, + }); + persistSelection(modelKey, nextReasoningEffort, { + engine: selectedEngine, + }); }, - [availableModels, persistSelection, selectedReasoningEffort], + [ + availableModels, + onSelectionChange, + persistSelection, + selectedEngine, + selectedReasoningEffort, + ], ); const handleSelectReasoningEffort = useCallback( (effort: ReasoningEffort) => { - if (!selectedModelKey) return; + if (!selectedModelKey) { + return; + } + setSelectedReasoningEffort(effort); - setReasoningMenuOpen(false); - persistSelection(selectedModelKey, effort); + onSelectionChange?.({ + engine: selectedEngine, + modelId: selectedModelKey, + reasoningEffort: effort, + }); + persistSelection(selectedModelKey, effort, { + engine: selectedEngine, + }); }, - [persistSelection, selectedModelKey], + [onSelectionChange, persistSelection, selectedEngine, selectedModelKey], ); return { - attachmentWarning, availableModels, + enginesQuery, + handleSelectEngine, handleSelectModel, handleSelectReasoningEffort, - modelMenuOpen, - modelMenuRef, modelsQuery, - reasoningLabel, - reasoningMenuOpen, - reasoningMenuRef, + selectedEngine, + selectedEngineStatus, selectedModel, selectedModelKey, selectedReasoningEffort, - setModelMenuOpen, - setReasoningMenuOpen, supportedReasoningEfforts, threadPersistenceReadyRef, }; diff --git a/src/components/chat/chat-composer/use-persist-selection.ts b/src/components/chat/chat-composer/use-persist-selection.ts index d7277fe2..1d2ae4d7 100644 --- a/src/components/chat/chat-composer/use-persist-selection.ts +++ b/src/components/chat/chat-composer/use-persist-selection.ts @@ -1,6 +1,7 @@ import { useCallback } from "react"; import type { ReasoningEffort } from "@/lib/ai/providers/models"; +import type { ChatEngine } from "@/server/db/enums"; import { applyThreadSettingsCacheUpdate } from "@/lib/threads/cache"; import { api } from "@/trpc/react"; @@ -21,6 +22,10 @@ export function usePersistSelection({ onMutate: (input) => { const previous = utils.chatPreferences.get.getData(); utils.chatPreferences.get.setData(undefined, (current) => ({ + engine: + input.engine !== undefined + ? (input.engine ?? "sentinel") + : (current?.engine ?? "sentinel"), mode: input.mode !== undefined ? (input.mode ?? null) @@ -50,6 +55,7 @@ export function usePersistSelection({ onMutate: (input) => { applyThreadSettingsCacheUpdate({ patch: { + ...(input.engine === undefined ? {} : { chatEngine: input.engine }), ...(input.modelId === undefined ? {} : { chatModelId: input.modelId }), @@ -70,6 +76,7 @@ export function usePersistSelection({ onSuccess: (data) => { applyThreadSettingsCacheUpdate({ patch: { + chatEngine: data.engine, chatModelId: data.modelId, chatReasoningEffort: data.reasoningEffort ?? null, mode: data.mode, @@ -86,6 +93,7 @@ export function usePersistSelection({ modelId: string, reasoningEffort: ReasoningEffort | null, options?: { + engine?: ChatEngine; mode?: "chat" | "plan"; skipGlobal?: boolean; skipThread?: boolean; @@ -93,6 +101,7 @@ export function usePersistSelection({ ) => { if (!options?.skipGlobal) { updateGlobalSelection.mutate({ + engine: options?.engine, mode: options?.mode, modelId, reasoningEffort, @@ -101,6 +110,7 @@ export function usePersistSelection({ if (!options?.skipThread && canPersistThreadSelection && threadId) { updateThreadSelection.mutate({ + ...(options?.engine === undefined ? {} : { engine: options.engine }), ...(options?.mode === undefined ? {} : { mode: options.mode }), modelId, reasoningEffort, @@ -116,8 +126,38 @@ export function usePersistSelection({ ], ); + const persistEngineSelection = useCallback( + ( + engine: ChatEngine, + options?: { + mode?: "chat" | "plan"; + skipThread?: boolean; + }, + ) => { + updateGlobalSelection.mutate({ + engine, + ...(options?.mode === undefined ? {} : { mode: options.mode }), + }); + + if (!options?.skipThread && canPersistThreadSelection && threadId) { + updateThreadSelection.mutate({ + engine, + ...(options?.mode === undefined ? {} : { mode: options.mode }), + threadId, + }); + } + }, + [ + canPersistThreadSelection, + threadId, + updateGlobalSelection, + updateThreadSelection, + ], + ); + return { globalSelectionQuery, + persistEngineSelection, persistSelection, updateGlobalSelection, updateThreadSelection, diff --git a/src/components/chat/chat-composer/use-plan-mode.ts b/src/components/chat/chat-composer/use-plan-mode.ts index 83776ce4..b3d8b704 100644 --- a/src/components/chat/chat-composer/use-plan-mode.ts +++ b/src/components/chat/chat-composer/use-plan-mode.ts @@ -1,3 +1,4 @@ +import type { ChatEngine } from "@/server/db/enums"; import { useCallback, useEffect, useRef, useState } from "react"; import type { ReasoningEffort } from "@/lib/ai/providers/models"; @@ -10,7 +11,10 @@ export function usePlanMode({ canPersistThreadSelection, draftMode, globalSelectionQuery, + onSelectionChange, + planModeAvailable, persistSelection, + selectedEngine, selectedModelKey, selectedReasoningEffort, selectionScopeKey, @@ -22,7 +26,15 @@ export function usePlanMode({ canPersistThreadSelection: boolean; draftMode?: "chat" | "plan" | null; globalSelectionQuery: PersistSelectionReturn["globalSelectionQuery"]; + onSelectionChange?: (input: { + engine?: ChatEngine; + modelId?: string | null; + mode?: "chat" | "plan"; + reasoningEffort?: ReasoningEffort | null; + }) => void; + planModeAvailable: boolean; persistSelection: PersistSelectionReturn["persistSelection"]; + selectedEngine: ChatEngine; selectedModelKey: string | null; selectedReasoningEffort: ReasoningEffort | null; selectionScopeKey: string; @@ -42,6 +54,11 @@ export function usePlanMode({ Boolean(threadSelection?.modelId) || !globalSelectionQuery.isLoading; useEffect(() => { + if (!planModeAvailable) { + setPlanMode(false); + return; + } + if (!preferencesReady) return; const currentThreadMode = threadSelection?.mode ?? null; @@ -74,18 +91,26 @@ export function usePlanMode({ ]); const handleTogglePlanMode = useCallback(() => { + if (!planModeAvailable) { + return; + } + setPlanMode((prev) => { const next = !prev; + onSelectionChange?.({ mode: next ? "plan" : "chat" }); if (selectedModelKey) { persistSelection(selectedModelKey, selectedReasoningEffort, { + engine: selectedEngine, mode: next ? "plan" : "chat", }); } else { updateGlobalSelection.mutate({ + engine: selectedEngine, mode: next ? "plan" : "chat", }); if (canPersistThreadSelection && threadId) { updateThreadSelection.mutate({ + engine: selectedEngine, mode: next ? "plan" : "chat", threadId, }); @@ -95,7 +120,10 @@ export function usePlanMode({ }); }, [ canPersistThreadSelection, + onSelectionChange, + planModeAvailable, persistSelection, + selectedEngine, selectedModelKey, selectedReasoningEffort, threadId, @@ -103,5 +131,8 @@ export function usePlanMode({ updateThreadSelection, ]); - return { handleTogglePlanMode, planMode }; + return { + handleTogglePlanMode, + planMode: planModeAvailable ? planMode : false, + }; } diff --git a/src/components/chat/chat-message.tsx b/src/components/chat/chat-message.tsx index adda9bf7..83b3f1ff 100644 --- a/src/components/chat/chat-message.tsx +++ b/src/components/chat/chat-message.tsx @@ -31,6 +31,7 @@ import { type MessagePart, } from "./message-parts/types"; import { Button } from "@heroui/react"; +import type { ChatEngine } from "@/server/db/enums"; const PRE_RESPONSE_STATUS_LABELS = [ "Working...", @@ -253,7 +254,9 @@ function getAttachmentGridColumns(count: number) { } function AssistantMessage({ + chatEngine, onApproveTool, + onApproveToolWithDecision, onAnswerPlanQuestions, onDenyTool, onStartPlanImplementation, @@ -263,7 +266,9 @@ function AssistantMessage({ isStreaming, message, }: { - onApproveTool?: (approvalId: string) => void; + chatEngine?: ChatEngine; + onApproveTool?: (approvalId: string, response?: string) => void; + onApproveToolWithDecision?: (approvalId: string, decision: string) => void; onAnswerPlanQuestions?: (input: { answers: Array<{ answer: string; @@ -281,6 +286,7 @@ function AssistantMessage({ isStreaming: boolean; message: ThreadUIMessage; }) { + const supportsSentinelMessageActions = chatEngine !== "codex"; const assistantText = useMemo(() => getAssistantText(message), [message]); const groups = useMemo( () => groupMessageParts(message.parts), @@ -409,6 +415,7 @@ function AssistantMessage({ ) : null} {!isStreaming && + supportsSentinelMessageActions && onRetry && (status === "error" || status === "cancelled") ? ( onRetry?.(message.id)} /> ) : null} - {!isStreaming && onRegenerate && status === "completed" ? ( + {!isStreaming && + supportsSentinelMessageActions && + onRegenerate && + status === "completed" ? ( void; onSelectBranch?: (messageId: string) => void; }) { + const supportsSentinelMessageActions = chatEngine !== "codex"; const fileParts = message.parts.filter( (part): part is Extract => part.type === "file", @@ -517,7 +531,7 @@ function UserMessage({ text={textParts.map((part) => part.text).join("\n\n")} title="Copy prompt" /> - {onEdit ? ( + {supportsSentinelMessageActions && onEdit ? ( void; + chatEngine?: ChatEngine; + onApproveTool?: (approvalId: string, response?: string) => void; + onApproveToolWithDecision?: (approvalId: string, decision: string) => void; onAnswerPlanQuestions?: (input: { answers: Array<{ answer: string; @@ -553,9 +569,11 @@ type ChatMessageProps = { }; export const ChatMessage = memo(function ChatMessage({ + chatEngine, message, isStreaming = false, onApproveTool, + onApproveToolWithDecision, onAnswerPlanQuestions, onDenyTool, onStartPlanImplementation, @@ -567,6 +585,7 @@ export const ChatMessage = memo(function ChatMessage({ if (message.role === "user") { return ( ; contextWindowIndicator?: { compactionEnabled: boolean; compactionWindowPercent: number; @@ -24,73 +25,97 @@ type ComposerToolbarProps = { inputTokens: number; usedPercent: number; } | null; + engineOptions: Array<{ + engine: ChatEngine; + error: string | null; + isAvailable: boolean; + label: string; + }>; hasWorkspace: boolean; isBusy: boolean; isLocked: boolean; modelSelector: ReactNode; - onComposerMenuOpenChange: (open: boolean) => void; onPickFiles: () => void; + onSelectEngine: (engine: ChatEngine) => void; onSend: () => void; onStop?: () => void; onTogglePlanMode: () => void; + planModeAvailable: boolean; planMode: boolean; + selectedEngine: ChatEngine; selectedModelKey: string | null; + showEngineSelector: boolean; }; export function ComposerToolbar({ - composerMenuOpen, - composerMenuRef, contextWindowIndicator, + engineOptions, hasWorkspace, isBusy, isLocked, modelSelector, - onComposerMenuOpenChange, onPickFiles, + onSelectEngine, onSend, onStop, onTogglePlanMode, + planModeAvailable, planMode, + selectedEngine, selectedModelKey, + showEngineSelector, }: ComposerToolbarProps) { + const [composerMenuOpen, setComposerMenuOpen] = useState(false); + const [engineSubOpen, setEngineSubOpen] = useState(false); + return ( -
-
-
- +
+
+ { + setComposerMenuOpen(open); + if (!open) setEngineSubOpen(false); + }} + > + + + - - {composerMenuOpen ? ( - + + { + if (key === "attach-files") { + setComposerMenuOpen(false); + onPickFiles(); + } else if (key === "plan-mode") { + setComposerMenuOpen(false); + onTogglePlanMode(); + } else if (key === "engine") { + setEngineSubOpen((prev) => !prev); + } }} > - - -
- - - - ) : null} - -
+ {engineOptions.map((engine) => ( + + {engine.label} + {!engine.isAvailable && engine.engine !== "sentinel" ? ( + + Unavailable + + ) : null} + + + ))} +
+
+ ) : null} + + + {modelSelector} diff --git a/src/components/chat/composer-workspace-bar.tsx b/src/components/chat/composer-workspace-bar.tsx index e5bd76fe..4577fa13 100644 --- a/src/components/chat/composer-workspace-bar.tsx +++ b/src/components/chat/composer-workspace-bar.tsx @@ -251,7 +251,7 @@ export function ComposerWorkspaceBar({ checkoutBranchMutation.isPending || createBranchMutation.isPending; return ( -
+
{ expect(renderer).toBe(GCalCreateEventTool); }); + + it("uses the CodexShellTool renderer for codex_command_execution", () => { + const renderer = resolveRenderer({ + input: { command: "npm test", cwd: "/workspace" }, + output: { + output: "ok", + exitCode: 0, + durationMs: 52, + status: "completed", + }, + state: "output-available", + toolCallId: "tool-call-codex-1", + toolName: "codex_command_execution", + type: "dynamic-tool", + } as const); + + expect(renderer).toBe(CodexShellTool); + }); + + it("uses the CodexFileChangeTool renderer for codex_file_change", () => { + const renderer = resolveRenderer({ + input: { changes: [{ path: "src/main.ts", kind: "update" }] }, + output: { output: "diff --git ...", status: "completed" }, + state: "output-available", + toolCallId: "tool-call-codex-fc", + toolName: "codex_file_change", + type: "dynamic-tool", + } as const); + + expect(renderer).toBe(CodexFileChangeTool); + }); + + it("uses the CodexWebSearchTool renderer for codex_web_search", () => { + const renderer = resolveRenderer({ + input: { query: "how to test" }, + output: { action: null }, + state: "output-available", + toolCallId: "tool-call-codex-ws", + toolName: "codex_web_search", + type: "dynamic-tool", + } as const); + + expect(renderer).toBe(CodexWebSearchTool); + }); + + it("uses the CodexMcpTool renderer for codex_mcp_tool_call", () => { + const renderer = resolveRenderer({ + input: { arguments: {}, server: "test-server", tool: "list" }, + output: { + durationMs: 100, + error: null, + result: null, + status: "completed", + }, + state: "output-available", + toolCallId: "tool-call-codex-mcp", + toolName: "codex_mcp_tool_call", + type: "dynamic-tool", + } as const); + + expect(renderer).toBe(CodexMcpTool); + }); + + it("uses the CodexImageViewTool renderer for codex_image_view", () => { + const renderer = resolveRenderer({ + input: { path: "/workspace/image.png" }, + output: { path: "/workspace/image.png" }, + state: "output-available", + toolCallId: "tool-call-codex-iv", + toolName: "codex_image_view", + type: "dynamic-tool", + } as const); + + expect(renderer).toBe(CodexImageViewTool); + }); + + it("uses the CodexPlanTool renderer for codex_plan", () => { + const renderer = resolveRenderer({ + input: { kind: "plan" }, + output: { text: "Step 1: do thing" }, + state: "output-available", + toolCallId: "tool-call-codex-plan", + toolName: "codex_plan", + type: "dynamic-tool", + } as const); + + expect(renderer).toBe(CodexPlanTool); + }); + + it("uses the CodexReviewModeTool renderer for codex_review_mode", () => { + const renderer = resolveRenderer({ + input: { review: "", transition: "enteredReviewMode" }, + output: { review: "", transition: "enteredReviewMode" }, + state: "output-available", + toolCallId: "tool-call-codex-rm", + toolName: "codex_review_mode", + type: "dynamic-tool", + } as const); + + expect(renderer).toBe(CodexReviewModeTool); + }); + + it("uses the CodexContextCompactionTool renderer for codex_context_compaction", () => { + const renderer = resolveRenderer({ + input: { kind: "contextCompaction" }, + output: { kind: "contextCompaction" }, + state: "output-available", + toolCallId: "tool-call-codex-cc", + toolName: "codex_context_compaction", + type: "dynamic-tool", + } as const); + + expect(renderer).toBe(CodexContextCompactionTool); + }); + + it("uses the CodexUserInputTool renderer for codex_user_input", () => { + const renderer = resolveRenderer({ + input: { prompt: "Enter your name", requestId: "req-1" }, + output: { response: null }, + state: "approval-requested", + toolCallId: "tool-call-codex-ui", + toolName: "codex_user_input", + type: "dynamic-tool", + } as any); + + expect(renderer).toBe(CodexUserInputTool); + }); + + it("uses the CodexCollabAgentTool renderer for codex_collab_agent", () => { + const renderer = resolveRenderer({ + input: { + prompt: null, + receiverThreadIds: ["t1"], + senderThreadId: "t0", + tool: "agent", + }, + output: { agentsStates: {}, status: "completed" }, + state: "output-available", + toolCallId: "tool-call-codex-ca", + toolName: "codex_collab_agent", + type: "dynamic-tool", + } as const); + + expect(renderer).toBe(CodexCollabAgentTool); + }); + + it("uses the generic Codex renderer for other codex tools", () => { + const renderer = resolveRenderer({ + input: { path: "/workspace/file.ts", content: "hello" }, + output: { status: "ok" }, + state: "output-available", + toolCallId: "tool-call-codex-2", + toolName: "codex_file_write", + type: "dynamic-tool", + } as const); + + expect(renderer).toBe(CodexRuntimeTool); + }); }); diff --git a/src/components/chat/message-parts/tool/registry.ts b/src/components/chat/message-parts/tool/registry.ts index 25b1f0d7..3baa7239 100644 --- a/src/components/chat/message-parts/tool/registry.ts +++ b/src/components/chat/message-parts/tool/registry.ts @@ -101,6 +101,19 @@ import { MongoFindTool } from "./renderers/integrations/database/mongodb/mongo-f import { MongoMutationTool } from "./renderers/integrations/database/mongodb/mongo-mutation"; import { MongoAggregateTool } from "./renderers/integrations/database/mongodb/mongo-aggregate"; import { MongoCountTool } from "./renderers/integrations/database/mongodb/mongo-count"; +import { CodexRuntimeTool } from "./renderers/codex-runtime"; +import { CodexFileChangeTool } from "./renderers/codex-file-change"; +import { CodexImageViewTool } from "./renderers/codex-image-view"; +import { CodexMcpTool } from "./renderers/codex-mcp"; +import { CodexPlanTool } from "./renderers/codex-plan"; +import { CodexShellTool } from "./renderers/codex-shell"; +import { + CodexCollabAgentTool, + CodexContextCompactionTool, + CodexReviewModeTool, +} from "./renderers/codex-status"; +import { CodexUserInputTool } from "./renderers/codex-user-input"; +import { CodexWebSearchTool } from "./renderers/codex-web-search"; const renderers: Record = { apply_patch: WorkspaceTool, @@ -313,6 +326,19 @@ const renderers: Record = { pubmed_get_article: PubMedArticleTool, }; +const codexRenderers: Record = { + codex_collab_agent: CodexCollabAgentTool, + codex_command_execution: CodexShellTool, + codex_context_compaction: CodexContextCompactionTool, + codex_file_change: CodexFileChangeTool, + codex_image_view: CodexImageViewTool, + codex_mcp_tool_call: CodexMcpTool, + codex_plan: CodexPlanTool, + codex_review_mode: CodexReviewModeTool, + codex_user_input: CodexUserInputTool, + codex_web_search: CodexWebSearchTool, +}; + function isIntegrationToolName(name: string) { return ( name.startsWith("gmail_") || @@ -355,6 +381,10 @@ export function resolveRenderer(part: ToolPart): Renderer | undefined { } if (part.type === "dynamic-tool") { + if (part.toolName.startsWith("codex_")) { + return codexRenderers[part.toolName] ?? CodexRuntimeTool; + } + return ( renderers[part.toolName] ?? resolveIntegrationFallback(part.toolName) ); diff --git a/src/components/chat/message-parts/tool/renderer.ts b/src/components/chat/message-parts/tool/renderer.ts index 2730e072..06c89c6b 100644 --- a/src/components/chat/message-parts/tool/renderer.ts +++ b/src/components/chat/message-parts/tool/renderer.ts @@ -2,8 +2,18 @@ import type { ComponentType } from "react"; import type { ToolPart as ToolPartType } from "../types"; +export type ApprovalDecision = + | "accept" + | "acceptForSession" + | "cancel" + | "decline"; + export type RendererProps = { - onApprove?: (approvalId: string) => void; + onApprove?: (approvalId: string, response?: string) => void; + onApproveWithDecision?: ( + approvalId: string, + decision: ApprovalDecision, + ) => void; onAnswerPlanQuestions?: (input: { answers: Array<{ answer: string; diff --git a/src/components/chat/message-parts/tool/renderers/codex-file-change/index.tsx b/src/components/chat/message-parts/tool/renderers/codex-file-change/index.tsx new file mode 100644 index 00000000..8b86a532 --- /dev/null +++ b/src/components/chat/message-parts/tool/renderers/codex-file-change/index.tsx @@ -0,0 +1,484 @@ +"use client"; + +import type { ReactNode } from "react"; +import { memo, useEffect, useMemo, useState } from "react"; +import { ScrollShadow } from "@heroui/react"; +import { Icon } from "@iconify/react"; + +import type { RendererProps } from "../../renderer"; +import { DiffView } from "../shared/diff-view"; +import { ToolLayout } from "../shared/tool-layout"; +import { + detectLanguageFromPath, + languageToVSCodeIcon, +} from "@/lib/syntax/highlighter"; + +type FileChangeKind = string | { type: string; move_path?: string | null }; + +type FileChange = { + diff?: string; + kind: FileChangeKind; + path: string; +}; + +type CodexFileChangeInput = { + changes: FileChange[]; + reason?: string | null; +}; + +type CodexFileChangeOutput = { + output: string; + status: string; +}; + +function resolveKindString(kind: FileChangeKind): string { + if (typeof kind === "string") return kind; + if (kind && typeof kind === "object" && typeof kind.type === "string") { + return kind.type; + } + return "update"; +} + +function isFileChange(value: unknown): value is FileChange { + if (!value || typeof value !== "object") return false; + const v = value as Record; + return ( + typeof v.path === "string" && + (typeof v.kind === "string" || + (typeof v.kind === "object" && v.kind !== null)) + ); +} + +function isFileChangeInput(value: unknown): value is CodexFileChangeInput { + if (!value || typeof value !== "object") return false; + const v = value as Record; + return Array.isArray(v.changes) && v.changes.every(isFileChange); +} + +function isFileChangeOutput(value: unknown): value is CodexFileChangeOutput { + if (!value || typeof value !== "object") return false; + const v = value as Record; + return typeof v.status === "string"; +} + +function getMovePath(kind: FileChangeKind): string | null { + if (typeof kind === "object" && kind?.move_path) { + return kind.move_path; + } + return null; +} + +function getChangeVerb(kind: FileChangeKind): string { + const k = resolveKindString(kind); + if (getMovePath(kind)) return "Renamed"; + switch (k) { + case "add": + return "Created"; + case "delete": + return "Deleted"; + default: + return "Modified"; + } +} + +function getChangeColor(kind: FileChangeKind): string { + const k = resolveKindString(kind); + switch (k) { + case "add": + return "text-success"; + case "delete": + return "text-danger"; + default: + return "text-warning"; + } +} + +function getFileName(path: string) { + return path.split("/").pop() ?? path; +} + +function stripDiffMetadata(raw: string): string { + const lines = raw.split("\n"); + const cleaned: string[] = []; + + for (const line of lines) { + if ( + line.startsWith("diff --git") || + line.startsWith("index ") || + line.startsWith("new file mode") || + line.startsWith("deleted file mode") || + line.startsWith("old mode") || + line.startsWith("new mode") || + line.startsWith("similarity index") || + line.startsWith("rename from") || + line.startsWith("rename to") || + line.startsWith("Binary files") + ) { + continue; + } + cleaned.push(line); + } + + return cleaned.join("\n"); +} + +/** + * Split a multi-file git diff into per-file sections. + * Each section preserves its unified diff content (@@, +, -, context). + */ +function splitGitDiffByFile(rawDiff: string) { + const sections: Array<{ diff: string; path: string }> = []; + const lines = rawDiff.split("\n"); + let currentPath = ""; + let currentLines: string[] = []; + + for (const line of lines) { + const gitHeader = line.match(/^diff --git a\/(.+?) b\//); + if (gitHeader) { + if (currentPath && currentLines.length > 0) { + sections.push({ + diff: stripDiffMetadata(currentLines.join("\n")), + path: currentPath, + }); + } + currentPath = gitHeader[1] ?? ""; + currentLines = [line]; + continue; + } + + const unifiedHeader = line.match(/^---\s+a\/(.+)/); + if (unifiedHeader && !currentPath) { + if (currentLines.length > 0) { + sections.push({ + diff: stripDiffMetadata(currentLines.join("\n")), + path: "patch", + }); + } + currentPath = unifiedHeader[1] ?? ""; + currentLines = [line]; + continue; + } + + currentLines.push(line); + } + + if (currentLines.length > 0) { + const diff = stripDiffMetadata(currentLines.join("\n")); + if (diff.trim()) { + sections.push({ diff, path: currentPath || "patch" }); + } + } + + return sections; +} + +/** + * Build per-file diff sections from all available sources: + * 1. Individual `diff` fields on each change entry + * 2. The accumulated output.output diff text + */ +function buildDiffSections( + changes: FileChange[], + output: CodexFileChangeOutput | null, +) { + const sections: Array<{ diff: string; path: string }> = []; + + const perChangeDiffs = changes.filter( + (c) => typeof c.diff === "string" && c.diff.trim(), + ); + + if (perChangeDiffs.length > 0) { + for (const change of perChangeDiffs) { + sections.push({ + diff: stripDiffMetadata(change.diff!), + path: change.path, + }); + } + return sections; + } + + if (output?.output?.trim()) { + return splitGitDiffByFile(output.output); + } + + return sections; +} + +function buildSummary( + part: RendererProps["part"], + input: CodexFileChangeInput, + output: CodexFileChangeOutput | null, +): ReactNode { + const count = input.changes.length; + const fileLabel = count === 1 ? "file" : "files"; + + if (part.state === "output-denied") { + return <>File changes denied; + } + + if (part.state === "output-error") { + return ( + <> + Failed to apply changes to{" "} + + {count} {fileLabel} + + + ); + } + + if (output?.status === "completed" || part.state === "output-available") { + if (count === 1 && input.changes[0]) { + return ( + <> + {getChangeVerb(input.changes[0].kind)}{" "} + + {getFileName(input.changes[0].path)} + + + ); + } + return ( + <> + Applied changes to{" "} + + {count} {fileLabel} + + + ); + } + + if (part.state === "approval-requested") { + if (count === 1 && input.changes[0]) { + return ( + <> + {getChangeVerb(input.changes[0].kind).replace(/d$/, "")}{" "} + + {getFileName(input.changes[0].path)} + + + ); + } + return ( + <> + Apply changes to{" "} + + {count} {fileLabel} + + + ); + } + + if (count === 1 && input.changes[0]) { + return ( + <> + Modifying{" "} + + {getFileName(input.changes[0].path)} + + + ); + } + + return ( + <> + Applying changes to{" "} + + {count} {fileLabel} + + + ); +} + +function FileChangeList({ changes }: { changes: FileChange[] }) { + return ( +
+ {changes.map((change, idx) => { + const lang = detectLanguageFromPath(change.path); + const icon = languageToVSCodeIcon[lang] ?? "vscode-icons:default-file"; + const name = getFileName(change.path); + const dir = change.path.includes("/") + ? change.path.slice(0, change.path.length - name.length) + : ""; + + return ( +
+ + + {dir ? {dir} : null} + {name} + {getMovePath(change.kind) && ( + + {" → "} + {getFileName(getMovePath(change.kind)!)} + + )} + + + {getChangeVerb(change.kind)} + +
+ ); + })} +
+ ); +} + +export const CodexFileChangeTool = memo(function CodexFileChangeTool({ + onApprove, + onApproveWithDecision, + onDeny, + part, +}: RendererProps) { + const approval = "approval" in part ? part.approval : undefined; + const hasInput = "input" in part && part.input !== undefined; + const hasOutput = "output" in part && part.output !== undefined; + const fileInput = + hasInput && isFileChangeInput(part.input) ? part.input : null; + const fileOutput = + hasOutput && isFileChangeOutput(part.output) ? part.output : null; + const partErrorText = "errorText" in part ? part.errorText : undefined; + const approvalId = approval?.id; + const showApprovalActions = + part.state === "approval-requested" && approvalId && onApprove && onDeny; + + const isRunning = + part.state === "approval-responded" || + part.state === "input-available" || + part.state === "input-streaming"; + const isFinished = + part.state === "output-denied" || + part.state === "output-error" || + part.state === "output-available"; + const isError = + part.state === "output-denied" || + part.state === "output-error" || + (fileOutput != null && fileOutput.status === "failed"); + const [isExpanded, setIsExpanded] = useState( + part.state === "approval-requested" || isRunning, + ); + + useEffect(() => { + setIsExpanded(part.state === "approval-requested" || isRunning); + }, [isRunning, part.state, part.toolCallId]); + + const diffSections = useMemo( + () => (fileInput ? buildDiffSections(fileInput.changes, fileOutput) : []), + [fileInput, fileOutput], + ); + + if (!fileInput) return null; + + const summary = buildSummary(part, fileInput, fileOutput); + const hasDiff = diffSections.length > 0; + + return ( + + + {fileInput.changes.length} file + {fileInput.changes.length !== 1 ? "s" : ""} + + + {fileOutput.status === "completed" ? "Applied" : "Failed"} + +
+ ) : null + } + actions={ + showApprovalActions ? ( +
+ {fileInput.reason ? ( +

+ {fileInput.reason} +

+ ) : null} +
+ + + + +
+
+ ) : undefined + } + > +
+ {fileInput.changes.length > 1 ? ( + + ) : null} + {hasDiff ? ( + +
+ {diffSections.map((section, idx) => ( + + ))} +
+
+ ) : null} +
+ + ); +}); diff --git a/src/components/chat/message-parts/tool/renderers/codex-image-view/index.tsx b/src/components/chat/message-parts/tool/renderers/codex-image-view/index.tsx new file mode 100644 index 00000000..9a130c96 --- /dev/null +++ b/src/components/chat/message-parts/tool/renderers/codex-image-view/index.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { memo, useState } from "react"; +import { Icon } from "@iconify/react"; + +import type { RendererProps } from "../../renderer"; +import { ToolLayout } from "../shared/tool-layout"; + +type CodexImageViewInput = { + path: string; +}; + +type CodexImageViewOutput = { + path: string; +}; + +function isImageViewInput(value: unknown): value is CodexImageViewInput { + if (!value || typeof value !== "object") return false; + return typeof (value as Record).path === "string"; +} + +function isImageViewOutput(value: unknown): value is CodexImageViewOutput { + if (!value || typeof value !== "object") return false; + return typeof (value as Record).path === "string"; +} + +function getFileName(path: string) { + return path.split("/").pop() ?? path; +} + +const PREVIEWABLE_EXTENSIONS = new Set([ + ".png", + ".jpg", + ".jpeg", + ".gif", + ".webp", + ".svg", + ".bmp", + ".ico", +]); + +function canPreview(path: string) { + const ext = path.toLowerCase().slice(path.lastIndexOf(".")); + return PREVIEWABLE_EXTENSIONS.has(ext); +} + +export const CodexImageViewTool = memo(function CodexImageViewTool({ + part, +}: RendererProps) { + const input = + "input" in part && isImageViewInput(part.input) ? part.input : null; + const output = + "output" in part && isImageViewOutput(part.output) ? part.output : null; + const [isExpanded, setIsExpanded] = useState(false); + + if (!input) return null; + + const imagePath = output?.path ?? input.path; + const name = getFileName(imagePath); + const showPreview = canPreview(imagePath); + + const summary = ( + <> + + Viewed {name} + + ); + + if (!showPreview) { + return ( + {}} + /> + ); + } + + return ( + +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {name} +
+

+ {imagePath} +

+
+ ); +}); diff --git a/src/components/chat/message-parts/tool/renderers/codex-mcp/index.tsx b/src/components/chat/message-parts/tool/renderers/codex-mcp/index.tsx new file mode 100644 index 00000000..19ab12a2 --- /dev/null +++ b/src/components/chat/message-parts/tool/renderers/codex-mcp/index.tsx @@ -0,0 +1,321 @@ +"use client"; + +import type { ReactNode } from "react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { ScrollShadow } from "@heroui/react"; +import { Copy01Icon, Tick01Icon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; + +import type { RendererProps } from "../../renderer"; +import { ToolLayout } from "../shared/tool-layout"; + +type CodexMcpInput = { + arguments: unknown; + server: string; + tool: string; +}; + +type CodexMcpOutput = { + durationMs: number | null; + error: unknown; + result: unknown; + status: string; +}; + +function isMcpInput(value: unknown): value is CodexMcpInput { + if (!value || typeof value !== "object") return false; + const v = value as Record; + return typeof v.server === "string" && typeof v.tool === "string"; +} + +function isMcpOutput(value: unknown): value is CodexMcpOutput { + if (!value || typeof value !== "object") return false; + const v = value as Record; + return typeof v.status === "string"; +} + +function formatDuration(ms: number) { + return `${(ms / 1000).toFixed(ms >= 1000 ? 1 : 2)}s`; +} + +function formatJson(value: unknown): string { + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +function extractMcpResultText(result: unknown): string | null { + if (!result || typeof result !== "object") return null; + + const r = result as Record; + if (Array.isArray(r.content)) { + const textParts = (r.content as Array>) + .filter((c) => c.type === "text" && typeof c.text === "string") + .map((c) => c.text as string); + if (textParts.length > 0) return textParts.join("\n"); + } + + return null; +} + +type McpImageContent = { + data: string; + mimeType: string; +}; + +function extractMcpResultImages(result: unknown): McpImageContent[] { + if (!result || typeof result !== "object") return []; + const r = result as Record; + if (!Array.isArray(r.content)) return []; + + return (r.content as Array>) + .filter( + (c) => + c.type === "image" && + typeof c.data === "string" && + typeof c.mimeType === "string", + ) + .map((c) => ({ + data: c.data as string, + mimeType: c.mimeType as string, + })); +} + +function extractMcpErrorText(error: unknown): string | null { + if (!error || typeof error !== "object") return null; + const e = error as Record; + if (typeof e.message === "string") return e.message; + return null; +} + +function buildSummary( + part: RendererProps["part"], + input: CodexMcpInput, + output: CodexMcpOutput | null, +): ReactNode { + const toolLabel = ( + + {input.server}:{input.tool} + + ); + + if (part.state === "output-denied") { + return <>MCP call denied; + } + + if (part.state === "output-error" || output?.status === "failed") { + return <>MCP call failed {toolLabel}; + } + + if (output?.status === "completed" || part.state === "output-available") { + return ( + <> + Called {toolLabel} + {output?.durationMs != null ? ( + + {formatDuration(output.durationMs)} + + ) : null} + + ); + } + + if (part.state === "approval-requested") { + return <>Call {toolLabel}; + } + + return <>Calling {toolLabel}; +} + +function JsonBlock({ label, value }: { label: string; value: unknown }) { + const [copied, setCopied] = useState(false); + const text = useMemo(() => formatJson(value), [value]); + + const handleCopy = useCallback(async () => { + await navigator.clipboard.writeText(text); + setCopied(true); + window.setTimeout(() => setCopied(false), 2000); + }, [text]); + + return ( +
+
+ + {label} + + +
+ +
+          {text}
+        
+
+
+ ); +} + +export const CodexMcpTool = memo(function CodexMcpTool({ + onApprove, + onDeny, + part, +}: RendererProps) { + const approval = "approval" in part ? part.approval : undefined; + const hasInput = "input" in part && part.input !== undefined; + const hasOutput = "output" in part && part.output !== undefined; + const mcpInput = hasInput && isMcpInput(part.input) ? part.input : null; + const mcpOutput = hasOutput && isMcpOutput(part.output) ? part.output : null; + const partErrorText = "errorText" in part ? part.errorText : undefined; + const approvalId = approval?.id; + const showApprovalActions = + part.state === "approval-requested" && approvalId && onApprove && onDeny; + + const isRunning = + part.state === "approval-responded" || + part.state === "input-available" || + part.state === "input-streaming"; + const isFinished = + part.state === "output-denied" || + part.state === "output-error" || + part.state === "output-available"; + const isError = + part.state === "output-denied" || + part.state === "output-error" || + mcpOutput?.status === "failed"; + const [isExpanded, setIsExpanded] = useState( + part.state === "approval-requested" || isRunning, + ); + + useEffect(() => { + setIsExpanded(part.state === "approval-requested" || isRunning); + }, [isRunning, part.state, part.toolCallId]); + + if (!mcpInput) return null; + + const summary = buildSummary(part, mcpInput, mcpOutput); + const errorText = mcpOutput ? extractMcpErrorText(mcpOutput.error) : null; + const resultText = mcpOutput ? extractMcpResultText(mcpOutput.result) : null; + const resultImages = mcpOutput + ? extractMcpResultImages(mcpOutput.result) + : []; + const hasArgs = + mcpInput.arguments !== undefined && mcpInput.arguments !== null; + const hasResult = + mcpOutput?.result !== undefined && mcpOutput?.result !== null; + + return ( + + + {mcpInput.server} / {mcpInput.tool} + + {mcpOutput.durationMs != null ? ( + + {formatDuration(mcpOutput.durationMs)} + + ) : ( + + {mcpOutput.status === "completed" ? "Success" : "Failed"} + + )} +
+ ) : null + } + actions={ + showApprovalActions ? ( +
+ + +
+ ) : undefined + } + > +
+ {hasArgs ? ( + + ) : null} + + {errorText ? ( +
+ {errorText} +
+ ) : null} + + {hasResult && !errorText ? ( + resultText ? ( +
+ + Result + + +
+                  {resultText}
+                
+
+
+ ) : ( + + ) + ) : null} + + {resultImages.length > 0 && ( +
+ {resultImages.map((img, i) => ( + /* eslint-disable-next-line @next/next/no-img-element */ + {`MCP + ))} +
+ )} +
+ + ); +}); diff --git a/src/components/chat/message-parts/tool/renderers/codex-plan/index.tsx b/src/components/chat/message-parts/tool/renderers/codex-plan/index.tsx new file mode 100644 index 00000000..e9f64eee --- /dev/null +++ b/src/components/chat/message-parts/tool/renderers/codex-plan/index.tsx @@ -0,0 +1,256 @@ +"use client"; + +import { memo, useCallback, useEffect, useMemo, useState } from "react"; +import { Button, ScrollShadow } from "@heroui/react"; +import { + ArrowDown01Icon, + Copy01Icon, + Download04Icon, + PlayIcon, + Task01Icon, + Tick01Icon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; + +import type { RendererProps } from "../../renderer"; +import { MarkdownContent } from "../../../text/markdown-content"; + +type PlanStep = { + status: "completed" | "inProgress" | "pending"; + step: string; +}; + +type CodexPlanOutput = { + steps?: PlanStep[] | null; + text: string; +}; + +function isPlanOutput(value: unknown): value is CodexPlanOutput { + if (!value || typeof value !== "object") return false; + return typeof (value as Record).text === "string"; +} + +const STEP_COLOR: Record = { + completed: "text-success", + inProgress: "text-primary sentinel-thinking-shimmer", + pending: "text-foreground/30", +}; + +function PlanSteps({ steps }: { steps: PlanStep[] }) { + return ( +
+ {steps.map((step, i) => ( +
+ + + + + {step.step} + +
+ ))} +
+ ); +} + +function extractProposedPlan(text: string): string { + const openTag = ""; + const closeTag = ""; + const openIdx = text.indexOf(openTag); + const closeIdx = text.indexOf(closeTag); + + if (openIdx !== -1 && closeIdx !== -1 && closeIdx > openIdx) { + return text.slice(openIdx + openTag.length, closeIdx).trim(); + } + + return text.trim(); +} + +const MAX_COLLAPSED_HEIGHT = 320; + +export const CodexPlanTool = memo(function CodexPlanTool({ + onStartPlanImplementation, + part, +}: RendererProps) { + const output = + "output" in part && isPlanOutput(part.output) ? part.output : null; + + const isStreaming = part.state === "input-streaming"; + const isDone = part.state === "output-available"; + const [isExpanded, setIsExpanded] = useState(false); + const [copied, setCopied] = useState(false); + + useEffect(() => { + setIsExpanded(false); + }, [part.toolCallId]); + + const body = useMemo( + () => extractProposedPlan(output?.text ?? ""), + [output?.text], + ); + + if (!body && !output?.steps?.length) return null; + + const hasSteps = output?.steps && output.steps.length > 0; + + const handleCopy = useCallback(async () => { + await navigator.clipboard.writeText(body); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }, [body]); + + const handleDownload = useCallback(() => { + const blob = new Blob([body], { type: "text/markdown" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "plan.md"; + a.click(); + URL.revokeObjectURL(url); + }, [body]); + + return ( +
+ {/* Header */} +
+ + {isStreaming + ? hasSteps + ? "Updating plan" + : "Generating plan" + : "Plan"} + {isStreaming && ( + + ... + + )} + +
+ {isDone && !hasSteps && ( + <> + + + + )} + +
+
+ + {/* Body */} +
+ + {hasSteps ? ( + + ) : ( +
+ +
+ )} +
+ {!isExpanded && isDone && ( +
+
+ +
+
+ )} + + {/* Gradient fade + expand button when collapsed */} +
+ + {/* Footer with implement button */} + {isDone && onStartPlanImplementation && ( +
+ +
+ )} +
+ ); +}); diff --git a/src/components/chat/message-parts/tool/renderers/codex-runtime.tsx b/src/components/chat/message-parts/tool/renderers/codex-runtime.tsx new file mode 100644 index 00000000..7ca606df --- /dev/null +++ b/src/components/chat/message-parts/tool/renderers/codex-runtime.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { Button } from "@heroui/react"; +import { memo, useEffect, useState } from "react"; + +import type { RendererProps } from "../renderer"; +import { ToolLayout } from "./shared/tool-layout"; + +function formatToolName(toolName: string) { + return toolName + .replace(/^codex_/, "") + .replace(/_/g, " ") + .replace(/\b\w/g, (char) => char.toUpperCase()); +} + +function renderJson(value: unknown) { + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +export const CodexRuntimeTool = memo(function CodexRuntimeTool({ + onApprove, + onDeny, + part, +}: RendererProps) { + if (part.type !== "dynamic-tool") { + return null; + } + + const hasInput = part.input !== undefined; + const hasOutput = part.output !== undefined; + const partErrorText = + "errorText" in part ? (part.errorText as string) : undefined; + const approvalId = + part.approval && + typeof part.approval === "object" && + "id" in part.approval && + typeof part.approval.id === "string" + ? part.approval.id + : null; + const showApprovalActions = + part.state === "approval-requested" && approvalId && onApprove && onDeny; + const isRunning = + part.state === "input-available" || + part.state === "input-streaming" || + part.state === "approval-responded"; + const isError = + part.state === "output-error" || part.state === "output-denied"; + const [isExpanded, setIsExpanded] = useState( + part.state === "approval-requested" || part.state === "output-error", + ); + + useEffect(() => { + setIsExpanded( + part.state === "approval-requested" || part.state === "output-error", + ); + }, [part.state, part.toolCallId]); + + return ( + + + +
+ ) : undefined + } + footer={{part.toolCallId}} + errorText={partErrorText} + isError={isError} + isExpandable={hasInput || hasOutput || !!partErrorText} + isExpanded={isExpanded} + isRunning={isRunning} + onExpandedChange={setIsExpanded} + summary={ + <> + {formatToolName(part.toolName)} + {showApprovalActions ? ( + requires approval + ) : null} + + } + > +
+ {hasInput ? ( +
+

Input

+
+              {renderJson(part.input)}
+            
+
+ ) : null} + + {hasOutput ? ( +
+

Output

+
+              {renderJson(part.output)}
+            
+
+ ) : null} +
+ + ); +}); diff --git a/src/components/chat/message-parts/tool/renderers/codex-shell/index.tsx b/src/components/chat/message-parts/tool/renderers/codex-shell/index.tsx new file mode 100644 index 00000000..9d3242a2 --- /dev/null +++ b/src/components/chat/message-parts/tool/renderers/codex-shell/index.tsx @@ -0,0 +1,394 @@ +"use client"; + +import type { ReactNode } from "react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { ScrollShadow } from "@heroui/react"; +import { Copy01Icon, Tick01Icon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; + +import type { RendererProps } from "../../renderer"; +import { ToolLayout } from "../shared/tool-layout"; +import { highlightToTokens, type ThemedToken } from "@/lib/syntax/highlighter"; +import { useResolvedTheme } from "@/lib/syntax/use-resolved-theme"; + +type CommandAction = { + type: string; + command: string; + path?: string; +}; + +type CodexShellInput = { + command: string; + commandActions?: CommandAction[]; + cwd: string; + reason?: string | null; +}; + +type CodexShellOutput = { + durationMs: number; + exitCode: number; + output: string; + processId?: string; + status: string; +}; + +function isCodexShellInput(value: unknown): value is CodexShellInput { + return ( + !!value && + typeof value === "object" && + "command" in value && + typeof (value as CodexShellInput).command === "string" && + "cwd" in value && + typeof (value as CodexShellInput).cwd === "string" + ); +} + +function isCodexShellOutput(value: unknown): value is CodexShellOutput { + if (!value || typeof value !== "object") return false; + const v = value as Record; + return typeof v.exitCode === "number" && typeof v.output === "string"; +} + +function extractDisplayCommand(input: CodexShellInput): string { + if (input.commandActions?.length) { + return input.commandActions.map((a) => a.command).join(" && "); + } + + const shellExec = input.command.match(/^\/bin\/(?:ba)?sh\s+-\w*c\s+'(.+)'$/s); + if (shellExec?.[1]) return shellExec[1]; + + const zshExec = input.command.match(/^\/bin\/zsh\s+-\w*c\s+'(.+)'$/s); + if (zshExec?.[1]) return zshExec[1]; + + return input.command; +} + +function truncateCommand(command: string, length = 60) { + if (command.length <= length) return command; + return `${command.slice(0, length)}...`; +} + +function formatDuration(durationMs: number) { + return `${(durationMs / 1000).toFixed(durationMs >= 1000 ? 1 : 2)}s`; +} + +function buildSummary( + part: RendererProps["part"], + input: CodexShellInput, + output: CodexShellOutput | null, +): ReactNode { + const cmd = truncateCommand(extractDisplayCommand(input)); + + if (part.state === "output-denied") { + return <>Command denied; + } + + if (part.state === "output-error") { + return ( + <> + Command failed $ {cmd} + + ); + } + + if (output) { + const succeeded = output.exitCode === 0; + return ( + <> + Ran $ {cmd} + + {formatDuration(output.durationMs)} + {!succeeded ? ` · exit ${output.exitCode}` : ""} + + + ); + } + + if (part.state === "approval-requested") { + return ( + <> + Run $ {cmd} + + ); + } + + return ( + <> + Running $ {cmd} + + ); +} + +function getTerminalText( + input: CodexShellInput, + output: CodexShellOutput | null, + state: RendererProps["part"]["state"], + errorText?: string, +) { + const displayCmd = extractDisplayCommand(input); + const lines = [`$ ${displayCmd}`]; + + if (state === "output-denied") { + lines.push("Execution denied."); + return lines.join("\n"); + } + + if (output) { + const text = output.output.trimEnd(); + if (text) lines.push(text); + else lines.push("(no output)"); + return lines.join("\n"); + } + + if (errorText) lines.push(errorText); + return lines.join("\n"); +} + +function tokenLinesToSegments( + tokenLines: ThemedToken[][] | null, +): Array> { + if (!tokenLines) return []; + return tokenLines.map((tokens) => + tokens.map((t) => ({ color: t.color, text: t.content })), + ); +} + +function TerminalOutput({ text }: { text: string }) { + const theme = useResolvedTheme(); + const [copied, setCopied] = useState(false); + const [hasBeenVisible, setHasBeenVisible] = useState(false); + const [syntaxLines, setSyntaxLines] = useState< + Array> + >([]); + const containerRef = useRef(null); + + const codeLines = useMemo(() => text.split("\n"), [text]); + + useEffect(() => { + const container = containerRef.current; + if (!container || hasBeenVisible) return; + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry?.isIntersecting) { + setHasBeenVisible(true); + observer.disconnect(); + } + }, + { rootMargin: "300px 0px" }, + ); + + observer.observe(container); + return () => observer.disconnect(); + }, [hasBeenVisible]); + + useEffect(() => { + if (!hasBeenVisible || codeLines.length === 0) return; + + let cancelled = false; + + const run = async () => { + try { + const tokens = await highlightToTokens(text, "shellscript", theme); + if (!cancelled) { + setSyntaxLines(tokenLinesToSegments(tokens)); + } + } catch { + // best-effort + } + }; + + void run(); + return () => { + cancelled = true; + }; + }, [hasBeenVisible, theme, text, codeLines.length]); + + const handleCopy = useCallback(async () => { + const outputOnly = codeLines.slice(1).join("\n"); + await navigator.clipboard.writeText(outputOnly || text); + setCopied(true); + window.setTimeout(() => setCopied(false), 2000); + }, [text, codeLines]); + + return ( +
+ + +
+ {codeLines.map((line, idx) => ( +
+ {syntaxLines[idx] ? ( + syntaxLines[idx].map((seg, si) => ( + + {seg.text} + + )) + ) : ( + + {line} + + )} +
+ ))} +
+
+
+ ); +} + +export const CodexShellTool = memo(function CodexShellTool({ + onApprove, + onApproveWithDecision, + onDeny, + part, +}: RendererProps) { + const approval = "approval" in part ? part.approval : undefined; + const hasInput = "input" in part && part.input !== undefined; + const hasOutput = "output" in part && part.output !== undefined; + const shellInput = + hasInput && isCodexShellInput(part.input) ? part.input : null; + const shellOutput = + hasOutput && isCodexShellOutput(part.output) ? part.output : null; + const partErrorText = "errorText" in part ? part.errorText : undefined; + const approvalId = approval?.id; + const showApprovalActions = + part.state === "approval-requested" && approvalId && onApprove && onDeny; + + const isRunning = + part.state === "approval-responded" || + part.state === "input-available" || + part.state === "input-streaming" || + (part.state === "output-available" && shellOutput?.status !== "completed"); + const isFinished = + part.state === "output-denied" || + part.state === "output-error" || + part.state === "output-available"; + const isError = + part.state === "output-denied" || + part.state === "output-error" || + (shellOutput != null && shellOutput.exitCode !== 0); + const [isExpanded, setIsExpanded] = useState( + part.state === "approval-requested" || isRunning, + ); + + useEffect(() => { + setIsExpanded(part.state === "approval-requested" || isRunning); + }, [isRunning, part.state, part.toolCallId]); + + if (!shellInput) return null; + + const summary = buildSummary(part, shellInput, shellOutput); + const terminalText = getTerminalText( + shellInput, + shellOutput, + part.state, + partErrorText, + ); + + const footer = shellOutput ? ( +
+ {shellInput.cwd && ( + + cwd: {shellInput.cwd} + + )} +
+ {formatDuration(shellOutput.durationMs)} + + {shellOutput.exitCode === 0 + ? "Success" + : `Exit ${shellOutput.exitCode}`} + +
+
+ ) : null; + + return ( + + {shellInput.reason && ( +

+ {shellInput.reason} +

+ )} +
+ + + + +
+
+ ) : undefined + } + > + + + ); +}); diff --git a/src/components/chat/message-parts/tool/renderers/codex-status/index.tsx b/src/components/chat/message-parts/tool/renderers/codex-status/index.tsx new file mode 100644 index 00000000..c14294e0 --- /dev/null +++ b/src/components/chat/message-parts/tool/renderers/codex-status/index.tsx @@ -0,0 +1,204 @@ +"use client"; + +import { memo, useState } from "react"; +import { ScrollShadow } from "@heroui/react"; +import { Icon } from "@iconify/react"; + +import type { RendererProps } from "../../renderer"; +import { ToolLayout } from "../shared/tool-layout"; + +type ReviewModeInput = { + review: string; + transition: string; +}; + +function isReviewModeInput(value: unknown): value is ReviewModeInput { + if (!value || typeof value !== "object") return false; + const v = value as Record; + return typeof v.transition === "string"; +} + +export const CodexReviewModeTool = memo(function CodexReviewModeTool({ + part, +}: RendererProps) { + const input = + "input" in part && isReviewModeInput(part.input) ? part.input : null; + const [isExpanded, setIsExpanded] = useState(false); + + if (!input) return null; + + const entered = input.transition === "enteredReviewMode"; + const hasReviewText = Boolean(input.review?.trim()); + + const summary = ( + <> + + {entered ? "Entered review mode" : "Exited review mode"} + + ); + + if (!hasReviewText) { + return ( + {}} + /> + ); + } + + return ( + + +
+          {input.review}
+        
+
+
+ ); +}); + +export const CodexContextCompactionTool = memo( + function CodexContextCompactionTool({ part }: RendererProps) { + const isRunning = + part.state === "input-available" || part.state === "input-streaming"; + + return ( +
+ + + {isRunning ? "Compacting context" : "Context compacted"} + +
+ ); + }, +); + +type CollabAgentInput = { + prompt: string | null; + receiverThreadIds: string[]; + senderThreadId: string; + tool: string; +}; + +type CollabAgentOutput = { + agentsStates: Record; + status: string; +}; + +function isCollabInput(value: unknown): value is CollabAgentInput { + if (!value || typeof value !== "object") return false; + const v = value as Record; + return typeof v.tool === "string"; +} + +function isCollabOutput(value: unknown): value is CollabAgentOutput { + if (!value || typeof value !== "object") return false; + const v = value as Record; + return typeof v.status === "string"; +} + +function AgentStatesBadge({ states }: { states: Record }) { + const entries = Object.entries(states); + if (entries.length === 0) return null; + + return ( +
+ {entries.map(([agentId, state]) => { + const statusStr = + state && typeof state === "object" && "status" in state + ? String((state as { status: unknown }).status) + : "unknown"; + const statusColor = + statusStr === "completed" + ? "bg-success/10 text-success" + : statusStr === "failed" + ? "bg-danger/10 text-danger" + : "bg-foreground/5 text-foreground/60"; + return ( + + {agentId.slice(0, 8)} + {statusStr} + + ); + })} +
+ ); +} + +export const CodexCollabAgentTool = memo(function CodexCollabAgentTool({ + part, +}: RendererProps) { + const input = + "input" in part && isCollabInput(part.input) ? part.input : null; + const output = + "output" in part && isCollabOutput(part.output) ? part.output : null; + const [isExpanded, setIsExpanded] = useState(false); + + const isRunning = + part.state === "input-available" || part.state === "input-streaming"; + const isError = part.state === "output-error"; + const isDone = part.state === "output-available"; + const agentCount = input?.receiverThreadIds?.length ?? 0; + + const label = (() => { + if (isError) return "Agent collaboration failed"; + if (isDone) + return `Collaborated with ${agentCount} agent${agentCount !== 1 ? "s" : ""}`; + if (isRunning) + return `Collaborating with ${agentCount} agent${agentCount !== 1 ? "s" : ""}`; + return "Agent collaboration"; + })(); + + const hasDetails = + output?.agentsStates && Object.keys(output.agentsStates).length > 0; + + const summary = ( + <> + + {label} + + ); + + return ( + + {hasDetails && ( +
+ {input?.prompt && ( +

{input.prompt}

+ )} + +
+ )} +
+ ); +}); diff --git a/src/components/chat/message-parts/tool/renderers/codex-user-input/index.tsx b/src/components/chat/message-parts/tool/renderers/codex-user-input/index.tsx new file mode 100644 index 00000000..ce2ead6b --- /dev/null +++ b/src/components/chat/message-parts/tool/renderers/codex-user-input/index.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { memo, useCallback, useState } from "react"; +import { Icon } from "@iconify/react"; + +import type { RendererProps } from "../../renderer"; +import { ToolLayout } from "../shared/tool-layout"; + +type UserInputInput = { + prompt: string; + requestId: string; +}; + +function isUserInputInput(value: unknown): value is UserInputInput { + if (!value || typeof value !== "object") return false; + const v = value as Record; + return typeof v.prompt === "string" && typeof v.requestId === "string"; +} + +export const CodexUserInputTool = memo(function CodexUserInputTool({ + onApprove, + part, +}: RendererProps) { + const isWaiting = part.state === "approval-requested"; + const isDone = + part.state === "output-available" || part.state === "approval-responded"; + + const input = + "input" in part && isUserInputInput(part.input) ? part.input : null; + const approval = "approval" in part ? part.approval : undefined; + const approvalId = approval?.id; + const [response, setResponse] = useState(""); + + const handleSubmit = useCallback(() => { + const trimmedResponse = response.trim(); + if (!approvalId || !trimmedResponse) return; + onApprove?.(approvalId, trimmedResponse); + }, [approvalId, onApprove, response]); + + if (!input) return null; + + const summary = ( + <> + + {isDone ? "Input provided" : "Codex is requesting input"} + + ); + + return ( + {}} + > + {isWaiting && ( +
+

{input.prompt}

+