diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 76133712d4..9b58127213 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -125,6 +125,7 @@ import { resolveSelectableProvider, } from "../providerModels"; import { useSettings } from "../hooks/useSettings"; +import { getComposerProviderState } from "../composerProviderState"; import { resolveAppModelSelection } from "../modelSelection"; import { isTerminalFocused } from "../lib/terminalFocus"; import { @@ -166,7 +167,6 @@ import { ComposerPendingApprovalPanel } from "./chat/ComposerPendingApprovalPane import { ComposerPendingUserInputPanel } from "./chat/ComposerPendingUserInputPanel"; import { ComposerPlanFollowUpBanner } from "./chat/ComposerPlanFollowUpBanner"; import { - getComposerProviderState, renderProviderTraitsMenuContent, renderProviderTraitsPicker, } from "./chat/composerProviderRegistry"; diff --git a/apps/web/src/components/chat/composerProviderRegistry.tsx b/apps/web/src/components/chat/composerProviderRegistry.tsx index 3307442db2..e9037e3cdd 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.tsx @@ -4,34 +4,10 @@ import { type ServerProviderModel, type ThreadId, } from "@t3tools/contracts"; -import { isClaudeUltrathinkPrompt, resolveEffort } from "@t3tools/shared/model"; import type { ReactNode } from "react"; -import { getProviderModelCapabilities } from "../../providerModels"; import { TraitsMenuContent, TraitsPicker } from "./TraitsPicker"; -import { - normalizeClaudeModelOptionsWithCapabilities, - normalizeCodexModelOptionsWithCapabilities, -} from "@t3tools/shared/model"; - -export type ComposerProviderStateInput = { - provider: ProviderKind; - model: string; - models: ReadonlyArray; - prompt: string; - modelOptions: ProviderModelOptions | null | undefined; -}; - -export type ComposerProviderState = { - provider: ProviderKind; - promptEffort: string | null; - modelOptionsForDispatch: ProviderModelOptions[ProviderKind] | undefined; - composerFrameClassName?: string; - composerSurfaceClassName?: string; - modelPickerIconClassName?: string; -}; type ProviderRegistryEntry = { - getState: (input: ComposerProviderStateInput) => ComposerProviderState; renderTraitsMenuContent: (input: { threadId: ThreadId; model: string; @@ -50,49 +26,8 @@ type ProviderRegistryEntry = { }) => ReactNode; }; -function getProviderStateFromCapabilities( - input: ComposerProviderStateInput, -): ComposerProviderState { - const { provider, model, models, prompt, modelOptions } = input; - const caps = getProviderModelCapabilities(models, model, provider); - const providerOptions = modelOptions?.[provider]; - - // Resolve effort - const rawEffort = providerOptions - ? "effort" in providerOptions - ? providerOptions.effort - : "reasoningEffort" in providerOptions - ? providerOptions.reasoningEffort - : null - : null; - - const promptEffort = resolveEffort(caps, rawEffort) ?? null; - - // Normalize options for dispatch - const normalizedOptions = - provider === "codex" - ? normalizeCodexModelOptionsWithCapabilities(caps, providerOptions) - : normalizeClaudeModelOptionsWithCapabilities(caps, providerOptions); - - // Ultrathink styling (driven by capabilities data, not provider identity) - const ultrathinkActive = - caps.promptInjectedEffortLevels.length > 0 && isClaudeUltrathinkPrompt(prompt); - - return { - provider, - promptEffort, - modelOptionsForDispatch: normalizedOptions, - ...(ultrathinkActive ? { composerFrameClassName: "ultrathink-frame" } : {}), - ...(ultrathinkActive - ? { composerSurfaceClassName: "shadow-[0_0_0_1px_rgba(255,255,255,0.04)_inset]" } - : {}), - ...(ultrathinkActive ? { modelPickerIconClassName: "ultrathink-chroma" } : {}), - }; -} - const composerProviderRegistry: Record = { codex: { - getState: (input) => getProviderStateFromCapabilities(input), renderTraitsMenuContent: ({ threadId, model, @@ -124,7 +59,6 @@ const composerProviderRegistry: Record = { ), }, claudeAgent: { - getState: (input) => getProviderStateFromCapabilities(input), renderTraitsMenuContent: ({ threadId, model, @@ -157,10 +91,6 @@ const composerProviderRegistry: Record = { }, }; -export function getComposerProviderState(input: ComposerProviderStateInput): ComposerProviderState { - return composerProviderRegistry[input.provider].getState(input); -} - export function renderProviderTraitsMenuContent(input: { provider: ProviderKind; threadId: ThreadId; diff --git a/apps/web/src/components/chat/composerProviderRegistry.test.tsx b/apps/web/src/composerProviderState.test.ts similarity index 99% rename from apps/web/src/components/chat/composerProviderRegistry.test.tsx rename to apps/web/src/composerProviderState.test.ts index 4dc79832d4..4499554957 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.test.tsx +++ b/apps/web/src/composerProviderState.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { ServerProviderModel } from "@t3tools/contracts"; -import { getComposerProviderState } from "./composerProviderRegistry"; +import { getComposerProviderState } from "./composerProviderState"; const CODEX_MODELS: ReadonlyArray = [ { diff --git a/apps/web/src/composerProviderState.ts b/apps/web/src/composerProviderState.ts new file mode 100644 index 0000000000..924dfefa2b --- /dev/null +++ b/apps/web/src/composerProviderState.ts @@ -0,0 +1,62 @@ +import { + type ProviderKind, + type ProviderModelOptions, + type ServerProviderModel, +} from "@t3tools/contracts"; +import { + isClaudeUltrathinkPrompt, + normalizeClaudeModelOptionsWithCapabilities, + normalizeCodexModelOptionsWithCapabilities, + resolveEffort, +} from "@t3tools/shared/model"; +import { getProviderModelCapabilities } from "./providerModels"; + +export type ComposerProviderStateInput = { + provider: ProviderKind; + model: string; + models: ReadonlyArray; + prompt: string; + modelOptions: ProviderModelOptions | null | undefined; +}; + +export type ComposerProviderState = { + provider: ProviderKind; + promptEffort: string | null; + modelOptionsForDispatch: ProviderModelOptions[ProviderKind] | undefined; + composerFrameClassName?: string; + composerSurfaceClassName?: string; + modelPickerIconClassName?: string; +}; + +export function getComposerProviderState(input: ComposerProviderStateInput): ComposerProviderState { + const { provider, model, models, prompt, modelOptions } = input; + const caps = getProviderModelCapabilities(models, model, provider); + const providerOptions = modelOptions?.[provider]; + + const rawEffort = providerOptions + ? "effort" in providerOptions + ? providerOptions.effort + : "reasoningEffort" in providerOptions + ? providerOptions.reasoningEffort + : null + : null; + + const promptEffort = resolveEffort(caps, rawEffort) ?? null; + const modelOptionsForDispatch = + provider === "codex" + ? normalizeCodexModelOptionsWithCapabilities(caps, providerOptions) + : normalizeClaudeModelOptionsWithCapabilities(caps, providerOptions); + const ultrathinkActive = + caps.promptInjectedEffortLevels.length > 0 && isClaudeUltrathinkPrompt(prompt); + + return { + provider, + promptEffort, + modelOptionsForDispatch, + ...(ultrathinkActive ? { composerFrameClassName: "ultrathink-frame" } : {}), + ...(ultrathinkActive + ? { composerSurfaceClassName: "shadow-[0_0_0_1px_rgba(255,255,255,0.04)_inset]" } + : {}), + ...(ultrathinkActive ? { modelPickerIconClassName: "ultrathink-chroma" } : {}), + }; +} diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index 98e2884adf..6ceb9b39ea 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -5,8 +5,8 @@ import { type ServerProvider, } from "@t3tools/contracts"; import { normalizeModelSlug, resolveSelectableModel } from "@t3tools/shared/model"; -import { getComposerProviderState } from "./components/chat/composerProviderRegistry"; import { UnifiedSettings } from "@t3tools/contracts/settings"; +import { getComposerProviderState } from "./composerProviderState"; import { getDefaultServerModel, getProviderModels, diff --git a/docs/web-circular-deps-plan.md b/docs/web-circular-deps-plan.md new file mode 100644 index 0000000000..4cd21f6c2f --- /dev/null +++ b/docs/web-circular-deps-plan.md @@ -0,0 +1,60 @@ +# Web Circular Dependency Cleanup Plan + +## Goal + +Break the verified web import cycle reported in [`ctx-analysis.md`](../ctx-analysis.md) without changing composer behavior. + +## Evidence + +### ctx report + +- [`ctx-analysis.md`](../ctx-analysis.md) identifies this cycle: + - `apps/web/src/components/chat/TraitsPicker.tsx` + - `apps/web/src/composerDraftStore.ts` + - `apps/web/src/modelSelection.ts` + - `apps/web/src/components/chat/composerProviderRegistry.tsx` + +### Live source references + +- `apps/web/src/modelSelection.ts:171` resolves settings-backed model selection and currently reaches into the chat registry for provider-state normalization. +- `apps/web/src/components/chat/composerProviderRegistry.tsx:53` contains pure provider-state logic, while `apps/web/src/components/chat/composerProviderRegistry.tsx:93` also owns React rendering for traits controls. +- `apps/web/src/components/chat/TraitsPicker.tsx:163` reads the composer store during render. +- `apps/web/src/composerDraftStore.ts:628` calls back into model selection when deriving effective draft state. + +### SCIP checks + +The local SCIP index confirms the same edge chain: + +```text +tools/scip-query deps apps/web/src/modelSelection.ts + -> apps/web/src/components/chat/composerProviderRegistry.tsx + +tools/scip-query deps apps/web/src/components/chat/composerProviderRegistry.tsx + -> apps/web/src/components/chat/TraitsPicker.tsx + +tools/scip-query deps apps/web/src/components/chat/TraitsPicker.tsx + -> apps/web/src/composerDraftStore.ts + +tools/scip-query deps apps/web/src/composerDraftStore.ts + -> apps/web/src/modelSelection.ts +``` + +`tools/scip-query refs getComposerProviderState` also shows that the pure provider-state helper is consumed outside the registry, which is the coupling we want to undo. + +## Checklist + +- [x] Extract the pure provider-state normalization from `apps/web/src/components/chat/composerProviderRegistry.tsx:53` into a non-React helper module that does not import traits UI. +- [x] Update `apps/web/src/modelSelection.ts:171` to use the new pure helper directly. +- [x] Keep `apps/web/src/components/chat/composerProviderRegistry.tsx:93` focused on rendering traits UI and provider-specific composition. +- [x] Move or update the provider-state tests so they exercise the extracted helper directly. +- [x] Re-run graph checks plus `bun fmt`, `bun lint`, and `bun typecheck`, and confirm the web cycle no longer appears. + +## Verification + +- `bun fmt` +- `bun lint` +- `bun typecheck` +- `mcp__socraticode__codebase_graph_circular` + - Remaining cycle: `packages/contracts/src/model.ts -> packages/contracts/src/orchestration.ts -> packages/contracts/src/model.ts` +- `tools/scip-query deps apps/web/src/modelSelection.ts` + - Now points to `apps/web/src/composerProviderState.ts` instead of `apps/web/src/components/chat/composerProviderRegistry.tsx`