From 0bc7e1c781777351fbebe84058e3d22ddaba10cd Mon Sep 17 00:00:00 2001 From: Mohamed Achaq Date: Tue, 24 Mar 2026 12:16:19 +0100 Subject: [PATCH 1/5] feat: enhance models page with Codex engine integration and attachment manager tests - Added Codex engine status display and runtime information to the ModelsPage component. - Implemented a new AttachmentManager test suite to ensure proper rendering and error handling. - Refactored AttachmentManager to remove deprecated warning handling. - Updated chat components to support engine selection and improved user experience with enhanced UI elements. --- src/app/(app)/settings/models/page.tsx | 140 ++ .../chat/attachment-manager.test.tsx | 49 + src/components/chat/attachment-manager.tsx | 8 - src/components/chat/chat-composer-helpers.ts | 63 +- src/components/chat/chat-composer/index.tsx | 174 +- src/components/chat/chat-composer/types.ts | 9 + .../chat/chat-composer/use-composer-editor.ts | 13 +- .../chat/chat-composer/use-model-selection.ts | 362 ++-- .../chat-composer/use-persist-selection.ts | 40 + .../chat/chat-composer/use-plan-mode.ts | 30 +- src/components/chat/chat-message.tsx | 35 +- src/components/chat/composer-toolbar.tsx | 220 +- .../chat/composer-workspace-bar.tsx | 2 +- .../chat/message-parts/tool/registry.test.ts | 156 ++ .../chat/message-parts/tool/registry.ts | 30 + .../chat/message-parts/tool/renderer.ts | 12 +- .../renderers/codex-file-change/index.tsx | 490 +++++ .../tool/renderers/codex-image-view/index.tsx | 103 + .../tool/renderers/codex-mcp/index.tsx | 324 +++ .../tool/renderers/codex-plan/index.tsx | 256 +++ .../tool/renderers/codex-runtime.tsx | 121 ++ .../tool/renderers/codex-shell/index.tsx | 400 ++++ .../tool/renderers/codex-status/index.tsx | 208 ++ .../tool/renderers/codex-user-input/index.tsx | 83 + .../tool/renderers/codex-web-search/index.tsx | 184 ++ src/components/chat/model-selector.tsx | 256 ++- src/components/chat/new-thread-screen.tsx | 102 +- src/components/chat/thread-screen.tsx | 179 +- src/hooks/use-thread-chat.test.ts | 96 +- src/hooks/use-thread-chat.ts | 58 +- src/lib/ai/chat/engines/codex-app-server.ts | 949 +++++++++ src/lib/ai/chat/engines/types.ts | 66 + src/lib/ai/chat/persistence.ts | 48 +- .../chat/runtime/codex-event-helpers.test.ts | 100 + .../ai/chat/runtime/codex-event-helpers.ts | 115 ++ src/lib/ai/chat/runtime/codex-helpers.test.ts | 44 + src/lib/ai/chat/runtime/codex-helpers.ts | 74 + src/lib/ai/chat/runtime/codex.ts | 1832 +++++++++++++++++ src/lib/ai/chat/runtime/parse-request.test.ts | 26 + src/lib/ai/chat/runtime/parse-request.ts | 5 + src/lib/ai/chat/runtime/run-thread-chat.ts | 13 +- src/lib/ai/chat/session-server.ts | 28 +- src/lib/ai/chat/session-types.ts | 3 +- src/lib/ai/chat/types.ts | 2 + src/lib/ai/messages/branches.test.ts | 50 + src/lib/ai/messages/branches.ts | 20 +- src/lib/ai/messages/types.ts | 80 +- src/lib/threads/cache.ts | 5 + src/schemas/chat-preferences.schema.ts | 12 +- src/schemas/workspace-thread.schema.ts | 5 + src/server/api/root.ts | 2 + .../api/routers/chat-preferences.test.ts | 36 + src/server/api/routers/chat-preferences.ts | 34 +- src/server/api/routers/engines.test.ts | 102 + src/server/api/routers/engines.ts | 280 +++ src/server/api/routers/threads.ts | 8 + src/server/db/enums.ts | 3 + src/server/db/index.ts | 23 + src/server/db/schema.ts | 6 + 59 files changed, 7621 insertions(+), 553 deletions(-) create mode 100644 src/components/chat/attachment-manager.test.tsx create mode 100644 src/components/chat/message-parts/tool/renderers/codex-file-change/index.tsx create mode 100644 src/components/chat/message-parts/tool/renderers/codex-image-view/index.tsx create mode 100644 src/components/chat/message-parts/tool/renderers/codex-mcp/index.tsx create mode 100644 src/components/chat/message-parts/tool/renderers/codex-plan/index.tsx create mode 100644 src/components/chat/message-parts/tool/renderers/codex-runtime.tsx create mode 100644 src/components/chat/message-parts/tool/renderers/codex-shell/index.tsx create mode 100644 src/components/chat/message-parts/tool/renderers/codex-status/index.tsx create mode 100644 src/components/chat/message-parts/tool/renderers/codex-user-input/index.tsx create mode 100644 src/components/chat/message-parts/tool/renderers/codex-web-search/index.tsx create mode 100644 src/lib/ai/chat/engines/codex-app-server.ts create mode 100644 src/lib/ai/chat/engines/types.ts create mode 100644 src/lib/ai/chat/runtime/codex-event-helpers.test.ts create mode 100644 src/lib/ai/chat/runtime/codex-event-helpers.ts create mode 100644 src/lib/ai/chat/runtime/codex-helpers.test.ts create mode 100644 src/lib/ai/chat/runtime/codex-helpers.ts create mode 100644 src/lib/ai/chat/runtime/codex.ts create mode 100644 src/lib/ai/messages/branches.test.ts create mode 100644 src/server/api/routers/engines.test.ts create mode 100644 src/server/api/routers/engines.ts diff --git a/src/app/(app)/settings/models/page.tsx b/src/app/(app)/settings/models/page.tsx index f10400a7..cde63d25 100644 --- a/src/app/(app)/settings/models/page.tsx +++ b/src/app/(app)/settings/models/page.tsx @@ -87,7 +87,13 @@ 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 +282,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/components/chat/attachment-manager.test.tsx b/src/components/chat/attachment-manager.test.tsx new file mode 100644 index 00000000..a952f931 --- /dev/null +++ b/src/components/chat/attachment-manager.test.tsx @@ -0,0 +1,49 @@ +import { describe, expect, it } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { detectAttachmentType } from "@/lib/files/chat-attachment-types"; + +import { AttachmentManager } from "./attachment-manager"; + +describe("AttachmentManager", () => { + 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..f3175e96 100644 --- a/src/components/chat/chat-composer/use-model-selection.ts +++ b/src/components/chat/chat-composer/use-model-selection.ts @@ -1,70 +1,56 @@ 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 +60,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 +96,85 @@ 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, - ) - : null, + nextModel ? 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 +183,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 +211,7 @@ export function useModelSelection({ } const nextReasoningEffort = resolveReasoningEffort( - selectedModel.provider, - selectedModel.modelId, + selectedModel, selectedReasoningEffort, ); @@ -222,54 +220,120 @@ 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, - ); - if (!nextModel) return; + const nextModel = availableModels.find((model) => model.modelId === modelKey); + 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..a593228e 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,5 @@ 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..1cb93247 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,12 @@ 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 +289,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 +418,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 +534,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 +575,11 @@ type ChatMessageProps = { }; export const ChatMessage = memo(function ChatMessage({ + chatEngine, message, isStreaming = false, onApproveTool, + onApproveToolWithDecision, onAnswerPlanQuestions, onDenyTool, onStartPlanImplementation, @@ -567,6 +591,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..b79e2698 --- /dev/null +++ b/src/components/chat/message-parts/tool/renderers/codex-file-change/index.tsx @@ -0,0 +1,490 @@ +"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..7e341c17 --- /dev/null +++ b/src/components/chat/message-parts/tool/renderers/codex-mcp/index.tsx @@ -0,0 +1,324 @@ +"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..270fd755 --- /dev/null +++ b/src/components/chat/message-parts/tool/renderers/codex-runtime.tsx @@ -0,0 +1,121 @@ +"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..88897adf --- /dev/null +++ b/src/components/chat/message-parts/tool/renderers/codex-shell/index.tsx @@ -0,0 +1,400 @@ +"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..920359ec --- /dev/null +++ b/src/components/chat/message-parts/tool/renderers/codex-status/index.tsx @@ -0,0 +1,208 @@ +"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..5a80699d --- /dev/null +++ b/src/components/chat/message-parts/tool/renderers/codex-user-input/index.tsx @@ -0,0 +1,83 @@ +"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}

+