From 4c97fbb77af2d3e19acf18cc023cecb12e60dc5b Mon Sep 17 00:00:00 2001 From: lukemaj Date: Fri, 25 Sep 2026 20:16:15 +0200 Subject: [PATCH 1/5] wip: add Prism role preferences and capacity settings (#21) --- .../settings/PrismSettings.logic.test.ts | 192 +++++++++ .../settings/PrismSettings.logic.ts | 96 +++++ .../src/components/settings/PrismSettings.tsx | 391 ++++++++++++++++++ .../settings/SettingsSidebarNav.tsx | 2 + .../src/components/settings/settingsSearch.ts | 10 + apps/web/src/routeTree.gen.ts | 21 + apps/web/src/routes/settings.prism.tsx | 4 + docs/fork.md | 4 + scripts/fork-upstream-edits.txt | 5 + 9 files changed, 725 insertions(+) create mode 100644 apps/web/src/components/settings/PrismSettings.logic.test.ts create mode 100644 apps/web/src/components/settings/PrismSettings.logic.ts create mode 100644 apps/web/src/components/settings/PrismSettings.tsx create mode 100644 apps/web/src/routes/settings.prism.tsx diff --git a/apps/web/src/components/settings/PrismSettings.logic.test.ts b/apps/web/src/components/settings/PrismSettings.logic.test.ts new file mode 100644 index 0000000000..521b8f66f1 --- /dev/null +++ b/apps/web/src/components/settings/PrismSettings.logic.test.ts @@ -0,0 +1,192 @@ +import { + DEFAULT_SERVER_SETTINGS, + EnvironmentId, + ProjectId, + ProviderInstanceId, +} from "@t3tools/contracts"; +import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; +import { describe, expect, it } from "vite-plus/test"; +import { + movePrismPreference, + planPrismModelsPatch, + prismEffortOptions, + prismModelChoices, +} from "./PrismSettings.logic"; +import { resolveSettingsScope } from "./settingsScope"; +import { persistScopedSettingsPatch } from "./scopedSettings"; + +const first = { instanceId: ProviderInstanceId.make("codex"), model: "first", effort: "high" }; +const second = { instanceId: ProviderInstanceId.make("opencode"), model: "second" }; +const roles = { + ...DEFAULT_SERVER_SETTINGS.prismRoles, + worker: { + ...DEFAULT_SERVER_SETTINGS.prismRoles.worker, + instructions: "Keep this kit", + skills: ["test"], + models: [first], + }, +}; +const environment = (id: string, connected = true) => ({ + environmentId: EnvironmentId.make(id), + label: id, + connection: { phase: connected ? ("connected" as const) : ("offline" as const) }, + serverConfig: { + settings: { ...DEFAULT_SERVER_SETTINGS, prismRoles: roles }, + environment: { capabilities: { projectSettingsOverrides: true } }, + }, +}); + +describe("Prism role preferences", () => { + it("moves preferences with their effort and does not mutate or wrap the list", () => { + const original = [first, second]; + expect(movePrismPreference(original, 0, 1)).toEqual([second, first]); + expect(original).toEqual([first, second]); + expect(movePrismPreference(original, 0, -1)).toBe(original); + expect(movePrismPreference(original, 1, 1)).toBe(original); + expect(movePrismPreference([], 0, 1)).toEqual([]); + }); + + it("uses provider effort choices and excludes prompt-only controls", () => { + expect( + prismEffortOptions({ + optionDescriptors: [ + { + id: "agent", + label: "Agent", + type: "select", + options: [{ id: "build", label: "Build" }], + }, + { + id: "variant", + label: "Reasoning", + type: "select", + options: [ + { id: "deep", label: "Deep" }, + { id: "prompt", label: "Prompt" }, + ], + promptInjectedValues: ["prompt"], + }, + ], + }), + ).toEqual([{ id: "deep", label: "Deep" }]); + expect(prismEffortOptions(null)).toEqual([]); + }); + + it("replaces model arrays, including clearing them, without changing role kits or offline environments", () => { + const envs = [environment("one"), environment("two", false)]; + const scope = resolveSettingsScope({}, [], envs); + const plan = planPrismModelsPatch(scope, envs, "worker", [second]); + expect(plan.serverWrites.map((write) => write.environmentId)).toEqual(["one"]); + const next = applyServerSettingsPatch( + envs[0]!.serverConfig.settings, + plan.serverWrites[0]!.patch, + ); + expect(next.prismRoles.worker).toEqual({ ...roles.worker, models: [second] }); + expect(next.prismRoles.planner).toEqual(roles.planner); + const clear = planPrismModelsPatch(scope, envs, "worker", []); + expect( + applyServerSettingsPatch(next, clear.serverWrites[0]!.patch).prismRoles.worker.models, + ).toEqual([]); + }); + + it("preserves each project's own kit when writing a project override", () => { + const env = environment("one"); + const projectId = ProjectId.make("project"); + const member = { + id: projectId, + environmentId: env.environmentId, + title: "Project", + workspaceRoot: "/repo", + physicalProjectKey: "one:/repo", + environmentLabel: "one", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-09-25T00:00:00.000Z", + updatedAt: "2026-09-25T00:00:00.000Z", + }; + const scopedEnv = { + ...env, + serverConfig: { + ...env.serverConfig, + settings: { + ...env.serverConfig.settings, + projectSettingsOverrides: { + [projectId]: { + prismRoles: { + ...roles, + worker: { ...roles.worker, instructions: "Project instructions" }, + }, + }, + }, + }, + }, + }; + const group = { + ...member, + projectKey: "group", + displayName: "Project", + memberProjects: [member], + memberProjectRefs: [{ environmentId: env.environmentId, projectId }], + groupedProjectCount: 1, + environmentPresence: "remote-only" as const, + allRemoteMembersAreDesktopLocal: false, + allRemoteMembersAreWsl: false, + remoteEnvironmentLabels: ["one"], + }; + const scope = resolveSettingsScope({ project: "group" }, [group], [scopedEnv]); + const plan = planPrismModelsPatch(scope, [scopedEnv], "worker", [second, first]); + const next = applyServerSettingsPatch( + scopedEnv.serverConfig.settings, + plan.serverWrites[0]!.patch, + ); + expect(next.prismRoles).toEqual(roles); + expect(next.projectSettingsOverrides[projectId]?.prismRoles?.worker).toEqual({ + ...roles.worker, + instructions: "Project instructions", + models: [second, first], + }); + expect(next.projectSettingsOverrides[projectId]?.prismRoles?.reviewer).toEqual(roles.reviewer); + }); + + it("reports disconnected scopes and partial save failures", async () => { + const offline = environment("offline", false); + const scope = resolveSettingsScope({ machine: offline.environmentId }, [], [offline]); + expect(planPrismModelsPatch(scope, [offline], "worker", []).unavailableReason).toBeTruthy(); + const envs = [environment("one"), environment("two")]; + const plan = planPrismModelsPatch(resolveSettingsScope({}, [], envs), envs, "worker", [first]); + const result = await persistScopedSettingsPatch( + plan, + async ({ environmentId }) => ({ _tag: environmentId === "one" ? "Success" : "Failure" }), + () => {}, + ); + expect(result.savedEnvironmentCount).toBe(1); + expect(result.failedEnvironments.map((env) => env.label)).toEqual(["two"]); + }); +}); + +it("offers only enabled models available across the selected scope", () => { + const entry = { + instanceId: first.instanceId, + displayName: "Codex", + enabled: true, + isAvailable: true, + models: [], + }; + const options = new Map([ + [ + first.instanceId, + [ + { slug: "enabled", name: "Enabled" }, + { slug: "unavailable", name: "Unavailable", isUnavailable: true }, + { slug: "other-scope", name: "Other scope" }, + ], + ], + ]); + expect( + prismModelChoices([entry], options, (_, model) => + model === "other-scope" ? "Unavailable elsewhere" : null, + ).map((choice) => choice.preference.model), + ).toEqual(["enabled"]); + expect(prismModelChoices([{ ...entry, enabled: false }], options, () => null)).toEqual([]); + expect(prismModelChoices([{ ...entry, isAvailable: false }], options, () => null)).toEqual([]); +}); diff --git a/apps/web/src/components/settings/PrismSettings.logic.ts b/apps/web/src/components/settings/PrismSettings.logic.ts new file mode 100644 index 0000000000..3744eb87af --- /dev/null +++ b/apps/web/src/components/settings/PrismSettings.logic.ts @@ -0,0 +1,96 @@ +import { + ProjectId, + type PrismModelPreference, + type PrismRole, + type ModelCapabilities, + type ProviderInstanceId, +} from "@t3tools/contracts"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; +import type { ProviderInstanceEntry } from "../../providerInstances"; +import type { ModelEsque } from "../chat/providerIconUtils"; +import { planScopedSettingsPatch } from "./scopedSettings"; + +export function prismModelKey(entry: Pick) { + return JSON.stringify([entry.instanceId, entry.model]); +} + +export function movePrismPreference( + models: readonly PrismModelPreference[], + index: number, + direction: -1 | 1, +) { + const destination = index + direction; + if (index < 0 || index >= models.length || destination < 0 || destination >= models.length) + return models; + const next = [...models]; + [next[index], next[destination]] = [next[destination]!, next[index]!]; + return next; +} + +export function prismEffortOptions(capabilities: ModelCapabilities | null | undefined) { + const descriptor = capabilities?.optionDescriptors?.find( + (option) => + option.type === "select" && ["effort", "reasoningEffort", "variant"].includes(option.id), + ); + return descriptor?.type === "select" + ? descriptor.options.filter((option) => !descriptor.promptInjectedValues?.includes(option.id)) + : []; +} + +/** Project overrides store complete kits, while environment updates accept deep patches. */ +export function planPrismModelsPatch( + scope: Parameters[0], + environments: Parameters[1], + role: PrismRole, + models: readonly PrismModelPreference[], +) { + const plan = planScopedSettingsPatch(scope, environments, { prismRoles: { [role]: { models } } }); + return { + ...plan, + serverWrites: plan.serverWrites.map((write) => { + const overrides = write.patch.projectSettingsOverrides; + const settings = environments.find((env) => env.environmentId === write.environmentId) + ?.serverConfig?.settings; + if (!overrides || !settings) return write; + return { + ...write, + patch: { + ...write.patch, + projectSettingsOverrides: Object.fromEntries( + Object.entries(overrides).map(([id, override]) => { + const roles = resolveProjectSettings(settings, ProjectId.make(id)).settings + .prismRoles; + return [ + id, + { ...override, prismRoles: { ...roles, [role]: { ...roles[role], models } } }, + ]; + }), + ), + }, + }; + }), + }; +} + +export function prismModelChoices( + entries: readonly Pick< + ProviderInstanceEntry, + "instanceId" | "displayName" | "enabled" | "isAvailable" | "models" + >[], + options: ReadonlyMap, + disabledReason: (instanceId: ProviderInstanceId, model: string) => string | null, +) { + return entries + .filter((entry) => entry.enabled && entry.isAvailable) + .flatMap((entry) => + (options.get(entry.instanceId) ?? []) + .filter((model) => !model.isUnavailable && !disabledReason(entry.instanceId, model.slug)) + .map((model) => ({ + preference: { instanceId: entry.instanceId, model: model.slug }, + label: `${entry.displayName} / ${model.name}`, + efforts: prismEffortOptions( + entry.models.find((candidate) => candidate.slug === model.slug)?.capabilities, + ), + })), + ); +} diff --git a/apps/web/src/components/settings/PrismSettings.tsx b/apps/web/src/components/settings/PrismSettings.tsx new file mode 100644 index 0000000000..9041475de1 --- /dev/null +++ b/apps/web/src/components/settings/PrismSettings.tsx @@ -0,0 +1,391 @@ +import { PRISM_ROLES, type PrismModelPreference, type PrismRole } from "@t3tools/contracts"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { Link } from "@tanstack/react-router"; +import { useState } from "react"; +import { ArrowDownIcon, ArrowUpIcon, TrashIcon } from "lucide-react"; +import { getCustomModelOptionsByInstance } from "../../modelSelection"; +import { + applyProviderInstanceSettings, + deriveProviderInstanceEntries, +} from "../../providerInstances"; +import { useThreadShells } from "../../state/entities"; +import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { useRightPanelStore } from "../../rightPanelStore"; +import { Button } from "../ui/button"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { useSettingsScope } from "./SettingsScopeContext"; +import { SettingsPageContainer, SettingsSection } from "./settingsLayout"; +import { useScopedSettings, useClearScopedSettings } from "./useScopedSettings"; +import { useScopedModelDisabledReason } from "./useScopedModelAvailability"; +import { persistScopedSettingsPatch } from "./scopedSettings"; +import { + movePrismPreference, + planPrismModelsPatch, + prismModelChoices, + prismModelKey, +} from "./PrismSettings.logic"; + +type ModelChoice = ReturnType[number]; + +function RolePreferences({ + role, + saved, + choices, + mixed, + disabled, + save, +}: { + role: PrismRole; + saved: readonly PrismModelPreference[]; + choices: readonly ModelChoice[]; + mixed: boolean; + disabled: boolean; + save: (models: readonly PrismModelPreference[]) => Promise; +}) { + const [draft, setDraft] = useState(saved); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + const [status, setStatus] = useState(null); + const dirty = JSON.stringify(draft) !== JSON.stringify(saved); + const available = choices.filter( + (choice) => !draft.some((entry) => prismModelKey(entry) === prismModelKey(choice.preference)), + ); + const title = role.charAt(0).toUpperCase() + role.slice(1); + const edit = (next: readonly PrismModelPreference[]) => { + setDraft(next); + setStatus(null); + setError(null); + }; + return ( + +
+ {mixed && ( +

+ Preferences differ across this scope. Saving replaces this role's model list on the + selected targets. +

+ )} + {draft.length === 0 && ( +

No preferred models configured.

+ )} +
    + {draft.map((entry, index) => { + const choice = choices.find( + (candidate) => prismModelKey(candidate.preference) === prismModelKey(entry), + ); + const efforts = choice?.efforts ?? []; + const unknownEffort = + entry.effort && !efforts.some((effort) => effort.id === entry.effort); + return ( +
  1. + {index + 1}. + + {choice?.label ?? `${entry.instanceId} / ${entry.model}`} + {!choice && ( + + Unavailable in this scope; skipped by routing. + + )} + + + + + +
  2. + ); + })} +
+
+ + + {dirty && ( + + )} +
+ {choices.length === 0 && ( +

+ Enable models in Providers for this scope to add preferences. +

+ )} + {error && ( +

+ {error} +

+ )} + {status && ( +

+ {status} +

+ )} +
+
+ ); +} + +export function PrismSettings() { + const { scope, target, targets, environment, environments, connectedEnvironments } = + useSettingsScope(); + const settings = useScopedSettings(); + const clearOverrides = useClearScopedSettings(); + const [saveError, setSaveError] = useState(null); + const persist = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false }); + const providers = environment?.serverConfig?.providers ?? EMPTY_SERVER_PROVIDERS; + const entries = applyProviderInstanceSettings(deriveProviderInstanceEntries(providers), settings); + const options = getCustomModelOptionsByInstance(settings, providers); + const disabledReason = useScopedModelDisabledReason(settings, entries); + const choices = prismModelChoices(entries, options, disabledReason); + const threads = useThreadShells(); + const thread = threads + .filter((candidate) => + targets.some( + (selected) => + selected.environmentId === candidate.environmentId && + (selected.projectId === null || selected.projectId === candidate.projectId), + ), + ) + .toSorted((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0]; + const openPanel = useRightPanelStore((state) => state.open); + return ( + + + {saveError && ( +

+ {saveError} +

+ )} +

+ Models are tried in order for each role. Only models enabled in Providers for the selected + scope are eligible. +

+ {!target && ( +

+ Connect an environment to edit Prism preferences. +

+ )} + {(scope.kind === "project" || scope.kind === "checkout") && ( +
+ +
+ )} +
+ {PRISM_ROLES.map((role) => ( + + JSON.stringify(candidate.settings.prismRoles[role].models) !== + JSON.stringify(settings.prismRoles[role].models), + )} + save={async (models) => { + setSaveError(null); + const plan = planPrismModelsPatch(scope, environments, role, models); + if (plan.unavailableReason) throw new Error(plan.unavailableReason); + const result = await persistScopedSettingsPatch(plan, persist, () => {}); + if (result.failedEnvironments.length) { + const message = `Could not save ${role} preferences on ${result.failedEnvironments.map((entry) => entry.label).join(", ")}.${result.savedEnvironmentCount ? " Other selected environments saved the change." : ""}`; + // A successful representative write remounts the role editor; keep partial failures on the page. + setSaveError(message); + throw new Error(message); + } + }} + /> + ))} + +
+ {connectedEnvironments.map((env) => ( +
+

{env.label}

+ {(env.serverConfig?.providers ?? []).map((provider) => ( +
+

+ {provider.displayName ?? provider.instanceId} + {!provider.enabled ? " (disabled)" : ""} +

+ {provider.usageLimits?.checkedAt && ( +

+ Checked {new Date(provider.usageLimits.checkedAt).toLocaleString()} +

+ )} + {provider.usageLimits?.unavailable && ( +

+ {provider.usageLimits.unavailable.message ?? + (provider.usageLimits.unavailable.reason === "probeFailed" + ? "Usage refresh failed; showing the last reading if available." + : "Usage reporting is unsupported.")} +

+ )} + {!provider.usageLimits?.windows.length && ( +

No usage windows reported.

+ )} + {provider.usageLimits?.windows.map((window) => ( +
+
+ + {window.label}: {window.usedPercent}% used + + + {window.resetsAt + ? `Resets ${new Date(window.resetsAt).toLocaleString()}` + : "Reset time not reported"} + +
+ +
+ ))} +
+ ))} +
+ ))} + {connectedEnvironments.length === 0 && ( +

+ Connect an environment to see its capacity. +

+ )} +
+
+ +
+

+ Recent jobs are not included in this snapshot. Open a thread's Agents panel to see Prism + spawns. +

+ {thread ? ( + openPanel(scopeThreadRef(thread.environmentId, thread.id), "agents")} + > + Open Agents panel + + ) : ( + + Open a thread to view Agents + + )} +
+
+
+ ); +} diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index ec46ab1f13..fc0e739da2 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -13,6 +13,7 @@ import { ArchiveIcon, BlocksIcon, BotIcon, + TriangleIcon, createLucideIcon, GitBranchIcon, HardDriveIcon, @@ -82,6 +83,7 @@ const SETTINGS_SECTION_ICONS: Readonly< "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/settings/keybindings": KeyboardIcon, "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/settings/snap-shot": SnapShotIcon, "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/settings/providers": BotIcon, + "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/settings/prism": TriangleIcon, "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/settings/integrations": BlocksIcon, "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/settings/source-control": GitBranchIcon, "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/settings/storage": HardDriveIcon, diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index acc9b7eb7d..0e1c5673fe 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -17,6 +17,7 @@ export type SettingsPath = | "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/settings/appearance" | "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/settings/keybindings" | "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/settings/snap-shot" + | "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/settings/prism" | "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/settings/providers" | "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/settings/integrations" | "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/settings/source-control" @@ -87,6 +88,7 @@ export const SETTINGS_SECTION_LABELS: Readonly> = { "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/settings/keybindings": "Keybindings", "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/settings/snap-shot": "SnapShots", "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/settings/providers": "Providers", + "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/settings/prism": "Prism (Model Router)", "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/settings/integrations": "Integrations", "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/settings/source-control": "Source Control", "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/settings/storage": "Storage", @@ -489,6 +491,13 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "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/settings/keybindings", searchTerms: ["keyboard shortcuts hotkeys commands bindings json"], }, + { + id: "prism-roles", + title: "Prism (Model Router)", + to: "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/settings/prism", + scope: "project-defaults", + searchTerms: ["planner dispatcher reviewer worker correction recovery models effort capacity"], + }, ...KEYBINDING_SEARCH_ITEMS, { id: "snap-shot-enabled", @@ -824,6 +833,7 @@ const SETTINGS_CATEGORY_SCOPES: Readonly SettingsRoute, } as any) +const SettingsPrismRoute = SettingsPrismRouteImport.update({ + id: '/prism', + path: '/prism', + getParentRoute: () => SettingsRoute, +} as any) const SettingsOpenSourceLicensesRoute = SettingsOpenSourceLicensesRouteImport.update({ id: '/open-source-licenses', @@ -173,6 +179,7 @@ export interface FileRoutesByFullPath { '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/settings/integrations': typeof SettingsIntegrationsRoute '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/settings/keybindings': typeof SettingsKeybindingsRoute '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/settings/open-source-licenses': typeof SettingsOpenSourceLicensesRoute + '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/settings/prism': typeof SettingsPrismRoute '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/settings/projects': typeof SettingsProjectsRoute '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/settings/providers': typeof SettingsProvidersRoute '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/settings/snap-shot': typeof SettingsSnapShotRoute @@ -197,6 +204,7 @@ export interface FileRoutesByTo { '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/settings/integrations': typeof SettingsIntegrationsRoute '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/settings/keybindings': typeof SettingsKeybindingsRoute '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/settings/open-source-licenses': typeof SettingsOpenSourceLicensesRoute + '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/settings/prism': typeof SettingsPrismRoute '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/settings/projects': typeof SettingsProjectsRoute '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/settings/providers': typeof SettingsProvidersRoute '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/settings/snap-shot': typeof SettingsSnapShotRoute @@ -224,6 +232,7 @@ export interface FileRoutesById { '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/settings/integrations': typeof SettingsIntegrationsRoute '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/settings/keybindings': typeof SettingsKeybindingsRoute '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/settings/open-source-licenses': typeof SettingsOpenSourceLicensesRoute + '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/settings/prism': typeof SettingsPrismRoute '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/settings/projects': typeof SettingsProjectsRoute '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/settings/providers': typeof SettingsProvidersRoute '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/settings/snap-shot': typeof SettingsSnapShotRoute @@ -252,6 +261,7 @@ export interface FileRouteTypes { | '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/settings/integrations' | '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/settings/keybindings' | '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/settings/open-source-licenses' + | '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/settings/prism' | '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/settings/projects' | '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/settings/providers' | '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/settings/snap-shot' @@ -276,6 +286,7 @@ export interface FileRouteTypes { | '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/settings/integrations' | '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/settings/keybindings' | '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/settings/open-source-licenses' + | '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/settings/prism' | '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/settings/projects' | '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/settings/providers' | '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/settings/snap-shot' @@ -302,6 +313,7 @@ export interface FileRouteTypes { | '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/settings/integrations' | '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/settings/keybindings' | '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/settings/open-source-licenses' + | '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/settings/prism' | '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/settings/projects' | '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/settings/providers' | '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/settings/snap-shot' @@ -408,6 +420,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsProjectsRouteImport parentRoute: typeof SettingsRoute } + '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/settings/prism': { + id: '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/settings/prism' + path: '/prism' + fullPath: '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/settings/prism' + preLoaderRoute: typeof SettingsPrismRouteImport + parentRoute: typeof SettingsRoute + } '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/settings/open-source-licenses': { id: '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/settings/open-source-licenses' path: '/open-source-licenses' @@ -520,6 +539,7 @@ interface SettingsRouteChildren { SettingsIntegrationsRoute: typeof SettingsIntegrationsRoute SettingsKeybindingsRoute: typeof SettingsKeybindingsRoute SettingsOpenSourceLicensesRoute: typeof SettingsOpenSourceLicensesRoute + SettingsPrismRoute: typeof SettingsPrismRoute SettingsProjectsRoute: typeof SettingsProjectsRoute SettingsProvidersRoute: typeof SettingsProvidersRoute SettingsSnapShotRoute: typeof SettingsSnapShotRoute @@ -536,6 +556,7 @@ const SettingsRouteChildren: SettingsRouteChildren = { SettingsIntegrationsRoute: SettingsIntegrationsRoute, SettingsKeybindingsRoute: SettingsKeybindingsRoute, SettingsOpenSourceLicensesRoute: SettingsOpenSourceLicensesRoute, + SettingsPrismRoute: SettingsPrismRoute, SettingsProjectsRoute: SettingsProjectsRoute, SettingsProvidersRoute: SettingsProvidersRoute, SettingsSnapShotRoute: SettingsSnapShotRoute, diff --git a/apps/web/src/routes/settings.prism.tsx b/apps/web/src/routes/settings.prism.tsx new file mode 100644 index 0000000000..1ab7334891 --- /dev/null +++ b/apps/web/src/routes/settings.prism.tsx @@ -0,0 +1,4 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { PrismSettings } from "../components/settings/PrismSettings"; + +export const Route = createFileRoute("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/settings/prism")({ component: PrismSettings }); diff --git a/docs/fork.md b/docs/fork.md index d0d794404d..1d4da4011e 100644 --- a/docs/fork.md +++ b/docs/fork.md @@ -79,6 +79,10 @@ any modified upstream file missing from `scripts/fork-upstream-edits.txt`. `apps/web/src/components/chat/ChatHeader.tsx` renders the parent crumb and sibling menu for a child thread. The logic lives in the fork-owned `AgentThreadTree.logic.ts`, `AgentThreadTree.tsx` and `chat/ThreadParentCrumbs.tsx`. +- Prism settings (#21): `apps/web/src/components/settings/SettingsSidebarNav.tsx` + and `settingsSearch.ts` register the page below Providers and in settings search; + `apps/web/src/routeTree.gen.ts` is regenerated for the fork-owned route. + The page and its preference logic live in new `PrismSettings` files. - Prism toolkit and role kits (#19): `packages/contracts/src/settings.ts` adds the `prismRoles` server setting (project-scoped, with its patch) and `packages/contracts/src/index.ts` exports the fork-owned `prism.ts` and diff --git a/scripts/fork-upstream-edits.txt b/scripts/fork-upstream-edits.txt index 992c071dda..c87d8452e1 100644 --- a/scripts/fork-upstream-edits.txt +++ b/scripts/fork-upstream-edits.txt @@ -50,3 +50,8 @@ packages/contracts/src/settings.ts # CI on GitHub-hosted runners (#6) .github/workflows/ci.yml .github/workflows/mobile-fingerprint-check.yml + +# Prism settings (#21) +apps/web/src/components/settings/SettingsSidebarNav.tsx +apps/web/src/components/settings/settingsSearch.ts +apps/web/src/routeTree.gen.ts From 9d91e616c9df8138c147b6e0326b9115282192d4 Mon Sep 17 00:00:00 2001 From: lukemaj Date: Fri, 25 Sep 2026 20:22:52 +0200 Subject: [PATCH 2/5] wip: edit easy medium and hard lanes for every Prism role --- .../settings/PrismSettings.logic.test.ts | 52 +++++-- .../settings/PrismSettings.logic.ts | 18 ++- .../src/components/settings/PrismSettings.tsx | 133 +++++++++++------- 3 files changed, 143 insertions(+), 60 deletions(-) diff --git a/apps/web/src/components/settings/PrismSettings.logic.test.ts b/apps/web/src/components/settings/PrismSettings.logic.test.ts index 521b8f66f1..04217a6028 100644 --- a/apps/web/src/components/settings/PrismSettings.logic.test.ts +++ b/apps/web/src/components/settings/PrismSettings.logic.test.ts @@ -17,13 +17,18 @@ import { persistScopedSettingsPatch } from "./scopedSettings"; const first = { instanceId: ProviderInstanceId.make("codex"), model: "first", effort: "high" }; const second = { instanceId: ProviderInstanceId.make("opencode"), model: "second" }; +const rolesLanes = { + ...DEFAULT_SERVER_SETTINGS.prismRoles.worker.lanes, + easy: [second], + hard: [{ ...first, effort: "low" }], +}; const roles = { ...DEFAULT_SERVER_SETTINGS.prismRoles, worker: { ...DEFAULT_SERVER_SETTINGS.prismRoles.worker, instructions: "Keep this kit", skills: ["test"], - models: [first], + lanes: { ...rolesLanes, medium: [first] }, }, }; const environment = (id: string, connected = true) => ({ @@ -75,17 +80,20 @@ describe("Prism role preferences", () => { it("replaces model arrays, including clearing them, without changing role kits or offline environments", () => { const envs = [environment("one"), environment("two", false)]; const scope = resolveSettingsScope({}, [], envs); - const plan = planPrismModelsPatch(scope, envs, "worker", [second]); + const plan = planPrismModelsPatch(scope, envs, "worker", "medium", [second]); expect(plan.serverWrites.map((write) => write.environmentId)).toEqual(["one"]); const next = applyServerSettingsPatch( envs[0]!.serverConfig.settings, plan.serverWrites[0]!.patch, ); - expect(next.prismRoles.worker).toEqual({ ...roles.worker, models: [second] }); + expect(next.prismRoles.worker).toEqual({ + ...roles.worker, + lanes: { ...rolesLanes, medium: [second] }, + }); expect(next.prismRoles.planner).toEqual(roles.planner); - const clear = planPrismModelsPatch(scope, envs, "worker", []); + const clear = planPrismModelsPatch(scope, envs, "worker", "medium", []); expect( - applyServerSettingsPatch(next, clear.serverWrites[0]!.patch).prismRoles.worker.models, + applyServerSettingsPatch(next, clear.serverWrites[0]!.patch).prismRoles.worker.lanes.medium, ).toEqual([]); }); @@ -134,7 +142,7 @@ describe("Prism role preferences", () => { remoteEnvironmentLabels: ["one"], }; const scope = resolveSettingsScope({ project: "group" }, [group], [scopedEnv]); - const plan = planPrismModelsPatch(scope, [scopedEnv], "worker", [second, first]); + const plan = planPrismModelsPatch(scope, [scopedEnv], "worker", "medium", [second, first]); const next = applyServerSettingsPatch( scopedEnv.serverConfig.settings, plan.serverWrites[0]!.patch, @@ -143,7 +151,7 @@ describe("Prism role preferences", () => { expect(next.projectSettingsOverrides[projectId]?.prismRoles?.worker).toEqual({ ...roles.worker, instructions: "Project instructions", - models: [second, first], + lanes: { ...rolesLanes, medium: [second, first] }, }); expect(next.projectSettingsOverrides[projectId]?.prismRoles?.reviewer).toEqual(roles.reviewer); }); @@ -151,9 +159,17 @@ describe("Prism role preferences", () => { it("reports disconnected scopes and partial save failures", async () => { const offline = environment("offline", false); const scope = resolveSettingsScope({ machine: offline.environmentId }, [], [offline]); - expect(planPrismModelsPatch(scope, [offline], "worker", []).unavailableReason).toBeTruthy(); + expect( + planPrismModelsPatch(scope, [offline], "worker", "medium", []).unavailableReason, + ).toBeTruthy(); const envs = [environment("one"), environment("two")]; - const plan = planPrismModelsPatch(resolveSettingsScope({}, [], envs), envs, "worker", [first]); + const plan = planPrismModelsPatch( + resolveSettingsScope({}, [], envs), + envs, + "worker", + "medium", + [first], + ); const result = await persistScopedSettingsPatch( plan, async ({ environmentId }) => ({ _tag: environmentId === "one" ? "Success" : "Failure" }), @@ -190,3 +206,21 @@ it("offers only enabled models available across the selected scope", () => { expect(prismModelChoices([{ ...entry, enabled: false }], options, () => null)).toEqual([]); expect(prismModelChoices([{ ...entry, isAvailable: false }], options, () => null)).toEqual([]); }); + +it("keeps the same model at distinct efforts as separate ordered entries", () => { + const envs = [environment("one")]; + const models = [first, { ...first, effort: "low" }]; + const plan = planPrismModelsPatch( + resolveSettingsScope({}, [], envs), + envs, + "reviewer", + "hard", + models, + ); + const next = applyServerSettingsPatch( + envs[0]!.serverConfig.settings, + plan.serverWrites[0]!.patch, + ); + expect(next.prismRoles.reviewer.lanes.hard).toEqual(models); + expect(next.prismRoles.worker).toEqual(roles.worker); +}); diff --git a/apps/web/src/components/settings/PrismSettings.logic.ts b/apps/web/src/components/settings/PrismSettings.logic.ts index 3744eb87af..a1aa87dbc0 100644 --- a/apps/web/src/components/settings/PrismSettings.logic.ts +++ b/apps/web/src/components/settings/PrismSettings.logic.ts @@ -2,6 +2,7 @@ import { ProjectId, type PrismModelPreference, type PrismRole, + type PrismLane, type ModelCapabilities, type ProviderInstanceId, } from "@t3tools/contracts"; @@ -14,8 +15,8 @@ export function prismModelKey(entry: Pick( + models: readonly T[], index: number, direction: -1 | 1, ) { @@ -42,9 +43,12 @@ export function planPrismModelsPatch( scope: Parameters[0], environments: Parameters[1], role: PrismRole, + lane: PrismLane, models: readonly PrismModelPreference[], ) { - const plan = planScopedSettingsPatch(scope, environments, { prismRoles: { [role]: { models } } }); + const plan = planScopedSettingsPatch(scope, environments, { + prismRoles: { [role]: { lanes: { [lane]: models } } }, + }); return { ...plan, serverWrites: plan.serverWrites.map((write) => { @@ -62,7 +66,13 @@ export function planPrismModelsPatch( .prismRoles; return [ id, - { ...override, prismRoles: { ...roles, [role]: { ...roles[role], models } } }, + { + ...override, + prismRoles: { + ...roles, + [role]: { ...roles[role], lanes: { ...roles[role].lanes, [lane]: models } }, + }, + }, ]; }), ), diff --git a/apps/web/src/components/settings/PrismSettings.tsx b/apps/web/src/components/settings/PrismSettings.tsx index 9041475de1..81739b08ff 100644 --- a/apps/web/src/components/settings/PrismSettings.tsx +++ b/apps/web/src/components/settings/PrismSettings.tsx @@ -1,7 +1,13 @@ -import { PRISM_ROLES, type PrismModelPreference, type PrismRole } from "@t3tools/contracts"; +import { + PRISM_ROLES, + PRISM_LANES, + type PrismLane, + type PrismModelPreference, + type PrismRole, +} from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { Link } from "@tanstack/react-router"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { ArrowDownIcon, ArrowUpIcon, TrashIcon } from "lucide-react"; import { getCustomModelOptionsByInstance } from "../../modelSelection"; import { @@ -28,8 +34,17 @@ import { type ModelChoice = ReturnType[number]; -function RolePreferences({ +type DraftPreference = PrismModelPreference & { entryId: number }; +const editablePreference = (model: PrismModelPreference, entryId: number): DraftPreference => ({ + ...model, + entryId, +}); +const savedPreferences = (models: readonly DraftPreference[]) => + models.map(({ entryId: _entryId, ...model }) => model); + +function LanePreferences({ role, + lane, saved, choices, mixed, @@ -37,32 +52,35 @@ function RolePreferences({ save, }: { role: PrismRole; + lane: PrismLane; saved: readonly PrismModelPreference[]; choices: readonly ModelChoice[]; mixed: boolean; disabled: boolean; save: (models: readonly PrismModelPreference[]) => Promise; }) { - const [draft, setDraft] = useState(saved); + const [draft, setDraft] = useState(() => + saved.map(editablePreference), + ); + const nextEntryId = useRef(saved.length); const [pending, setPending] = useState(false); const [error, setError] = useState(null); const [status, setStatus] = useState(null); - const dirty = JSON.stringify(draft) !== JSON.stringify(saved); - const available = choices.filter( - (choice) => !draft.some((entry) => prismModelKey(entry) === prismModelKey(choice.preference)), - ); - const title = role.charAt(0).toUpperCase() + role.slice(1); - const edit = (next: readonly PrismModelPreference[]) => { + const dirty = JSON.stringify(savedPreferences(draft)) !== JSON.stringify(saved); + const available = choices; + const title = `${role.charAt(0).toUpperCase() + role.slice(1)} ${lane}`; + const edit = (next: readonly DraftPreference[]) => { setDraft(next); setStatus(null); setError(null); }; return ( - -
+
+

{lane}

+
{mixed && (

- Preferences differ across this scope. Saving replaces this role's model list on the + Preferences differ across this scope. Saving replaces this lane's model list on the selected targets.

)} @@ -79,15 +97,17 @@ function RolePreferences({ entry.effort && !efforts.some((effort) => effort.id === entry.effort); return (
  • - {index + 1}. + + {index === 0 ? "Primary" : `Fallback ${index}`} + {choice?.label ?? `${entry.instanceId} / ${entry.model}`} {!choice && ( - Unavailable in this scope; skipped by routing. + Unavailable on one or more selected targets. )} @@ -100,6 +120,7 @@ function RolePreferences({ draft.map((model, position) => position === index ? { + entryId: model.entryId, instanceId: model.instanceId, model: model.model, ...(value ? { effort: value } : {}), @@ -165,7 +186,8 @@ function RolePreferences({ const choice = available.find( (candidate) => prismModelKey(candidate.preference) === value, ); - if (choice) edit([...draft, choice.preference]); + if (choice) + edit([...draft, editablePreference(choice.preference, nextEntryId.current++)]); }} > @@ -184,12 +206,13 @@ function RolePreferences({ {dirty && ( - )} @@ -222,7 +250,7 @@ function RolePreferences({

    )}
  • - +
    ); } @@ -258,8 +286,8 @@ export function PrismSettings() {

    )}

    - Models are tried in order for each role. Only models enabled in Providers for the selected - scope are eligible. + Each role has easy, medium and hard lanes. The first model is primary; the rest are + fallbacks. Only models enabled in Providers for the selected scope are eligible.

    {!target && (

    @@ -280,30 +308,41 @@ export function PrismSettings() { )} {PRISM_ROLES.map((role) => ( - - JSON.stringify(candidate.settings.prismRoles[role].models) !== - JSON.stringify(settings.prismRoles[role].models), - )} - save={async (models) => { - setSaveError(null); - const plan = planPrismModelsPatch(scope, environments, role, models); - if (plan.unavailableReason) throw new Error(plan.unavailableReason); - const result = await persistScopedSettingsPatch(plan, persist, () => {}); - if (result.failedEnvironments.length) { - const message = `Could not save ${role} preferences on ${result.failedEnvironments.map((entry) => entry.label).join(", ")}.${result.savedEnvironmentCount ? " Other selected environments saved the change." : ""}`; - // A successful representative write remounts the role editor; keep partial failures on the page. - setSaveError(message); - throw new Error(message); - } - }} - /> + +

    + {PRISM_LANES.map((lane) => ( + + JSON.stringify(candidate.settings.prismRoles[role].lanes[lane]) !== + JSON.stringify(settings.prismRoles[role].lanes[lane]), + )} + save={async (models) => { + setSaveError(null); + const plan = planPrismModelsPatch(scope, environments, role, lane, models); + if (plan.unavailableReason) throw new Error(plan.unavailableReason); + const result = await persistScopedSettingsPatch(plan, persist, () => {}); + if (result.failedEnvironments.length) { + const message = `Could not save ${role} ${lane} preferences on ${result.failedEnvironments.map((entry) => entry.label).join(", ")}.${result.savedEnvironmentCount ? " Other selected environments saved the change." : ""}`; + // A successful representative write remounts the editor; keep partial failures on the page. + setSaveError(message); + throw new Error(message); + } + }} + /> + ))} +
    + ))}
    From 4409232390ea071102a690d02065c8d6ee93b4bd Mon Sep 17 00:00:00 2001 From: lukemaj Date: Fri, 25 Sep 2026 20:25:30 +0200 Subject: [PATCH 3/5] wip: derive Prism lanes from the exported schema --- apps/web/src/components/settings/PrismSettings.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/settings/PrismSettings.tsx b/apps/web/src/components/settings/PrismSettings.tsx index 81739b08ff..4f8bb577a9 100644 --- a/apps/web/src/components/settings/PrismSettings.tsx +++ b/apps/web/src/components/settings/PrismSettings.tsx @@ -1,7 +1,6 @@ import { PRISM_ROLES, - PRISM_LANES, - type PrismLane, + PrismLane, type PrismModelPreference, type PrismRole, } from "@t3tools/contracts"; @@ -314,7 +313,7 @@ export function PrismSettings() { title={role.charAt(0).toUpperCase() + role.slice(1)} >
    - {PRISM_LANES.map((lane) => ( + {PrismLane.literals.map((lane) => ( Date: Fri, 25 Sep 2026 20:30:59 +0200 Subject: [PATCH 4/5] wip: serialize Prism saves through the settings snapshot --- .../settings/PrismSettings.logic.test.ts | 29 ++++++ .../settings/PrismSettings.logic.ts | 31 ++++++ .../src/components/settings/PrismSettings.tsx | 96 +++++++++++++++---- 3 files changed, 137 insertions(+), 19 deletions(-) diff --git a/apps/web/src/components/settings/PrismSettings.logic.test.ts b/apps/web/src/components/settings/PrismSettings.logic.test.ts index 04217a6028..a2b0674fc0 100644 --- a/apps/web/src/components/settings/PrismSettings.logic.test.ts +++ b/apps/web/src/components/settings/PrismSettings.logic.test.ts @@ -11,6 +11,7 @@ import { planPrismModelsPatch, prismEffortOptions, prismModelChoices, + prismWriteObserved, } from "./PrismSettings.logic"; import { resolveSettingsScope } from "./settingsScope"; import { persistScopedSettingsPatch } from "./scopedSettings"; @@ -224,3 +225,31 @@ it("keeps the same model at distinct efforts as separate ordered entries", () => expect(next.prismRoles.reviewer.lanes.hard).toEqual(models); expect(next.prismRoles.worker).toEqual(roles.worker); }); + +it("keeps the save barrier until the updated settings snapshot arrives", () => { + const envs = [environment("one")]; + const plan = planPrismModelsPatch(resolveSettingsScope({}, [], envs), envs, "worker", "easy", [ + first, + ]); + const expectation = { + kind: "lane" as const, + role: "worker" as const, + lane: "easy" as const, + models: [first], + }; + expect(prismWriteObserved(plan, envs, expectation)).toBe(false); + const updated = { + ...envs[0]!, + serverConfig: { + ...envs[0]!.serverConfig, + settings: applyServerSettingsPatch( + envs[0]!.serverConfig.settings, + plan.serverWrites[0]!.patch, + ), + }, + }; + expect(prismWriteObserved(plan, [updated], expectation)).toBe(true); + expect(prismWriteObserved(plan, [], expectation)).toBe(false); + // Failed writes do not wait forever for a snapshot that will never arrive. + expect(prismWriteObserved({ ...plan, serverWrites: [] }, envs, expectation)).toBe(true); +}); diff --git a/apps/web/src/components/settings/PrismSettings.logic.ts b/apps/web/src/components/settings/PrismSettings.logic.ts index a1aa87dbc0..cfdde34b10 100644 --- a/apps/web/src/components/settings/PrismSettings.logic.ts +++ b/apps/web/src/components/settings/PrismSettings.logic.ts @@ -104,3 +104,34 @@ export function prismModelChoices( })), ); } + +export type PrismWriteExpectation = + | { kind: "lane"; role: PrismRole; lane: PrismLane; models: readonly PrismModelPreference[] } + | { kind: "inherit" }; + +/** A successful RPC is not enough: wait for the streamed settings before building another full override. */ +export function prismWriteObserved( + plan: ReturnType, + environments: Parameters[1], + expectation: PrismWriteExpectation, +) { + return plan.serverWrites.every((write) => { + const settings = environments.find((env) => env.environmentId === write.environmentId) + ?.serverConfig?.settings; + if (!settings) return false; + const projectIds = Object.keys(write.patch.projectSettingsOverrides ?? {}); + if (expectation.kind === "inherit") { + return projectIds.every( + (id) => settings.projectSettingsOverrides[ProjectId.make(id)]?.prismRoles === undefined, + ); + } + const effective = projectIds.length + ? projectIds.map((id) => resolveProjectSettings(settings, ProjectId.make(id)).settings) + : [settings]; + return effective.every( + (target) => + JSON.stringify(target.prismRoles[expectation.role].lanes[expectation.lane]) === + JSON.stringify(expectation.models), + ); + }); +} diff --git a/apps/web/src/components/settings/PrismSettings.tsx b/apps/web/src/components/settings/PrismSettings.tsx index 4f8bb577a9..d1f263e0fe 100644 --- a/apps/web/src/components/settings/PrismSettings.tsx +++ b/apps/web/src/components/settings/PrismSettings.tsx @@ -6,7 +6,7 @@ import { } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { Link } from "@tanstack/react-router"; -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { ArrowDownIcon, ArrowUpIcon, TrashIcon } from "lucide-react"; import { getCustomModelOptionsByInstance } from "../../modelSelection"; import { @@ -21,14 +21,16 @@ import { Button } from "../ui/button"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { useSettingsScope } from "./SettingsScopeContext"; import { SettingsPageContainer, SettingsSection } from "./settingsLayout"; -import { useScopedSettings, useClearScopedSettings } from "./useScopedSettings"; +import { useScopedSettings } from "./useScopedSettings"; import { useScopedModelDisabledReason } from "./useScopedModelAvailability"; -import { persistScopedSettingsPatch } from "./scopedSettings"; +import { persistScopedSettingsPatch, planScopedSettingsClear } from "./scopedSettings"; import { movePrismPreference, planPrismModelsPatch, prismModelChoices, prismModelKey, + prismWriteObserved, + type PrismWriteExpectation, } from "./PrismSettings.logic"; type ModelChoice = ReturnType[number]; @@ -257,9 +259,56 @@ export function PrismSettings() { const { scope, target, targets, environment, environments, connectedEnvironments } = useSettingsScope(); const settings = useScopedSettings(); - const clearOverrides = useClearScopedSettings(); const [saveError, setSaveError] = useState(null); const persist = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false }); + const saveLock = useRef(false); + const [pendingWrite, setPendingWrite] = useState<{ + plan: ReturnType; + expectation: PrismWriteExpectation; + acknowledged: boolean; + } | null>(null); + const observed = + pendingWrite?.acknowledged && + prismWriteObserved(pendingWrite.plan, environments, pendingWrite.expectation); + useEffect(() => { + if (!observed) return; + saveLock.current = false; + setPendingWrite(null); + }, [observed]); + async function savePlan( + plan: ReturnType, + expectation: PrismWriteExpectation, + ) { + if (saveLock.current) throw new Error("Wait for the current settings update to finish."); + if (plan.unavailableReason) throw new Error(plan.unavailableReason); + saveLock.current = true; + setSaveError(null); + setPendingWrite({ plan, expectation, acknowledged: false }); + let result; + try { + result = await persistScopedSettingsPatch(plan, persist, () => {}); + } catch (cause) { + saveLock.current = false; + setPendingWrite(null); + throw cause; + } + const failed = new Set(result.failedEnvironments.map((env) => env.environmentId)); + setPendingWrite({ + plan: { + ...plan, + serverWrites: plan.serverWrites.filter((write) => !failed.has(write.environmentId)), + }, + expectation, + acknowledged: true, + }); + if (failed.size) { + const message = `Could not save preferences on ${result.failedEnvironments.map((env) => env.label).join(", ")}.${result.savedEnvironmentCount ? " Other selected environments saved the change." : ""}`; + // The representative update remounts its lane editor, so failures also live on the page. + setSaveError(message); + throw new Error(message); + } + } + const providers = environment?.serverConfig?.providers ?? EMPTY_SERVER_PROVIDERS; const entries = applyProviderInstanceSettings(deriveProviderInstanceEntries(providers), settings); const options = getCustomModelOptionsByInstance(settings, providers); @@ -279,6 +328,11 @@ export function PrismSettings() { return ( + {pendingWrite && ( +

    + {pendingWrite.acknowledged ? "Waiting for settings refresh…" : "Saving preferences…"} +

    + )} {saveError && (

    {saveError} @@ -298,8 +352,16 @@ export function PrismSettings() { @@ -320,24 +382,20 @@ export function PrismSettings() { lane={lane} saved={settings.prismRoles[role].lanes[lane]} choices={choices} - disabled={!target} + disabled={!target || pendingWrite !== null} mixed={targets.some( (candidate) => JSON.stringify(candidate.settings.prismRoles[role].lanes[lane]) !== JSON.stringify(settings.prismRoles[role].lanes[lane]), )} - save={async (models) => { - setSaveError(null); - const plan = planPrismModelsPatch(scope, environments, role, lane, models); - if (plan.unavailableReason) throw new Error(plan.unavailableReason); - const result = await persistScopedSettingsPatch(plan, persist, () => {}); - if (result.failedEnvironments.length) { - const message = `Could not save ${role} ${lane} preferences on ${result.failedEnvironments.map((entry) => entry.label).join(", ")}.${result.savedEnvironmentCount ? " Other selected environments saved the change." : ""}`; - // A successful representative write remounts the editor; keep partial failures on the page. - setSaveError(message); - throw new Error(message); - } - }} + save={(models) => + savePlan(planPrismModelsPatch(scope, environments, role, lane, models), { + kind: "lane", + role, + lane, + models, + }) + } /> ))}

    From deafe1b0bdbf2741c696c35f3a2b6acd06285dfc Mon Sep 17 00:00:00 2001 From: lukemaj Date: Fri, 25 Sep 2026 20:36:37 +0200 Subject: [PATCH 5/5] wip: retain Prism save guard across scope navigation --- .../settings/PrismSettings.state.test.ts | 71 +++++++++++++++++++ .../settings/PrismSettings.state.ts | 60 ++++++++++++++++ .../src/components/settings/PrismSettings.tsx | 43 ++++------- 3 files changed, 145 insertions(+), 29 deletions(-) create mode 100644 apps/web/src/components/settings/PrismSettings.state.test.ts create mode 100644 apps/web/src/components/settings/PrismSettings.state.ts diff --git a/apps/web/src/components/settings/PrismSettings.state.test.ts b/apps/web/src/components/settings/PrismSettings.state.test.ts new file mode 100644 index 0000000000..d99e57ad29 --- /dev/null +++ b/apps/web/src/components/settings/PrismSettings.state.test.ts @@ -0,0 +1,71 @@ +import { DEFAULT_SERVER_SETTINGS, EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; +import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; +import { describe, expect, it } from "vite-plus/test"; +import { planPrismModelsPatch } from "./PrismSettings.logic"; +import { createPrismSaveStore } from "./PrismSettings.state"; +import { resolveSettingsScope } from "./settingsScope"; + +const environment = { + environmentId: EnvironmentId.make("one"), + label: "one", + connection: { phase: "connected" as const }, + serverConfig: { settings: DEFAULT_SERVER_SETTINGS }, +}; +const models = [{ instanceId: ProviderInstanceId.make("codex"), model: "model", effort: "high" }]; +const expectation = { + kind: "lane" as const, + role: "worker" as const, + lane: "easy" as const, + models, +}; +const scope = resolveSettingsScope({}, [], [environment]); +const plan = planPrismModelsPatch(scope, [environment], "worker", "easy", models); +const updatedEnvironment = { + ...environment, + serverConfig: { + settings: applyServerSettingsPatch( + environment.serverConfig.settings, + plan.serverWrites[0]!.patch, + ), + }, +}; + +describe("Prism save lifecycle", () => { + it("retains the guard without page subscribers until acknowledgement and fresh settings arrive", () => { + const store = createPrismSaveStore(); + const unsubscribe = store.subscribe(() => {}); + expect(store.getState().begin(plan, expectation)).toBe(true); + unsubscribe(); // Scope navigation unmounts the editor while its RPC is outstanding. + expect(store.getState().begin(plan, expectation)).toBe(false); + store.getState().observe([updatedEnvironment]); + expect(store.getState().pendingWrite).not.toBeNull(); // Stream may arrive before the receipt. + store.getState().acknowledge(new Set()); + store.getState().observe([environment]); + expect(store.getState().begin(plan, expectation)).toBe(false); // Receipt alone cannot release stale data. + store.getState().observe([updatedEnvironment]); + expect(store.getState().pendingWrite).toBeNull(); + const next = planPrismModelsPatch(scope, [updatedEnvironment], "worker", "hard", models); + expect(store.getState().begin(next, { ...expectation, lane: "hard" })).toBe(true); + const settings = applyServerSettingsPatch( + updatedEnvironment.serverConfig.settings, + next.serverWrites[0]!.patch, + ); + expect(settings.prismRoles.worker.lanes.easy).toEqual(models); + expect(settings.prismRoles.worker.lanes.hard).toEqual(models); + }); + + it("releases failed writes without expecting nonexistent snapshots and retains their error", () => { + const store = createPrismSaveStore(); + store.getState().begin(plan, expectation); + store.getState().acknowledge(new Set([environment.environmentId])); + store.getState().setSaveError("one failed"); + store.getState().observe([environment]); + expect(store.getState().pendingWrite).toBeNull(); + expect(store.getState().saveError).toBe("one failed"); + expect(store.getState().begin(plan, expectation)).toBe(true); + expect(store.getState().saveError).toBeNull(); + store.getState().fail("Connection lost"); + expect(store.getState().pendingWrite).toBeNull(); + expect(store.getState().saveError).toBe("Connection lost"); + }); +}); diff --git a/apps/web/src/components/settings/PrismSettings.state.ts b/apps/web/src/components/settings/PrismSettings.state.ts new file mode 100644 index 0000000000..d4c3561822 --- /dev/null +++ b/apps/web/src/components/settings/PrismSettings.state.ts @@ -0,0 +1,60 @@ +import { createStore } from "zustand/vanilla"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { + planPrismModelsPatch, + prismWriteObserved, + type PrismWriteExpectation, +} from "./PrismSettings.logic"; + +type Plan = ReturnType; +interface PrismSaveState { + pendingWrite: { plan: Plan; expectation: PrismWriteExpectation; acknowledged: boolean } | null; + saveError: string | null; + begin: (plan: Plan, expectation: PrismWriteExpectation) => boolean; + acknowledge: (failed: ReadonlySet) => void; + observe: (environments: Parameters[1]) => void; + fail: (message: string) => void; + setSaveError: (message: string | null) => void; +} + +export function createPrismSaveStore() { + return createStore((set, get) => ({ + pendingWrite: null, + saveError: null, + begin: (plan, expectation) => { + if (get().pendingWrite) return false; + set({ pendingWrite: { plan, expectation, acknowledged: false }, saveError: null }); + return true; + }, + acknowledge: (failed) => { + const pending = get().pendingWrite; + if (!pending) return; + set({ + pendingWrite: { + ...pending, + acknowledged: true, + plan: { + ...pending.plan, + serverWrites: pending.plan.serverWrites.filter( + (write) => !failed.has(write.environmentId), + ), + }, + }, + }); + }, + observe: (environments) => { + const pending = get().pendingWrite; + if ( + pending?.acknowledged && + prismWriteObserved(pending.plan, environments, pending.expectation) + ) { + set({ pendingWrite: null }); + } + }, + fail: (saveError) => set({ pendingWrite: null, saveError }), + setSaveError: (saveError) => set({ saveError }), + })); +} + +// Scope navigation remounts the settings page. The write and its snapshot barrier must outlive it. +export const prismSaveStore = createPrismSaveStore(); diff --git a/apps/web/src/components/settings/PrismSettings.tsx b/apps/web/src/components/settings/PrismSettings.tsx index d1f263e0fe..764b1532e9 100644 --- a/apps/web/src/components/settings/PrismSettings.tsx +++ b/apps/web/src/components/settings/PrismSettings.tsx @@ -7,6 +7,9 @@ import { import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { Link } from "@tanstack/react-router"; import { useEffect, useRef, useState } from "react"; +import { useStore } from "zustand"; +import { useEnvironments } from "../../state/environments"; +import { prismSaveStore } from "./PrismSettings.state"; import { ArrowDownIcon, ArrowUpIcon, TrashIcon } from "lucide-react"; import { getCustomModelOptionsByInstance } from "../../modelSelection"; import { @@ -29,7 +32,6 @@ import { planPrismModelsPatch, prismModelChoices, prismModelKey, - prismWriteObserved, type PrismWriteExpectation, } from "./PrismSettings.logic"; @@ -259,48 +261,31 @@ export function PrismSettings() { const { scope, target, targets, environment, environments, connectedEnvironments } = useSettingsScope(); const settings = useScopedSettings(); - const [saveError, setSaveError] = useState(null); + const { pendingWrite, saveError, setSaveError } = useStore(prismSaveStore); + const { environments: allEnvironments } = useEnvironments(); const persist = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false }); - const saveLock = useRef(false); - const [pendingWrite, setPendingWrite] = useState<{ - plan: ReturnType; - expectation: PrismWriteExpectation; - acknowledged: boolean; - } | null>(null); - const observed = - pendingWrite?.acknowledged && - prismWriteObserved(pendingWrite.plan, environments, pendingWrite.expectation); useEffect(() => { - if (!observed) return; - saveLock.current = false; - setPendingWrite(null); - }, [observed]); + if (pendingWrite?.acknowledged) prismSaveStore.getState().observe(allEnvironments); + }, [allEnvironments, pendingWrite]); async function savePlan( plan: ReturnType, expectation: PrismWriteExpectation, ) { - if (saveLock.current) throw new Error("Wait for the current settings update to finish."); if (plan.unavailableReason) throw new Error(plan.unavailableReason); - saveLock.current = true; - setSaveError(null); - setPendingWrite({ plan, expectation, acknowledged: false }); + if (!prismSaveStore.getState().begin(plan, expectation)) { + throw new Error("Wait for the current settings update to finish."); + } let result; try { result = await persistScopedSettingsPatch(plan, persist, () => {}); } catch (cause) { - saveLock.current = false; - setPendingWrite(null); + prismSaveStore + .getState() + .fail(cause instanceof Error ? cause.message : "Could not save preferences."); throw cause; } const failed = new Set(result.failedEnvironments.map((env) => env.environmentId)); - setPendingWrite({ - plan: { - ...plan, - serverWrites: plan.serverWrites.filter((write) => !failed.has(write.environmentId)), - }, - expectation, - acknowledged: true, - }); + prismSaveStore.getState().acknowledge(failed); if (failed.size) { const message = `Could not save preferences on ${result.failedEnvironments.map((env) => env.label).join(", ")}.${result.savedEnvironmentCount ? " Other selected environments saved the change." : ""}`; // The representative update remounts its lane editor, so failures also live on the page.