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..a2b0674fc0 --- /dev/null +++ b/apps/web/src/components/settings/PrismSettings.logic.test.ts @@ -0,0 +1,255 @@ +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, + prismWriteObserved, +} 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 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"], + lanes: { ...rolesLanes, medium: [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", "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, + lanes: { ...rolesLanes, medium: [second] }, + }); + expect(next.prismRoles.planner).toEqual(roles.planner); + const clear = planPrismModelsPatch(scope, envs, "worker", "medium", []); + expect( + applyServerSettingsPatch(next, clear.serverWrites[0]!.patch).prismRoles.worker.lanes.medium, + ).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", "medium", [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", + lanes: { ...rolesLanes, medium: [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", "medium", []).unavailableReason, + ).toBeTruthy(); + const envs = [environment("one"), environment("two")]; + const plan = planPrismModelsPatch( + resolveSettingsScope({}, [], envs), + envs, + "worker", + "medium", + [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([]); +}); + +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); +}); + +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 new file mode 100644 index 0000000000..cfdde34b10 --- /dev/null +++ b/apps/web/src/components/settings/PrismSettings.logic.ts @@ -0,0 +1,137 @@ +import { + ProjectId, + type PrismModelPreference, + type PrismRole, + type PrismLane, + 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 T[], + 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, + lane: PrismLane, + models: readonly PrismModelPreference[], +) { + const plan = planScopedSettingsPatch(scope, environments, { + prismRoles: { [role]: { lanes: { [lane]: 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], lanes: { ...roles[role].lanes, [lane]: 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, + ), + })), + ); +} + +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.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 new file mode 100644 index 0000000000..764b1532e9 --- /dev/null +++ b/apps/web/src/components/settings/PrismSettings.tsx @@ -0,0 +1,472 @@ +import { + PRISM_ROLES, + PrismLane, + type PrismModelPreference, + type PrismRole, +} from "@t3tools/contracts"; +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 { + 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 } from "./useScopedSettings"; +import { useScopedModelDisabledReason } from "./useScopedModelAvailability"; +import { persistScopedSettingsPatch, planScopedSettingsClear } from "./scopedSettings"; +import { + movePrismPreference, + planPrismModelsPatch, + prismModelChoices, + prismModelKey, + type PrismWriteExpectation, +} from "./PrismSettings.logic"; + +type ModelChoice = ReturnType[number]; + +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, + disabled, + 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.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(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 lane'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 === 0 ? "Primary" : `Fallback ${index}`} + + + {choice?.label ?? `${entry.instanceId} / ${entry.model}`} + {!choice && ( + + Unavailable on one or more selected targets. + + )} + + + + + +
  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 { pendingWrite, saveError, setSaveError } = useStore(prismSaveStore); + const { environments: allEnvironments } = useEnvironments(); + const persist = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false }); + useEffect(() => { + if (pendingWrite?.acknowledged) prismSaveStore.getState().observe(allEnvironments); + }, [allEnvironments, pendingWrite]); + async function savePlan( + plan: ReturnType, + expectation: PrismWriteExpectation, + ) { + if (plan.unavailableReason) throw new Error(plan.unavailableReason); + 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) { + prismSaveStore + .getState() + .fail(cause instanceof Error ? cause.message : "Could not save preferences."); + throw cause; + } + const failed = new Set(result.failedEnvironments.map((env) => env.environmentId)); + 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. + 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); + 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 ( + + + {pendingWrite && ( +

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

+ )} + {saveError && ( +

+ {saveError} +

+ )} +

+ 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 && ( +

+ Connect an environment to edit Prism preferences. +

+ )} + {(scope.kind === "project" || scope.kind === "checkout") && ( +
+ +
+ )} +
+ {PRISM_ROLES.map((role) => ( + +
+ {PrismLane.literals.map((lane) => ( + + JSON.stringify(candidate.settings.prismRoles[role].lanes[lane]) !== + JSON.stringify(settings.prismRoles[role].lanes[lane]), + )} + save={(models) => + savePlan(planPrismModelsPatch(scope, environments, role, lane, models), { + kind: "lane", + role, + lane, + models, + }) + } + /> + ))} +
+
+ ))} + +
+ {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