authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-07 03:57:22-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-07 00:57:22-07:00
log95305c36fa418301183e4750f13b6a525a20cafe
tree333599e14932c5344fd5c0dd277a1017814a080f
parent48aa875c0e6f8f2ee83d6972dd4357a4a083fe30
signature Signed by PGP key B5690EEEBB952194

feat(web): per-device provider settings (#4479)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

16 files changed, 1961 insertions(+), 688 deletions(-)

apps/web/src/components/ConnectionStatusDot.tsx+22
......@@ -1,6 +1,28 @@
1import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
2
13import { cn } from "~/lib/utils";
24import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
35
6/** Canonical connection-phase → dot color mapping shared by every status dot. */
7export function connectionPhaseDotClassName(phase: EnvironmentConnectionPhase): string {
8 switch (phase) {
9 case "connected":
10 return "bg-success";
11 case "connecting":
12 case "reconnecting":
13 return "bg-warning";
14 case "error":
15 return "bg-destructive";
16 default:
17 return "bg-muted-foreground/40";
18 }
19}
20
21/** Ping halo for transitional phases; null renders no ping. */
22export function connectionPhasePingClassName(phase: EnvironmentConnectionPhase): string | null {
23 return phase === "connecting" || phase === "reconnecting" ? "bg-warning/60 duration-2000" : null;
24}
25
426type ConnectionStatusDotProps = {
527 tooltipText?: string | null;
628 dotClassName: string;
apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx created+54
......@@ -0,0 +1,54 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
3
4import { reactHookHarness as hooks } from "../../test/reactHookHarness";
5
6const settingsHooks = vi.hoisted(() => ({
7 read: vi.fn(() => ({ providerInstances: {} })),
8 update: vi.fn(() => vi.fn()),
9}));
10
11vi.mock("react", async (importOriginal) => {
12 const actual = await importOriginal<typeof import("react")>();
13 const { reactHookHarness } = await import("../../test/reactHookHarness");
14 return {
15 ...actual,
16 useMemo: reactHookHarness.useMemo,
17 useState: reactHookHarness.useState,
18 };
19});
20
21vi.mock("react/compiler-runtime", async () => {
22 const { reactHookHarness } = await import("../../test/reactHookHarness");
23 return { c: reactHookHarness.useMemoCache };
24});
25
26vi.mock("../../hooks/useSettings", () => ({
27 useEnvironmentSettings: settingsHooks.read,
28 useUpdateEnvironmentSettings: settingsHooks.update,
29}));
30
31import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog";
32
33const remoteEnvironmentId = EnvironmentId.make("remote-device");
34
35describe("AddProviderInstanceDialog environment routing", () => {
36 beforeEach(() => {
37 hooks.reset();
38 settingsHooks.read.mockClear();
39 settingsHooks.update.mockClear();
40 });
41
42 it("reads and writes settings through the supplied environment", () => {
43 hooks.beginRender();
44 AddProviderInstanceDialog({
45 open: true,
46 environmentId: remoteEnvironmentId,
47 environmentLabel: "Remote device",
48 onOpenChange: vi.fn(),
49 });
50
51 expect(settingsHooks.read).toHaveBeenCalledWith(remoteEnvironmentId);
52 expect(settingsHooks.update).toHaveBeenCalledWith(remoteEnvironmentId);
53 });
54});
apps/web/src/components/settings/AddProviderInstanceDialog.tsx+16-8
......@@ -6,10 +6,11 @@ import { useMemo, useState } from "react";
66import {
77 ProviderInstanceId,
88 ProviderDriverKind,
9 type EnvironmentId,
910 type ProviderInstanceConfig,
1011} from "@t3tools/contracts";
1112
12import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings";
13import { useEnvironmentSettings, useUpdateEnvironmentSettings } from "../../hooks/useSettings";
1314import { cn } from "../../lib/utils";
1415import { normalizeProviderAccentColor } from "../../providerInstances";
1516import { Button } from "../ui/button";
......@@ -115,13 +116,20 @@ function validateInstanceId(id: string, existing: ReadonlySet<string>): string |
115116}
116117
117118interface AddProviderInstanceDialogProps {
118 open: boolean;
119 onOpenChange: (open: boolean) => void;
119 readonly open: boolean;
120 readonly environmentId: EnvironmentId;
121 readonly environmentLabel: string;
122 readonly onOpenChange: (open: boolean) => void;
120123}
121124
122export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderInstanceDialogProps) {
123 const settings = usePrimarySettings();
124 const updateSettings = useUpdatePrimarySettings();
125export function AddProviderInstanceDialog({
126 open,
127 environmentId,
128 environmentLabel,
129 onOpenChange,
130}: AddProviderInstanceDialogProps) {
131 const settings = useEnvironmentSettings(environmentId);
132 const updateSettings = useUpdateEnvironmentSettings(environmentId);
125133
126134 const [wizardStep, setWizardStep] = useState(0);
127135 const [driver, setDriver] = useState<ProviderDriverKind>(DEFAULT_DRIVER_KIND);
......@@ -227,8 +235,8 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns
227235 <DialogHeader>
228236 <DialogTitle>Add provider instance</DialogTitle>
229237 <DialogDescription>
230 Configure an additional provider instance — for example, a second Codex install
231 pointed at a different workspace.
238 Configure an additional provider instance on {environmentLabel} — for example, a
239 second Codex install pointed at a different workspace.
232240 </DialogDescription>
233241 <AddProviderInstanceWizardSteps
234242 currentStep={wizardStep}
apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx created+256
......@@ -0,0 +1,256 @@
1import type { ReactElement } from "react";
2import {
3 DEFAULT_UNIFIED_SETTINGS,
4 EnvironmentId,
5 ProviderDriverKind,
6 ProviderInstanceId,
7 type ServerProvider,
8 type UnifiedSettings,
9} from "@t3tools/contracts";
10import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
11
12import { visitElements } from "../../test/reactElementTree";
13import { reactHookHarness as hooks } from "../../test/reactHookHarness";
14
15const atoms = vi.hoisted(() => ({
16 providers: null as ReadonlyArray<ServerProvider> | null,
17 providersAtom: Symbol("providers"),
18 refreshProviders: Symbol("refreshProviders"),
19 updateProvider: Symbol("updateProvider"),
20}));
21
22const commands = vi.hoisted(() => ({
23 refresh: vi.fn(),
24 updateProvider: vi.fn(),
25}));
26
27const settingsState = vi.hoisted(() => ({
28 value: null as UnifiedSettings | null,
29 readEnvironmentIds: [] as EnvironmentId[],
30 updateEnvironmentIds: [] as EnvironmentId[],
31 updateSettings: vi.fn(),
32}));
33
34vi.mock("react", async (importOriginal) => {
35 const actual = await importOriginal<typeof import("react")>();
36 const { reactHookHarness } = await import("../../test/reactHookHarness");
37 return {
38 ...actual,
39 useCallback: reactHookHarness.useCallback,
40 useMemo: reactHookHarness.useMemo,
41 useRef: reactHookHarness.useRef,
42 useState: reactHookHarness.useState,
43 };
44});
45
46vi.mock("react/compiler-runtime", async () => {
47 const { reactHookHarness } = await import("../../test/reactHookHarness");
48 return { c: reactHookHarness.useMemoCache };
49});
50
51vi.mock("@effect/atom-react", () => ({
52 useAtomValue: () => atoms.providers,
53}));
54
55vi.mock("../../state/server", () => ({
56 EMPTY_SERVER_PROVIDERS: [],
57 serverEnvironment: {
58 providersValueAtom: () => atoms.providersAtom,
59 refreshProviders: atoms.refreshProviders,
60 updateProvider: atoms.updateProvider,
61 },
62}));
63
64vi.mock("../../state/use-atom-command", () => ({
65 useAtomCommand: (atom: symbol) =>
66 atom === atoms.refreshProviders ? commands.refresh : commands.updateProvider,
67}));
68
69vi.mock("../../hooks/useSettings", () => ({
70 useEnvironmentSettings: (environmentId: EnvironmentId) => {
71 settingsState.readEnvironmentIds.push(environmentId);
72 return settingsState.value;
73 },
74 useUpdateEnvironmentSettings: (environmentId: EnvironmentId) => {
75 settingsState.updateEnvironmentIds.push(environmentId);
76 return settingsState.updateSettings;
77 },
78}));
79
80vi.mock("../../environments/primary", () => ({
81 usePrimarySessionState: () => ({ data: null, error: null, isPending: false, refresh: vi.fn() }),
82}));
83
84vi.mock("../../state/session", () => ({
85 useEnvironmentSessionState: () => ({ data: null, hasError: false, isPending: true }),
86}));
87
88import { EnvironmentProviderSettings } from "./ProviderSettingsPanel";
89
90const environmentId = EnvironmentId.make("remote-device");
91const codexId = ProviderInstanceId.make("codex");
92const customId = ProviderInstanceId.make("codex_work");
93
94function provider(): ServerProvider {
95 return {
96 instanceId: codexId,
97 driver: ProviderDriverKind.make("codex"),
98 enabled: true,
99 installed: true,
100 version: "1.0.0",
101 status: "ready",
102 auth: { status: "authenticated" },
103 checkedAt: "2026-07-24T12:00:00.000Z",
104 models: [],
105 slashCommands: [],
106 skills: [],
107 versionAdvisory: {
108 status: "behind_latest",
109 currentVersion: "1.0.0",
110 latestVersion: "1.1.0",
111 updateCommand: "pnpm add -g @openai/codex@latest",
112 canUpdate: true,
113 checkedAt: "2026-07-24T12:00:00.000Z",
114 message: "Update available.",
115 },
116 };
117}
118
119function renderPanel(options?: {
120 readonly readOnly?: boolean;
121}): ReactElement<Record<string, unknown>> {
122 hooks.beginRender();
123 return EnvironmentProviderSettings({
124 environmentId,
125 environmentLabel: "Remote device",
126 ...(options?.readOnly === undefined ? {} : { readOnly: options.readOnly }),
127 }) as ReactElement<Record<string, unknown>>;
128}
129
130async function flushPromises(): Promise<void> {
131 await Promise.resolve();
132 await Promise.resolve();
133}
134
135describe("EnvironmentProviderSettings routing", () => {
136 beforeEach(() => {
137 hooks.reset();
138 atoms.providers = null;
139 settingsState.value = DEFAULT_UNIFIED_SETTINGS;
140 settingsState.readEnvironmentIds = [];
141 settingsState.updateEnvironmentIds = [];
142 settingsState.updateSettings.mockReset();
143 commands.refresh.mockReset().mockResolvedValue({ _tag: "Success" });
144 commands.updateProvider.mockReset().mockResolvedValue({ _tag: "Success" });
145 });
146
147 it("coalesces a nullable provider snapshot before rendering array-backed UI", () => {
148 expect(() => renderPanel()).not.toThrow();
149 expect(settingsState.readEnvironmentIds).toEqual([environmentId]);
150 expect(settingsState.updateEnvironmentIds).toEqual([environmentId]);
151 });
152
153 it("routes refresh and provider update commands to the selected environment", async () => {
154 atoms.providers = [provider()];
155 const panel = renderPanel();
156 const refreshButton = visitElements(
157 panel,
158 (element) => element.props["aria-label"] === "Refresh provider status",
159 );
160 expect(refreshButton).not.toBeNull();
161 (refreshButton?.props.onClick as (() => void) | undefined)?.();
162 await flushPromises();
163
164 expect(commands.refresh).toHaveBeenCalledWith({ environmentId, input: {} });
165
166 const providerCard = visitElements(
167 panel,
168 (element) =>
169 element.props.instanceId === codexId && typeof element.props.onRunUpdate === "function",
170 );
171 expect(providerCard).not.toBeNull();
172 (providerCard?.props.onRunUpdate as (() => void) | undefined)?.();
173 await flushPromises();
174
175 expect(commands.updateProvider).toHaveBeenCalledWith({
176 environmentId,
177 input: { provider: ProviderDriverKind.make("codex"), instanceId: codexId },
178 });
179 });
180
181 it("renders the provider layout inert with a limited-permissions notice when read only", () => {
182 atoms.providers = [provider()];
183 const panel = renderPanel({ readOnly: true });
184
185 const inertWrapper = visitElements(panel, (element) => element.props.inert === true);
186 expect(inertWrapper).not.toBeNull();
187 const providerCard = visitElements(panel, (element) => element.props.instanceId === codexId);
188 expect(providerCard).not.toBeNull();
189
190 const notice = visitElements(panel, (element) => element.props.title === "Limited permissions");
191 expect(notice).not.toBeNull();
192
193 expect(
194 visitElements(panel, (element) => element.props["aria-label"] === "Add provider instance"),
195 ).toBeNull();
196 expect(
197 visitElements(panel, (element) => element.props["aria-label"] === "Refresh provider status"),
198 ).toBeNull();
199 });
200
201 it("keeps the editable layout interactive when not read only", () => {
202 atoms.providers = [provider()];
203 const panel = renderPanel();
204 expect(visitElements(panel, (element) => element.props.inert === true)).toBeNull();
205 expect(
206 visitElements(panel, (element) => element.props.title === "Limited permissions"),
207 ).toBeNull();
208 });
209
210 it("deletes and resets provider configuration without erasing shared preferences", () => {
211 settingsState.value = {
212 ...DEFAULT_UNIFIED_SETTINGS,
213 providerInstances: {
214 [codexId]: {
215 driver: ProviderDriverKind.make("codex"),
216 enabled: false,
217 },
218 [customId]: {
219 driver: ProviderDriverKind.make("codex"),
220 enabled: true,
221 },
222 },
223 providerModelPreferences: {
224 [customId]: { hiddenModels: ["hidden"], modelOrder: ["model"] },
225 },
226 favorites: [{ provider: customId, model: "favorite" }],
227 };
228 const panel = renderPanel();
229 const customCard = visitElements(panel, (element) => element.props.instanceId === customId);
230 expect(customCard).not.toBeNull();
231 (customCard?.props.onDelete as (() => void) | undefined)?.();
232
233 expect(settingsState.updateSettings).toHaveBeenLastCalledWith({
234 providerInstances: {
235 [codexId]: settingsState.value.providerInstances?.[codexId],
236 },
237 });
238
239 settingsState.updateSettings.mockClear();
240 const defaultCard = visitElements(panel, (element) => element.props.instanceId === codexId);
241 const resetAction = defaultCard?.props.headerAction;
242 const resetButton = visitElements(
243 resetAction,
244 (element) => typeof element.props.onClick === "function",
245 );
246 expect(resetButton).not.toBeNull();
247 (resetButton?.props.onClick as (() => void) | undefined)?.();
248
249 const resetPatch = settingsState.updateSettings.mock.lastCall?.[0] as
250 | Record<string, unknown>
251 | undefined;
252 expect(Object.keys(resetPatch ?? {}).sort()).toEqual(["providerInstances", "providers"]);
253 expect(resetPatch).not.toHaveProperty("favorites");
254 expect(resetPatch).not.toHaveProperty("providerModelPreferences");
255 });
256});
apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts created+260
......@@ -0,0 +1,260 @@
1import { AuthOrchestrationOperateScope, EnvironmentId } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
5 buildProviderEnvironmentOptions,
6 classifyProviderEnvironmentAccess,
7 resolvePrimaryOperateAccess,
8 resolveRemoteOperateAccess,
9 resolveSelectedProviderEnvironmentId,
10} from "./ProviderSettingsPanel.logic";
11
12const primaryId = EnvironmentId.make("primary");
13const relayId = EnvironmentId.make("relay");
14const sshId = EnvironmentId.make("ssh");
15
16const environments = [
17 { environmentId: sshId, label: "Zulu SSH" },
18 { environmentId: relayId, label: "Alpha Relay" },
19 { environmentId: primaryId, label: "This device" },
20] as const;
21
22describe("provider environment selection", () => {
23 it("sorts the primary environment first and the rest by label", () => {
24 expect(
25 buildProviderEnvironmentOptions(environments, primaryId).map(
26 (environment) => environment.environmentId,
27 ),
28 ).toEqual([primaryId, relayId, sshId]);
29 });
30
31 it("keeps a valid selection, then falls back to primary or the first environment", () => {
32 const options = buildProviderEnvironmentOptions(environments, primaryId);
33
34 expect(resolveSelectedProviderEnvironmentId(options, sshId, primaryId)).toBe(sshId);
35 expect(
36 resolveSelectedProviderEnvironmentId(
37 options.filter((environment) => environment.environmentId !== sshId),
38 sshId,
39 primaryId,
40 ),
41 ).toBe(primaryId);
42 expect(resolveSelectedProviderEnvironmentId(options.slice(1), primaryId, primaryId)).toBe(
43 relayId,
44 );
45 expect(resolveSelectedProviderEnvironmentId([], null, primaryId)).toBeNull();
46 });
47});
48
49describe("provider environment access", () => {
50 it("allows connected environments with config and operate access", () => {
51 expect(
52 classifyProviderEnvironmentAccess({
53 connectionPhase: "connected",
54 hasServerConfig: true,
55 operateAccess: "granted",
56 }),
57 ).toEqual({ kind: "editable" });
58 });
59
60 it("waits for config before exposing controls", () => {
61 expect(
62 classifyProviderEnvironmentAccess({
63 connectionPhase: "connected",
64 hasServerConfig: false,
65 operateAccess: "granted",
66 }),
67 ).toEqual({ kind: "loading", reason: "config" });
68 });
69
70 it("waits for unresolved operate access instead of assuming it is editable", () => {
71 expect(
72 classifyProviderEnvironmentAccess({
73 connectionPhase: "connected",
74 hasServerConfig: true,
75 operateAccess: "pending",
76 }),
77 ).toEqual({ kind: "loading", reason: "permissions" });
78 });
79
80 it("represents known missing operate access as read only", () => {
81 expect(
82 classifyProviderEnvironmentAccess({
83 connectionPhase: "connected",
84 hasServerConfig: true,
85 operateAccess: "denied",
86 }),
87 ).toEqual({ kind: "read-only" });
88 });
89
90 it.each(["available", "offline", "connecting", "reconnecting"] as const)(
91 "keeps %s environments unavailable",
92 (connectionPhase) => {
93 expect(
94 classifyProviderEnvironmentAccess({
95 connectionPhase,
96 hasServerConfig: true,
97 operateAccess: "granted",
98 }),
99 ).toEqual({ kind: "unavailable" });
100 },
101 );
102
103 it("separates connection errors from other unavailable states", () => {
104 expect(
105 classifyProviderEnvironmentAccess({
106 connectionPhase: "error",
107 hasServerConfig: true,
108 operateAccess: "granted",
109 }),
110 ).toEqual({ kind: "error" });
111 });
112});
113
114describe("primary operate access", () => {
115 const authenticated = {
116 authenticated: true as const,
117 scopes: [AuthOrchestrationOperateScope],
118 };
119
120 it("keeps cached session data authoritative while SWR revalidates", () => {
121 expect(
122 resolvePrimaryOperateAccess({
123 isPrimary: true,
124 hasDesktopBridge: false,
125 session: authenticated,
126 isPending: true,
127 hasError: false,
128 }),
129 ).toBe("granted");
130 });
131
132 it("reports pending only before any session has resolved", () => {
133 expect(
134 resolvePrimaryOperateAccess({
135 isPrimary: true,
136 hasDesktopBridge: false,
137 session: null,
138 isPending: true,
139 hasError: false,
140 }),
141 ).toBe("pending");
142 });
143
144 it("treats a failed session fetch as a transport problem, not a denial", () => {
145 expect(
146 resolvePrimaryOperateAccess({
147 isPrimary: true,
148 hasDesktopBridge: false,
149 session: null,
150 isPending: false,
151 hasError: true,
152 }),
153 ).toBe("granted");
154 });
155
156 it("denies unauthenticated sessions and sessions without the operate scope", () => {
157 expect(
158 resolvePrimaryOperateAccess({
159 isPrimary: true,
160 hasDesktopBridge: false,
161 session: { authenticated: false },
162 isPending: false,
163 hasError: false,
164 }),
165 ).toBe("denied");
166 expect(
167 resolvePrimaryOperateAccess({
168 isPrimary: true,
169 hasDesktopBridge: false,
170 session: { authenticated: true, scopes: ["orchestration:read"] },
171 isPending: false,
172 hasError: false,
173 }),
174 ).toBe("denied");
175 expect(
176 resolvePrimaryOperateAccess({
177 isPrimary: true,
178 hasDesktopBridge: false,
179 session: null,
180 isPending: false,
181 hasError: false,
182 }),
183 ).toBe("denied");
184 });
185
186 it("grants desktop bridge and remote environments without blocking on the primary session", () => {
187 expect(
188 resolvePrimaryOperateAccess({
189 isPrimary: true,
190 hasDesktopBridge: true,
191 session: null,
192 isPending: true,
193 hasError: false,
194 }),
195 ).toBe("granted");
196 expect(
197 resolvePrimaryOperateAccess({
198 isPrimary: false,
199 hasDesktopBridge: false,
200 session: null,
201 isPending: true,
202 hasError: false,
203 }),
204 ).toBe("granted");
205 });
206});
207
208describe("remote operate access", () => {
209 it("derives access from the environment session's granted scopes", () => {
210 expect(
211 resolveRemoteOperateAccess({
212 session: { authenticated: true, scopes: [AuthOrchestrationOperateScope] },
213 isPending: false,
214 hasError: false,
215 }),
216 ).toBe("granted");
217 expect(
218 resolveRemoteOperateAccess({
219 session: { authenticated: true, scopes: ["orchestration:read"] },
220 isPending: false,
221 hasError: false,
222 }),
223 ).toBe("denied");
224 expect(
225 resolveRemoteOperateAccess({
226 session: { authenticated: false },
227 isPending: false,
228 hasError: false,
229 }),
230 ).toBe("denied");
231 });
232
233 it("reports pending before the first session resolve, then keeps cached data", () => {
234 expect(resolveRemoteOperateAccess({ session: null, isPending: true, hasError: false })).toBe(
235 "pending",
236 );
237 expect(
238 resolveRemoteOperateAccess({
239 session: { authenticated: true, scopes: [AuthOrchestrationOperateScope] },
240 isPending: true,
241 hasError: false,
242 }),
243 ).toBe("granted");
244 });
245
246 it("stays optimistic when the session fetch fails or an older server omits scopes", () => {
247 // Transport failures and pre-scope-reporting servers are not permission
248 // decisions; the environment RPC layer still rejects unauthorized writes.
249 expect(resolveRemoteOperateAccess({ session: null, isPending: false, hasError: true })).toBe(
250 "granted",
251 );
252 expect(
253 resolveRemoteOperateAccess({
254 session: { authenticated: true },
255 isPending: false,
256 hasError: false,
257 }),
258 ).toBe("granted");
259 });
260});
apps/web/src/components/settings/ProviderSettingsPanel.logic.ts created+160
......@@ -0,0 +1,160 @@
1import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
2import {
3 AuthOrchestrationOperateScope,
4 type AuthSessionState,
5 type EnvironmentId,
6} from "@t3tools/contracts";
7
8export interface ProviderEnvironmentOptionLike {
9 readonly environmentId: EnvironmentId;
10 readonly label: string;
11}
12
13export function buildProviderEnvironmentOptions<T extends ProviderEnvironmentOptionLike>(
14 environments: ReadonlyArray<T>,
15 primaryEnvironmentId: EnvironmentId | null,
16): ReadonlyArray<T> {
17 return environments.toSorted((left, right) => {
18 const leftIsPrimary = left.environmentId === primaryEnvironmentId;
19 const rightIsPrimary = right.environmentId === primaryEnvironmentId;
20 if (leftIsPrimary !== rightIsPrimary) {
21 return leftIsPrimary ? -1 : 1;
22 }
23 return (
24 left.label.localeCompare(right.label) ||
25 String(left.environmentId).localeCompare(String(right.environmentId))
26 );
27 });
28}
29
30export function resolveSelectedProviderEnvironmentId(
31 environments: ReadonlyArray<ProviderEnvironmentOptionLike>,
32 selectedEnvironmentId: EnvironmentId | null,
33 primaryEnvironmentId: EnvironmentId | null,
34): EnvironmentId | null {
35 if (
36 selectedEnvironmentId !== null &&
37 environments.some((environment) => environment.environmentId === selectedEnvironmentId)
38 ) {
39 return selectedEnvironmentId;
40 }
41 if (
42 primaryEnvironmentId !== null &&
43 environments.some((environment) => environment.environmentId === primaryEnvironmentId)
44 ) {
45 return primaryEnvironmentId;
46 }
47 return environments[0]?.environmentId ?? null;
48}
49
50export type ProviderEnvironmentAccess =
51 | { readonly kind: "editable" }
52 /** `reason` distinguishes waiting on the device from waiting on permissions. */
53 | { readonly kind: "loading"; readonly reason: "config" | "permissions" }
54 | { readonly kind: "read-only" }
55 | { readonly kind: "unavailable" }
56 | { readonly kind: "error" };
57
58/**
59 * Whether the session may change provider configuration on an environment.
60 * `pending` means the answer is still unknown, which must not be presented as
61 * editable: rendering controls we already know might be rejected only turns a
62 * permission problem into a failed write.
63 */
64export type ProviderOperateAccess = "granted" | "denied" | "pending";
65
66/**
67 * Resolve operate access from an environment's `/api/auth/session` answer.
68 *
69 * Cached session data wins over an in-flight revalidation. The session atoms
70 * are SWR-backed, so they report `isPending` on every background refresh;
71 * treating that as unknown would flip a working panel back to loading and
72 * discard in-progress edits.
73 *
74 * `missingScopesAccess` decides the case where the session resolved but did
75 * not report scopes: the primary serves the web app itself so its server
76 * always reports them (absence means denial), while a remote device may run an
77 * older server version that predates scope reporting, where denial would lock
78 * out a legitimate session. The environment RPC layer stays authoritative
79 * either way.
80 */
81function resolveSessionOperateAccess(input: {
82 readonly session: Pick<AuthSessionState, "authenticated" | "scopes"> | null;
83 readonly isPending: boolean;
84 readonly hasError: boolean;
85 readonly missingScopesAccess: "granted" | "denied";
86}): ProviderOperateAccess {
87 if (input.session === null) {
88 if (input.isPending) {
89 return "pending";
90 }
91 // A failed session fetch is a transport problem, not a permission
92 // decision — locking the panel read-only would misreport it. Stay
93 // optimistic; the environment RPC layer still rejects unauthorized writes.
94 return input.hasError ? "granted" : "denied";
95 }
96 if (!input.session.authenticated) {
97 return "denied";
98 }
99 if (input.session.scopes === undefined) {
100 return input.missingScopesAccess;
101 }
102 return input.session.scopes.includes(AuthOrchestrationOperateScope) ? "granted" : "denied";
103}
104
105/** Operate access for the primary environment's own browser session. */
106export function resolvePrimaryOperateAccess(input: {
107 readonly isPrimary: boolean;
108 readonly hasDesktopBridge: boolean;
109 readonly session: Pick<AuthSessionState, "authenticated" | "scopes"> | null;
110 readonly isPending: boolean;
111 readonly hasError: boolean;
112}): ProviderOperateAccess {
113 if (!input.isPrimary || input.hasDesktopBridge) {
114 return "granted";
115 }
116 return resolveSessionOperateAccess({
117 session: input.session,
118 isPending: input.isPending,
119 hasError: input.hasError,
120 missingScopesAccess: "denied",
121 });
122}
123
124/**
125 * Operate access for a non-primary environment, derived from the scopes its
126 * `/api/auth/session` endpoint reports for this client's credential.
127 */
128export function resolveRemoteOperateAccess(input: {
129 readonly session: Pick<AuthSessionState, "authenticated" | "scopes"> | null;
130 readonly isPending: boolean;
131 readonly hasError: boolean;
132}): ProviderOperateAccess {
133 return resolveSessionOperateAccess({
134 ...input,
135 missingScopesAccess: "granted",
136 });
137}
138
139export function classifyProviderEnvironmentAccess(input: {
140 readonly connectionPhase: EnvironmentConnectionPhase;
141 readonly hasServerConfig: boolean;
142 readonly operateAccess: ProviderOperateAccess;
143}): ProviderEnvironmentAccess {
144 if (input.connectionPhase === "error") {
145 return { kind: "error" };
146 }
147 if (input.connectionPhase !== "connected") {
148 return { kind: "unavailable" };
149 }
150 if (!input.hasServerConfig) {
151 return { kind: "loading", reason: "config" };
152 }
153 if (input.operateAccess === "pending") {
154 return { kind: "loading", reason: "permissions" };
155 }
156 if (input.operateAccess === "denied") {
157 return { kind: "read-only" };
158 }
159 return { kind: "editable" };
160}
apps/web/src/components/settings/ProviderSettingsPanel.tsx created+902
......@@ -0,0 +1,902 @@
1import { useAtomValue } from "@effect/atom-react";
2import { connectionStatusText } from "@t3tools/client-runtime/connection";
3import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
4import {
5 isAtomCommandInterrupted,
6 squashAtomCommandFailure,
7} from "@t3tools/client-runtime/state/runtime";
8import {
9 defaultInstanceIdForDriver,
10 type EnvironmentId,
11 PROVIDER_DISPLAY_NAMES,
12 ProviderDriverKind,
13 type ProviderInstanceConfig,
14 type ProviderInstanceId,
15} from "@t3tools/contracts";
16import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
17import {
18 getBackgroundActivityPresetSettings,
19 resolveServerBackgroundActivitySettings,
20} from "@t3tools/shared/backgroundActivitySettings";
21import * as Arr from "effect/Array";
22import * as Duration from "effect/Duration";
23import * as Equal from "effect/Equal";
24import * as Result from "effect/Result";
25import {
26 CloudIcon,
27 LaptopIcon,
28 LoaderIcon,
29 MonitorIcon,
30 PlusIcon,
31 RefreshCwIcon,
32 TerminalIcon,
33} from "lucide-react";
34import { useCallback, useMemo, useRef, useState } from "react";
35
36import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal";
37import { isElectron } from "../../env";
38import { usePrimarySessionState } from "../../environments/primary";
39import { useEnvironmentSettings, useUpdateEnvironmentSettings } from "../../hooks/useSettings";
40import { cn } from "../../lib/utils";
41import { resolveAppModelSelectionState } from "../../modelSelection";
42import {
43 useEnvironments,
44 usePrimaryEnvironmentId,
45 type EnvironmentPresentation,
46} from "../../state/environments";
47import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server";
48import { useEnvironmentSessionState } from "../../state/session";
49import { useAtomCommand } from "../../state/use-atom-command";
50import { getRelativeTimeState } from "../../timestampFormat";
51import {
52 ConnectionStatusDot,
53 connectionPhaseDotClassName,
54 connectionPhasePingClassName,
55} from "../ConnectionStatusDot";
56import {
57 canOneClickUpdateProviderCandidate,
58 collectProviderUpdateCandidates,
59 hasOneClickUpdateProviderCandidate,
60 isProviderUpdateActive,
61 type ProviderUpdateCandidate,
62} from "../ProviderUpdateLaunchNotification.logic";
63import { Button } from "../ui/button";
64import {
65 NumberField,
66 NumberFieldDecrement,
67 NumberFieldGroup,
68 NumberFieldIncrement,
69 NumberFieldInput,
70} from "../ui/number-field";
71import { stackedThreadToast, toastManager } from "../ui/toast";
72import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
73import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog";
74import { ProviderInstanceCard } from "./ProviderInstanceCard";
75import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta";
76import { searchableSetting } from "./settingsSearch";
77import {
78 backgroundActivityOverrideSettings,
79 buildProviderInstanceUpdatePatch,
80 durationToSeconds,
81 normalizeIntervalSeconds,
82 PROVIDER_HEALTH_INTERVAL_STEP_SECONDS,
83} from "./SettingsPanels.logic";
84import {
85 PolicyTooltip,
86 SettingResetButton,
87 SettingsPageContainer,
88 SettingsRow,
89 SettingsSection,
90 useRelativeTimeTick,
91} from "./settingsLayout";
92import {
93 buildProviderEnvironmentOptions,
94 classifyProviderEnvironmentAccess,
95 type ProviderEnvironmentAccess,
96 type ProviderOperateAccess,
97 resolvePrimaryOperateAccess,
98 resolveRemoteOperateAccess,
99 resolveSelectedProviderEnvironmentId,
100} from "./ProviderSettingsPanel.logic";
101
102function withoutProviderInstanceKey<V>(
103 record: Readonly<Record<ProviderInstanceId, V>> | undefined,
104 key: ProviderInstanceId,
105): Record<ProviderInstanceId, V> {
106 const next = { ...record } as Record<ProviderInstanceId, V>;
107 delete next[key];
108 return next;
109}
110
111function withoutProviderInstanceFavorites(
112 favorites: ReadonlyArray<{ readonly provider: ProviderInstanceId; readonly model: string }>,
113 instanceId: ProviderInstanceId,
114) {
115 return favorites.filter((favorite) => favorite.provider !== instanceId);
116}
117
118const PROVIDER_SETTINGS = DRIVER_OPTIONS.map((definition) => ({
119 provider: definition.value,
120}));
121
122function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) {
123 useRelativeTimeTick();
124 const lastCheckedRelative = getRelativeTimeState(lastCheckedAt);
125
126 if (lastCheckedRelative.status === "missing") {
127 return null;
128 }
129
130 if (lastCheckedRelative.status === "invalid") {
131 return <span className="text-[11px] text-muted-foreground/50">Checked unavailable</span>;
132 }
133
134 return (
135 <span className="text-[11px] text-muted-foreground/60">
136 {lastCheckedRelative.suffix ? (
137 <>
138 Checked <span className="font-mono tabular-nums">{lastCheckedRelative.value}</span>{" "}
139 {lastCheckedRelative.suffix}
140 </>
141 ) : (
142 <>Checked {lastCheckedRelative.value}</>
143 )}
144 </span>
145 );
146}
147
148function providerEnvironmentIcon(environment: EnvironmentPresentation) {
149 if (environment.entry.target._tag === "PrimaryConnectionTarget") return MonitorIcon;
150 if (environment.entry.target._tag === "RelayConnectionTarget") return CloudIcon;
151 if (environment.entry.target._tag === "SshConnectionTarget") return TerminalIcon;
152 if (isDesktopLocalConnectionTarget(environment.entry.target)) return LaptopIcon;
153 return CloudIcon;
154}
155
156function providerEnvironmentDetail(environment: EnvironmentPresentation): string {
157 if (environment.entry.target._tag === "PrimaryConnectionTarget") return "Primary device";
158 if (environment.relayManaged) return "T3 Connect";
159 if (environment.entry.target._tag === "SshConnectionTarget") return "SSH";
160 if (isDesktopLocalConnectionTarget(environment.entry.target)) return "Local device";
161 return environment.displayUrl ?? "Remote device";
162}
163
164function EnvironmentUnavailableRow({
165 environment,
166 access,
167}: {
168 readonly environment: EnvironmentPresentation;
169 readonly access: Exclude<ProviderEnvironmentAccess, { kind: "editable" | "read-only" }>;
170}) {
171 const isLoading = access.kind === "loading";
172 const title = isLoading
173 ? "Loading provider settings"
174 : access.kind === "error"
175 ? "Could not connect to this device"
176 : "Provider settings are unavailable";
177 const description = isLoading
178 ? access.reason === "permissions"
179 ? "Checking what this session is allowed to change."
180 : `Waiting for ${environment.label}'s configuration.`
181 : connectionStatusText(environment.connection);
182 // No spinner: this state can persist indefinitely for a wedged device, and a
183 // continuously repainting animation would run the whole time.
184 return (
185 <SettingsSection title="Providers">
186 <SettingsRow title={title} description={description} />
187 </SettingsSection>
188 );
189}
190
191export function ProviderSettingsPanel() {
192 const { environments, isReady } = useEnvironments();
193 const primaryEnvironmentId = usePrimaryEnvironmentId();
194 const options = useMemo(
195 () => buildProviderEnvironmentOptions(environments, primaryEnvironmentId),
196 [environments, primaryEnvironmentId],
197 );
198 // Raw user intent; the effective selection is re-derived every render so a
199 // device that drops out of the catalog falls back without erasing the pick —
200 // if it reappears (e.g. after a reconnect) the selection is restored.
201 const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<EnvironmentId | null>(
202 primaryEnvironmentId,
203 );
204 const effectiveEnvironmentId = resolveSelectedProviderEnvironmentId(
205 options,
206 selectedEnvironmentId,
207 primaryEnvironmentId,
208 );
209 const selectedEnvironment =
210 options.find((environment) => environment.environmentId === effectiveEnvironmentId) ?? null;
211 const onlyPrimaryDevice =
212 options.length === 1 && options[0]?.entry.target._tag === "PrimaryConnectionTarget";
213
214 return (
215 <SettingsPageContainer>
216 {!onlyPrimaryDevice ? (
217 <SettingsSection title="Devices">
218 {options.length === 0 ? (
219 // The catalog hydrates asynchronously, so an empty list before it is
220 // ready means "not loaded yet", not "nothing is connected".
221 <SettingsRow
222 title={isReady ? "No connected devices" : "Loading devices"}
223 description={
224 isReady
225 ? "Connect an execution environment before configuring providers."
226 : "Reading connected execution environments."
227 }
228 />
229 ) : (
230 <div className="grid gap-1 sm:grid-cols-2">
231 {options.map((environment) => {
232 const Icon = providerEnvironmentIcon(environment);
233 const selected = environment.environmentId === effectiveEnvironmentId;
234 const statusText = connectionStatusText(environment.connection);
235 return (
236 <button
237 key={environment.environmentId}
238 type="button"
239 aria-pressed={selected}
240 className={cn(
241 "flex min-w-0 items-center gap-3 rounded-xl px-3 py-2.5 text-left transition-colors sm:px-4",
242 selected
243 ? "bg-primary/8 ring-1 ring-primary/25 dark:bg-primary/12"
244 : "hover:bg-muted/40",
245 )}
246 onClick={() => setSelectedEnvironmentId(environment.environmentId)}
247 >
248 <span className="flex size-8 shrink-0 items-center justify-center rounded-lg border border-border/60 bg-background text-muted-foreground">
249 <Icon className="size-4" aria-hidden />
250 </span>
251 <span className="min-w-0 flex-1">
252 <span className="flex items-center gap-1.5">
253 <ConnectionStatusDot
254 tooltipText={statusText}
255 dotClassName={connectionPhaseDotClassName(environment.connection.phase)}
256 pingClassName={connectionPhasePingClassName(environment.connection.phase)}
257 />
258 <span className="truncate text-sm font-medium text-foreground">
259 {environment.label}
260 </span>
261 </span>
262 <span className="block truncate pl-[18px] text-xs text-muted-foreground">
263 {providerEnvironmentDetail(environment)} · {statusText}
264 </span>
265 </span>
266 </button>
267 );
268 })}
269 </div>
270 )}
271 </SettingsSection>
272 ) : null}
273
274 {selectedEnvironment ? (
275 <SelectedEnvironmentProviderSettings
276 key={selectedEnvironment.environmentId}
277 environment={selectedEnvironment}
278 />
279 ) : null}
280 </SettingsPageContainer>
281 );
282}
283
284function SelectedEnvironmentProviderSettings({
285 environment,
286}: {
287 readonly environment: EnvironmentPresentation;
288}) {
289 const isPrimary = environment.entry.target._tag === "PrimaryConnectionTarget";
290 if (isPrimary) {
291 // The desktop app owns its primary server outright; a browser session
292 // checks the scopes its cookie session was granted.
293 if (isElectron) {
294 return <AccessGatedProviderSettings environment={environment} operateAccess="granted" />;
295 }
296 return <PrimarySessionGatedProviderSettings environment={environment} />;
297 }
298 return <RemoteSessionGatedProviderSettings environment={environment} />;
299}
300
301function PrimarySessionGatedProviderSettings({
302 environment,
303}: {
304 readonly environment: EnvironmentPresentation;
305}) {
306 const primarySessionState = usePrimarySessionState();
307 const operateAccess = resolvePrimaryOperateAccess({
308 isPrimary: true,
309 hasDesktopBridge: false,
310 session: primarySessionState.data,
311 isPending: primarySessionState.isPending,
312 hasError: primarySessionState.error !== null,
313 });
314 return <AccessGatedProviderSettings environment={environment} operateAccess={operateAccess} />;
315}
316
317function RemoteSessionGatedProviderSettings({
318 environment,
319}: {
320 readonly environment: EnvironmentPresentation;
321}) {
322 const sessionState = useEnvironmentSessionState(environment.environmentId);
323 const operateAccess = resolveRemoteOperateAccess({
324 session: sessionState.data,
325 isPending: sessionState.isPending,
326 hasError: sessionState.hasError,
327 });
328 return <AccessGatedProviderSettings environment={environment} operateAccess={operateAccess} />;
329}
330
331function AccessGatedProviderSettings({
332 environment,
333 operateAccess,
334}: {
335 readonly environment: EnvironmentPresentation;
336 readonly operateAccess: ProviderOperateAccess;
337}) {
338 const access = classifyProviderEnvironmentAccess({
339 connectionPhase: environment.connection.phase,
340 hasServerConfig: environment.serverConfig !== null,
341 operateAccess,
342 });
343 if (access.kind !== "editable" && access.kind !== "read-only") {
344 return <EnvironmentUnavailableRow environment={environment} access={access} />;
345 }
346 return (
347 <EnvironmentProviderSettings
348 environmentId={environment.environmentId}
349 environmentLabel={environment.label}
350 readOnly={access.kind === "read-only"}
351 />
352 );
353}
354
355export function EnvironmentProviderSettings({
356 environmentId,
357 environmentLabel,
358 readOnly = false,
359}: {
360 readonly environmentId: EnvironmentId;
361 readonly environmentLabel: string;
362 /**
363 * Render the full provider layout, greyed out and inert, when this session's
364 * credential lacks `orchestration:operate` on the environment. Showing the
365 * real configuration keeps the view honest; disabling interaction keeps
366 * every one of its writes from being offered and then rejected.
367 */
368 readonly readOnly?: boolean;
369}) {
370 const settings = useEnvironmentSettings(environmentId);
371 const updateSettings = useUpdateEnvironmentSettings(environmentId);
372 const serverProviders =
373 useAtomValue(serverEnvironment.providersValueAtom(environmentId)) ?? EMPTY_SERVER_PROVIDERS;
374 const refreshServerProviders = useAtomCommand(serverEnvironment.refreshProviders, {
375 reportFailure: false,
376 });
377 const updateProvider = useAtomCommand(serverEnvironment.updateProvider, {
378 reportFailure: false,
379 });
380 const [isRefreshingProviders, setIsRefreshingProviders] = useState(false);
381 const [isAddInstanceDialogOpen, setIsAddInstanceDialogOpen] = useState(false);
382 const [updatingProviderDrivers, setUpdatingProviderDrivers] = useState<
383 ReadonlySet<ProviderDriverKind>
384 >(() => new Set());
385 const [openInstanceDetails, setOpenInstanceDetails] = useState<Record<string, boolean>>({});
386 const refreshingRef = useRef(false);
387 const updatingDriversRef = useRef<Set<ProviderDriverKind>>(new Set());
388
389 const providerUpdateCandidates = useMemo(
390 () => collectProviderUpdateCandidates(serverProviders),
391 [serverProviders],
392 );
393 const providerUpdateCandidateByInstanceId = useMemo(
394 () => new Map(providerUpdateCandidates.map((candidate) => [candidate.instanceId, candidate])),
395 [providerUpdateCandidates],
396 );
397 const visibleProviderSettings = PROVIDER_SETTINGS.filter(
398 (providerSettings) =>
399 providerSettings.provider !== "cursor" ||
400 serverProviders.some(
401 (provider) =>
402 provider.instanceId === defaultInstanceIdForDriver(ProviderDriverKind.make("cursor")),
403 ),
404 );
405 const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders);
406 const textGenInstanceId = textGenerationModelSelection.instanceId;
407 const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings);
408 const providerHealthPreset = getBackgroundActivityPresetSettings(
409 resolvedBackgroundActivity.profile,
410 ).providerHealthRefreshInterval;
411 const providerHealthRefreshIntervalSeconds = durationToSeconds(
412 resolvedBackgroundActivity.providerHealthRefreshInterval,
413 );
414 const defaultProviderHealthRefreshIntervalSeconds = durationToSeconds(providerHealthPreset);
415 const lastCheckedAt =
416 serverProviders.length > 0
417 ? serverProviders.reduce(
418 (latest, provider) => (provider.checkedAt > latest ? provider.checkedAt : latest),
419 serverProviders[0]!.checkedAt,
420 )
421 : null;
422
423 const refreshProviders = useCallback(() => {
424 if (refreshingRef.current) return;
425 refreshingRef.current = true;
426 setIsRefreshingProviders(true);
427 void (async () => {
428 const result = await refreshServerProviders({
429 environmentId,
430 input: {},
431 });
432 refreshingRef.current = false;
433 setIsRefreshingProviders(false);
434 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
435 console.warn("Failed to refresh providers", {
436 operation: "refresh-providers",
437 environmentId,
438 ...safeErrorLogAttributes(squashAtomCommandFailure(result)),
439 });
440 }
441 })();
442 }, [environmentId, refreshServerProviders]);
443
444 const runProviderUpdate = useCallback(
445 async (candidate: ProviderUpdateCandidate) => {
446 // Ref-based re-entry guard, mirroring refreshProviders: a state updater
447 // may run after this function returns, so it cannot gate the dispatch.
448 if (updatingDriversRef.current.has(candidate.driver)) {
449 return;
450 }
451 updatingDriversRef.current.add(candidate.driver);
452 setUpdatingProviderDrivers((previous) => new Set(previous).add(candidate.driver));
453
454 const result = await updateProvider({
455 environmentId,
456 input: {
457 provider: candidate.driver,
458 instanceId: candidate.instanceId,
459 },
460 });
461 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
462 const error = squashAtomCommandFailure(result);
463 toastManager.add(
464 stackedThreadToast({
465 type: "error",
466 title: `Could not update ${PROVIDER_DISPLAY_NAMES[candidate.driver] ?? candidate.driver}`,
467 description:
468 error instanceof Error
469 ? error.message
470 : "The provider update command could not be started.",
471 }),
472 );
473 }
474 updatingDriversRef.current.delete(candidate.driver);
475 setUpdatingProviderDrivers((previous) => {
476 if (!previous.has(candidate.driver)) {
477 return previous;
478 }
479 const next = new Set(previous);
480 next.delete(candidate.driver);
481 return next;
482 });
483 },
484 [environmentId, updateProvider],
485 );
486
487 interface InstanceRow {
488 readonly instanceId: ProviderInstanceId;
489 readonly instance: ProviderInstanceConfig;
490 readonly driver: ProviderDriverKind;
491 readonly isDefault: boolean;
492 readonly isDirty?: boolean;
493 }
494
495 const instancesByDriver = new Map<
496 ProviderDriverKind,
497 Array<[ProviderInstanceId, ProviderInstanceConfig]>
498 >();
499 for (const [rawId, instance] of Object.entries(settings.providerInstances ?? {})) {
500 const driver = instance.driver;
501 const list = instancesByDriver.get(driver) ?? [];
502 list.push([rawId as ProviderInstanceId, instance]);
503 instancesByDriver.set(driver, list);
504 }
505
506 const defaultSlotIdsBySource = new Set<string>(
507 visibleProviderSettings.map((providerSettings) =>
508 String(defaultInstanceIdForDriver(providerSettings.provider)),
509 ),
510 );
511
512 const rows: InstanceRow[] = [];
513 const visibleDriverKinds = new Set<ProviderDriverKind>(
514 visibleProviderSettings.map((providerSettings) => providerSettings.provider),
515 );
516
517 for (const providerSettings of visibleProviderSettings) {
518 type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers];
519 const legacyProviders = settings.providers as Record<string, LegacyProviderSettings>;
520 const defaultLegacyProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record<
521 string,
522 LegacyProviderSettings
523 >;
524 const driver = providerSettings.provider;
525 const defaultInstanceId = defaultInstanceIdForDriver(driver);
526 const explicitInstance = settings.providerInstances?.[defaultInstanceId];
527 // A remote device may run a server version whose settings predate this
528 // driver, so the legacy mirror can be absent. Without either an explicit
529 // instance or a legacy blob there is nothing to render for the slot.
530 const legacyConfig = legacyProviders[providerSettings.provider];
531 const defaultLegacyConfig = defaultLegacyProviders[providerSettings.provider];
532 const effectiveInstance: ProviderInstanceConfig | undefined =
533 explicitInstance ??
534 (legacyConfig !== undefined
535 ? ({
536 driver,
537 enabled: legacyConfig.enabled,
538 config: legacyConfig,
539 } satisfies ProviderInstanceConfig)
540 : undefined);
541 // Only the default slot depends on the legacy blob; custom instances for
542 // the driver must still render even when the slot has nothing to show.
543 if (effectiveInstance !== undefined) {
544 const isDirty =
545 explicitInstance !== undefined || !Equal.equals(legacyConfig, defaultLegacyConfig);
546 rows.push({
547 instanceId: defaultInstanceId,
548 instance: effectiveInstance,
549 driver,
550 isDefault: true,
551 isDirty,
552 });
553 }
554 for (const [id, instance] of instancesByDriver.get(providerSettings.provider) ?? []) {
555 if (id === defaultInstanceId) continue;
556 rows.push({ instanceId: id, instance, driver: instance.driver, isDefault: false });
557 }
558 }
559 for (const [driver, list] of instancesByDriver) {
560 if (visibleDriverKinds.has(driver)) continue;
561 for (const [id, instance] of list) {
562 rows.push({
563 instanceId: id,
564 instance,
565 driver: instance.driver,
566 isDefault: defaultSlotIdsBySource.has(String(id)),
567 });
568 }
569 }
570
571 const updateProviderInstance = (
572 row: InstanceRow,
573 next: ProviderInstanceConfig,
574 options?: {
575 readonly textGenerationModelSelection?: Parameters<
576 typeof buildProviderInstanceUpdatePatch
577 >[0]["textGenerationModelSelection"];
578 },
579 ) => {
580 updateSettings(
581 buildProviderInstanceUpdatePatch({
582 settings,
583 instanceId: row.instanceId,
584 instance: next,
585 driver: row.driver,
586 isDefault: row.isDefault,
587 textGenerationModelSelection: options?.textGenerationModelSelection,
588 }),
589 );
590 };
591
592 const deleteProviderInstance = (id: ProviderInstanceId) => {
593 updateSettings({
594 providerInstances: withoutProviderInstanceKey(settings.providerInstances, id),
595 });
596 };
597
598 const updateProviderModelPreferences = (
599 instanceId: ProviderInstanceId,
600 next: {
601 readonly hiddenModels: ReadonlyArray<string>;
602 readonly modelOrder: ReadonlyArray<string>;
603 },
604 ) => {
605 const hiddenModels = [...new Set(next.hiddenModels.filter((slug) => slug.trim().length > 0))];
606 const modelOrder = [...new Set(next.modelOrder.filter((slug) => slug.trim().length > 0))];
607 const rest = withoutProviderInstanceKey(settings.providerModelPreferences, instanceId);
608 updateSettings({
609 providerModelPreferences:
610 hiddenModels.length === 0 && modelOrder.length === 0
611 ? rest
612 : {
613 ...rest,
614 [instanceId]: {
615 hiddenModels,
616 modelOrder,
617 },
618 },
619 });
620 };
621
622 const updateProviderFavoriteModels = (
623 instanceId: ProviderInstanceId,
624 nextFavoriteModels: ReadonlyArray<string>,
625 ) => {
626 const favoriteModels = [
627 ...new Set(
628 Arr.filterMap(nextFavoriteModels, (slug) => {
629 const trimmedSlug = slug.trim();
630 return trimmedSlug.length > 0 ? Result.succeed(trimmedSlug) : Result.failVoid;
631 }),
632 ),
633 ];
634 updateSettings({
635 favorites: [
636 ...withoutProviderInstanceFavorites(settings.favorites ?? [], instanceId),
637 ...favoriteModels.map((model) => ({ provider: instanceId, model })),
638 ],
639 });
640 };
641
642 const resetDefaultInstance = (driverKind: ProviderDriverKind) => {
643 type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers];
644 const defaultLegacyProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record<
645 string,
646 LegacyProviderSettings | undefined
647 >;
648 const defaultInstanceId = defaultInstanceIdForDriver(driverKind);
649 const defaultLegacyProvider = defaultLegacyProviders[driverKind];
650 if (defaultLegacyProvider === undefined) return;
651 updateSettings({
652 providers: {
653 ...settings.providers,
654 [driverKind]: defaultLegacyProvider,
655 } as typeof settings.providers,
656 providerInstances: withoutProviderInstanceKey(settings.providerInstances, defaultInstanceId),
657 });
658 };
659
660 return (
661 <>
662 <SettingsSection
663 {...searchableSetting("providers")}
664 headerAction={
665 <div className="flex items-center gap-1.5">
666 <ProviderLastChecked lastCheckedAt={lastCheckedAt} />
667 {!readOnly ? (
668 <>
669 <Tooltip>
670 <TooltipTrigger
671 render={
672 <Button
673 size="icon-xs"
674 variant="ghost"
675 className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground"
676 onClick={() => setIsAddInstanceDialogOpen(true)}
677 aria-label="Add provider instance"
678 >
679 <PlusIcon className="size-3" />
680 </Button>
681 }
682 />
683 <TooltipPopup side="top">Add provider instance</TooltipPopup>
684 </Tooltip>
685 <Tooltip>
686 <TooltipTrigger
687 render={
688 <Button
689 size="icon-xs"
690 variant="ghost"
691 className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground"
692 disabled={isRefreshingProviders}
693 onClick={() => void refreshProviders()}
694 aria-label="Refresh provider status"
695 >
696 {isRefreshingProviders ? (
697 <LoaderIcon className="size-3 animate-spin" />
698 ) : (
699 <RefreshCwIcon className="size-3" />
700 )}
701 </Button>
702 }
703 />
704 <TooltipPopup side="top">Refresh provider status</TooltipPopup>
705 </Tooltip>
706 </>
707 ) : null}
708 </div>
709 }
710 >
711 {readOnly ? (
712 <SettingsRow
713 title="Limited permissions"
714 description={`This session can view ${environmentLabel}'s providers, but its credential does not allow changing their configuration.`}
715 />
716 ) : null}
717 <div
718 // `inert` blocks focus and interaction in one attribute, so the
719 // read-only view stays byte-for-byte the editable layout without
720 // threading a disabled flag through every control.
721 inert={readOnly}
722 aria-disabled={readOnly || undefined}
723 className={readOnly ? "space-y-1 opacity-50 select-none" : "space-y-1"}
724 >
725 <SettingsRow
726 title={
727 <span className="inline-flex items-center gap-1.5">
728 Health check interval
729 <PolicyTooltip>
730 This interval is configured here, then the shared Background activity policy
731 decides whether provider probes may run when the timer fires. Custom intervals
732 appear as Advanced in General settings.
733 </PolicyTooltip>
734 </span>
735 }
736 description="Refresh provider availability, versions, auth state, and model metadata in the background. Set this to 0 seconds to rely on manual refreshes."
737 resetAction={
738 providerHealthRefreshIntervalSeconds !==
739 defaultProviderHealthRefreshIntervalSeconds ? (
740 <SettingResetButton
741 label="provider health check interval"
742 onClick={() =>
743 updateSettings(
744 backgroundActivityOverrideSettings(
745 settings.backgroundActivity,
746 resolvedBackgroundActivity,
747 {
748 providerHealthRefreshInterval: undefined,
749 },
750 ),
751 )
752 }
753 />
754 ) : null
755 }
756 control={
757 <div className="flex shrink-0 items-center gap-2">
758 <NumberField
759 value={providerHealthRefreshIntervalSeconds}
760 min={0}
761 step={PROVIDER_HEALTH_INTERVAL_STEP_SECONDS}
762 size="sm"
763 className="w-32"
764 onValueChange={(value) =>
765 updateSettings(
766 backgroundActivityOverrideSettings(
767 settings.backgroundActivity,
768 resolvedBackgroundActivity,
769 {
770 providerHealthRefreshInterval: Duration.seconds(
771 normalizeIntervalSeconds(value),
772 ),
773 },
774 ),
775 )
776 }
777 >
778 <NumberFieldGroup>
779 <NumberFieldDecrement aria-label="Decrease provider health check interval" />
780 <NumberFieldInput aria-label="Provider health check interval in seconds" />
781 <NumberFieldIncrement aria-label="Increase provider health check interval" />
782 </NumberFieldGroup>
783 </NumberField>
784 <span className="text-xs text-muted-foreground">seconds</span>
785 </div>
786 }
787 />
788
789 {rows.map((row) => {
790 const driverOption = getDriverOption(row.driver);
791 const liveProvider = serverProviders.find(
792 (candidate) => candidate.instanceId === row.instanceId,
793 );
794 const updateCandidate = liveProvider
795 ? providerUpdateCandidateByInstanceId.get(liveProvider.instanceId)
796 : undefined;
797 const isDriverUpdateRunning =
798 updateCandidate !== undefined &&
799 (updatingProviderDrivers.has(updateCandidate.driver) ||
800 serverProviders.some(
801 (provider) =>
802 provider.driver === updateCandidate.driver && isProviderUpdateActive(provider),
803 ));
804 const showInlineUpdateButton =
805 updateCandidate !== undefined &&
806 hasOneClickUpdateProviderCandidate(updateCandidate, serverProviders);
807 const canRunInlineUpdate =
808 updateCandidate !== undefined &&
809 canOneClickUpdateProviderCandidate(updateCandidate, serverProviders) &&
810 !updatingProviderDrivers.has(updateCandidate.driver);
811 const modelPreferences = settings.providerModelPreferences?.[row.instanceId] ?? {
812 hiddenModels: [],
813 modelOrder: [],
814 };
815 const favoriteModels = Arr.filterMap(settings.favorites ?? [], (favorite) =>
816 favorite.provider === row.instanceId
817 ? Result.succeed(favorite.model)
818 : Result.failVoid,
819 );
820 const resetLabel = driverOption?.label ?? String(row.driver);
821 const headerAction =
822 row.isDefault && row.isDirty ? (
823 <SettingResetButton
824 label={`${resetLabel} provider settings`}
825 onClick={() => resetDefaultInstance(row.driver)}
826 />
827 ) : null;
828 return (
829 <ProviderInstanceCard
830 key={row.instanceId}
831 instanceId={row.instanceId}
832 instance={row.instance}
833 driverOption={driverOption}
834 liveProvider={liveProvider}
835 isExpanded={openInstanceDetails[row.instanceId] ?? false}
836 onExpandedChange={(open) =>
837 setOpenInstanceDetails((existing) => ({
838 ...existing,
839 [row.instanceId]: open,
840 }))
841 }
842 onUpdate={(next) => {
843 const wasEnabled = row.instance.enabled ?? true;
844 const isDisabling = next.enabled === false && wasEnabled;
845 const shouldClearTextGen = isDisabling && textGenInstanceId === row.instanceId;
846 if (shouldClearTextGen) {
847 updateProviderInstance(row, next, {
848 textGenerationModelSelection:
849 DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection,
850 });
851 } else {
852 updateProviderInstance(row, next);
853 }
854 }}
855 onDelete={row.isDefault ? undefined : () => deleteProviderInstance(row.instanceId)}
856 headerAction={headerAction}
857 hiddenModels={modelPreferences.hiddenModels}
858 favoriteModels={favoriteModels}
859 modelOrder={modelPreferences.modelOrder}
860 onHiddenModelsChange={(hiddenModels) =>
861 updateProviderModelPreferences(row.instanceId, {
862 ...modelPreferences,
863 hiddenModels,
864 })
865 }
866 onFavoriteModelsChange={(favoriteModels) =>
867 updateProviderFavoriteModels(row.instanceId, favoriteModels)
868 }
869 onModelOrderChange={(modelOrder) =>
870 updateProviderModelPreferences(row.instanceId, {
871 ...modelPreferences,
872 modelOrder,
873 })
874 }
875 onRunUpdate={
876 showInlineUpdateButton && updateCandidate
877 ? () => {
878 if (!canRunInlineUpdate) {
879 return;
880 }
881 void runProviderUpdate(updateCandidate);
882 }
883 : undefined
884 }
885 isUpdating={showInlineUpdateButton ? isDriverUpdateRunning : undefined}
886 />
887 );
888 })}
889 </div>
890 </SettingsSection>
891
892 {isAddInstanceDialogOpen ? (
893 <AddProviderInstanceDialog
894 open
895 environmentId={environmentId}
896 environmentLabel={environmentLabel}
897 onOpenChange={setIsAddInstanceDialogOpen}
898 />
899 ) : null}
900 </>
901 );
902}
apps/web/src/components/settings/SettingsPanels.logic.ts+57
......@@ -10,10 +10,12 @@ import type {
1010} from "@t3tools/contracts";
1111import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
1212import {
13 getBackgroundActivityBaseProfile,
1314 normalizeBackgroundActivitySettings,
1415 normalizeServerBackgroundActivitySettings,
1516 resolveServerBackgroundActivitySettings,
1617} from "@t3tools/shared/backgroundActivitySettings";
18import * as Duration from "effect/Duration";
1719import * as Equal from "effect/Equal";
1820
1921export function isProjectGroupingEnabled(mode: SidebarProjectGroupingMode): boolean {
......@@ -190,3 +192,58 @@ export function buildProviderInstanceUpdatePatch(input: {
190192 : {}),
191193 };
192194}
195
196// ── Background-activity interval helpers ─────────────────────────────
197// Shared by the General panel's interval rows and the Providers panel's
198// health-check row.
199
200export const PROVIDER_HEALTH_INTERVAL_STEP_SECONDS = 30;
201
202type BackgroundActivityOverridePatch = Partial<{
203 [K in keyof BackgroundActivitySettings["overrides"]]:
204 | BackgroundActivitySettings["overrides"][K]
205 | undefined;
206}>;
207
208export function durationToSeconds(duration: Duration.Duration): number {
209 return Math.round(Duration.toMillis(duration) / 1_000);
210}
211
212export function normalizeIntervalSeconds(value: number | null, minimum = 0): number {
213 if (value === null || !Number.isFinite(value)) {
214 return minimum;
215 }
216 return Math.max(minimum, Math.round(value));
217}
218
219export function backgroundActivityOverrideSettings(
220 current: BackgroundActivitySettings,
221 resolved: ReturnType<typeof resolveServerBackgroundActivitySettings>,
222 overrides: BackgroundActivityOverridePatch,
223) {
224 const nextOverrides: BackgroundActivityOverridePatch = {
225 automaticGitFetchInterval: resolved.automaticGitFetchInterval,
226 providerHealthRefreshInterval: resolved.providerHealthRefreshInterval,
227 hostPowerMonitorActiveInterval: resolved.hostPowerMonitorActiveInterval,
228 hostPowerMonitorIdleInterval: resolved.hostPowerMonitorIdleInterval,
229 idleClientTtl: resolved.idleClientTtl,
230 pauseWhenHostLocked: resolved.pauseWhenHostLocked,
231 pauseWhenHostLowPower: resolved.pauseWhenHostLowPower,
232 pauseWhenClientLowPower: resolved.pauseWhenClientLowPower,
233 pauseWhenOnBattery: resolved.pauseWhenOnBattery,
234 ...overrides,
235 };
236 for (const [key, value] of Object.entries(nextOverrides)) {
237 if (value === undefined) {
238 delete nextOverrides[key as keyof typeof nextOverrides];
239 }
240 }
241 return {
242 backgroundActivity: {
243 schemaVersion: 1 as const,
244 profile: "custom" as const,
245 baseProfile: getBackgroundActivityBaseProfile(current),
246 overrides: nextOverrides as BackgroundActivitySettings["overrides"],
247 },
248 };
249}
apps/web/src/components/settings/SettingsPanels.tsx+9-674
......@@ -1,30 +1,16 @@
1import {
2 ArchiveIcon,
3 ArchiveX,
4 InfoIcon,
5 LoaderIcon,
6 PlusIcon,
7 RefreshCwIcon,
8 SettingsIcon,
9} from "lucide-react";
1import { ArchiveIcon, ArchiveX, LoaderIcon, SettingsIcon } from "lucide-react";
102import { Link } from "@tanstack/react-router";
113import type { CSSProperties, ReactNode } from "react";
124import { useCallback, useEffect, useMemo, useRef, useState } from "react";
135import { useAtomValue } from "@effect/atom-react";
146import {
15 defaultInstanceIdForDriver,
167 type BackgroundActivityProfile,
17 type BackgroundActivitySettings,
188 type DesktopUpdateChannel,
19 PROVIDER_DISPLAY_NAMES,
209 ProviderDriverKind,
21 type ProviderInstanceConfig,
22 type ProviderInstanceId,
2310 type ScopedThreadRef,
2411 type SidebarProjectGroupingMode,
2512} from "@t3tools/contracts";
2613import { scopeThreadRef } from "@t3tools/client-runtime/environment";
27import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
2814import {
2915 isAtomCommandInterrupted,
3016 settlePromise,
......@@ -45,16 +31,10 @@ import {
4531 MIN_PROMPT_FONT_SIZE,
4632 MIN_TERMINAL_FONT_SIZE,
4733} from "@t3tools/contracts/settings";
48import {
49 getBackgroundActivityBaseProfile,
50 getBackgroundActivityPresetSettings,
51 resolveServerBackgroundActivitySettings,
52} from "@t3tools/shared/backgroundActivitySettings";
34import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings";
5335import { createModelSelection } from "@t3tools/shared/model";
54import * as Arr from "effect/Array";
5536import * as Duration from "effect/Duration";
5637import * as Equal from "effect/Equal";
57import * as Result from "effect/Result";
5838import * as Schema from "effect/Schema";
5939import { APP_VERSION, HOSTED_APP_CHANNEL, HOSTED_APP_CHANNEL_LABEL } from "../../branding";
6040import {
......@@ -88,15 +68,10 @@ import {
8868} from "../../providerInstances";
8969import { ensureLocalApi, readLocalApi } from "../../localApi";
9070import { isMacPlatform } from "../../lib/utils";
91import {
92 primaryServerObservabilityAtom,
93 primaryServerProvidersAtom,
94 serverEnvironment,
95} from "../../state/server";
96import { usePrimaryEnvironment } from "../../state/environments";
71import { primaryServerObservabilityAtom, primaryServerProvidersAtom } from "../../state/server";
9772import { useProjects } from "../../state/entities";
9873import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState";
99import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat";
74import { formatRelativeTimeLabel } from "../../timestampFormat";
10075import { Button } from "../ui/button";
10176import {
10277 Dialog,
......@@ -131,20 +106,13 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../
131106import { Switch } from "../ui/switch";
132107import { stackedThreadToast, toastManager } from "../ui/toast";
133108import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
134import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog";
135import {
136 canOneClickUpdateProviderCandidate,
137 collectProviderUpdateCandidates,
138 hasOneClickUpdateProviderCandidate,
139 isProviderUpdateActive,
140 type ProviderUpdateCandidate,
141} from "../ProviderUpdateLaunchNotification.logic";
142import { ProviderInstanceCard } from "./ProviderInstanceCard";
143import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta";
144109import {
110 backgroundActivityOverrideSettings,
145111 backgroundActivitySharedPolicySettings,
146 buildProviderInstanceUpdatePatch,
112 durationToSeconds,
147113 formatDiagnosticsDescription,
114 normalizeIntervalSeconds,
115 PROVIDER_HEALTH_INTERVAL_STEP_SECONDS,
148116 hasChangedBackgroundActivitySettings,
149117 isProjectGroupingEnabled,
150118 projectGroupingModeFromToggle,
......@@ -153,16 +121,15 @@ import {
153121 resolveBackgroundActivityProfileOption,
154122} from "./SettingsPanels.logic";
155123import {
124 PolicyTooltip,
156125 SettingResetButton,
157126 SettingsPageContainer,
158127 SettingsRow,
159128 SettingsSection,
160 useRelativeTimeTick,
161129 useSettingsSearchTargetId,
162130} from "./settingsLayout";
163131import { searchableSetting } from "./settingsSearch";
164132import { ProjectFavicon } from "../ProjectFavicon";
165import { useAtomCommand } from "../../state/use-atom-command";
166133
167134const THEME_OPTIONS = [
168135 {
......@@ -198,11 +165,6 @@ const BACKGROUND_ACTIVITY_PROFILE_LABELS: Record<BackgroundActivityProfile, stri
198165};
199166
200167type BackgroundActivityProfileOption = BackgroundActivityProfile | "advanced";
201type BackgroundActivityOverridePatch = Partial<{
202 [K in keyof BackgroundActivitySettings["overrides"]]:
203 | BackgroundActivitySettings["overrides"][K]
204 | undefined;
205}>;
206168
207169const BACKGROUND_ACTIVITY_PROFILE_OPTION_LABELS: Record<BackgroundActivityProfileOption, string> = {
208170 ...BACKGROUND_ACTIVITY_PROFILE_LABELS,
......@@ -219,7 +181,6 @@ const BACKGROUND_ACTIVITY_PROFILE_DESCRIPTIONS: Record<BackgroundActivityProfile
219181const ADVANCED_BACKGROUND_ACTIVITY_DESCRIPTION =
220182 "Uses custom background intervals with the selected shared power policy.";
221183
222const PROVIDER_HEALTH_INTERVAL_STEP_SECONDS = 30;
223184const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex");
224185const BACKGROUND_ACTIVITY_BOOLEAN_OVERRIDES: ReadonlyArray<{
225186 readonly key:
......@@ -235,17 +196,6 @@ const BACKGROUND_ACTIVITY_BOOLEAN_OVERRIDES: ReadonlyArray<{
235196 { key: "pauseWhenOnBattery", label: "Pause on battery" },
236197];
237198
238function durationToSeconds(duration: Duration.Duration): number {
239 return Math.round(Duration.toMillis(duration) / 1_000);
240}
241
242function normalizeIntervalSeconds(value: number | null, minimum = 0): number {
243 if (value === null || !Number.isFinite(value)) {
244 return minimum;
245 }
246 return Math.max(minimum, Math.round(value));
247}
248
249199function resetBackgroundActivitySettings() {
250200 return {
251201 backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity,
......@@ -262,105 +212,6 @@ function backgroundActivityProfileSettings(profile: BackgroundActivityProfile) {
262212 };
263213}
264214
265function backgroundActivityOverrideSettings(
266 current: BackgroundActivitySettings,
267 resolved: ReturnType<typeof resolveServerBackgroundActivitySettings>,
268 overrides: BackgroundActivityOverridePatch,
269) {
270 const nextOverrides: BackgroundActivityOverridePatch = {
271 automaticGitFetchInterval: resolved.automaticGitFetchInterval,
272 providerHealthRefreshInterval: resolved.providerHealthRefreshInterval,
273 hostPowerMonitorActiveInterval: resolved.hostPowerMonitorActiveInterval,
274 hostPowerMonitorIdleInterval: resolved.hostPowerMonitorIdleInterval,
275 idleClientTtl: resolved.idleClientTtl,
276 pauseWhenHostLocked: resolved.pauseWhenHostLocked,
277 pauseWhenHostLowPower: resolved.pauseWhenHostLowPower,
278 pauseWhenClientLowPower: resolved.pauseWhenClientLowPower,
279 pauseWhenOnBattery: resolved.pauseWhenOnBattery,
280 ...overrides,
281 };
282 for (const [key, value] of Object.entries(nextOverrides)) {
283 if (value === undefined) {
284 delete nextOverrides[key as keyof typeof nextOverrides];
285 }
286 }
287 return {
288 backgroundActivity: {
289 schemaVersion: 1 as const,
290 profile: "custom" as const,
291 baseProfile: getBackgroundActivityBaseProfile(current),
292 overrides: nextOverrides as BackgroundActivitySettings["overrides"],
293 },
294 };
295}
296
297function PolicyTooltip({ children }: { readonly children: string }) {
298 return (
299 <Tooltip>
300 <TooltipTrigger
301 render={
302 <button
303 type="button"
304 className="inline-flex size-5 items-center justify-center rounded-sm text-muted-foreground hover:text-foreground"
305 aria-label="Background policy details"
306 >
307 <InfoIcon className="size-3.5" />
308 </button>
309 }
310 />
311 <TooltipPopup side="top" className="max-w-72">
312 {children}
313 </TooltipPopup>
314 </Tooltip>
315 );
316}
317
318function withoutProviderInstanceKey<V>(
319 record: Readonly<Record<ProviderInstanceId, V>> | undefined,
320 key: ProviderInstanceId,
321): Record<ProviderInstanceId, V> {
322 const next = { ...record } as Record<ProviderInstanceId, V>;
323 delete next[key];
324 return next;
325}
326
327function withoutProviderInstanceFavorites(
328 favorites: ReadonlyArray<{ readonly provider: ProviderInstanceId; readonly model: string }>,
329 instanceId: ProviderInstanceId,
330) {
331 return favorites.filter((favorite) => favorite.provider !== instanceId);
332}
333
334const PROVIDER_SETTINGS = DRIVER_OPTIONS.map((definition) => ({
335 provider: definition.value,
336}));
337
338function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) {
339 useRelativeTimeTick();
340 const lastCheckedRelative = getRelativeTimeState(lastCheckedAt);
341
342 if (lastCheckedRelative.status === "missing") {
343 return null;
344 }
345
346 if (lastCheckedRelative.status === "invalid") {
347 return <span className="text-[11px] text-muted-foreground/50">Checked unavailable</span>;
348 }
349
350 return (
351 <span className="text-[11px] text-muted-foreground/60">
352 {lastCheckedRelative.suffix ? (
353 <>
354 Checked <span className="font-mono tabular-nums">{lastCheckedRelative.value}</span>{" "}
355 {lastCheckedRelative.suffix}
356 </>
357 ) : (
358 <>Checked {lastCheckedRelative.value}</>
359 )}
360 </span>
361 );
362}
363
364215function AboutVersionTitle() {
365216 return (
366217 <span className="inline-flex items-center gap-2">
......@@ -2146,522 +1997,6 @@ export function GeneralSettingsPanel() {
21461997 );
21471998}
21481999
2149export function ProviderSettingsPanel() {
2150 const settings = usePrimarySettings();
2151 const updateSettings = useUpdatePrimarySettings();
2152 const serverProviders = useAtomValue(primaryServerProvidersAtom);
2153 const primaryEnvironment = usePrimaryEnvironment();
2154 const refreshServerProviders = useAtomCommand(serverEnvironment.refreshProviders, {
2155 reportFailure: false,
2156 });
2157 const updateProvider = useAtomCommand(serverEnvironment.updateProvider, {
2158 reportFailure: false,
2159 });
2160 const [isRefreshingProviders, setIsRefreshingProviders] = useState(false);
2161 const [isAddInstanceDialogOpen, setIsAddInstanceDialogOpen] = useState(false);
2162 const [updatingProviderDrivers, setUpdatingProviderDrivers] = useState<
2163 ReadonlySet<ProviderDriverKind>
2164 >(() => new Set());
2165 const [openInstanceDetails, setOpenInstanceDetails] = useState<Record<string, boolean>>({});
2166 const refreshingRef = useRef(false);
2167
2168 const providerUpdateCandidates = useMemo(
2169 () => collectProviderUpdateCandidates(serverProviders),
2170 [serverProviders],
2171 );
2172 const providerUpdateCandidateByInstanceId = useMemo(
2173 () => new Map(providerUpdateCandidates.map((candidate) => [candidate.instanceId, candidate])),
2174 [providerUpdateCandidates],
2175 );
2176 const visibleProviderSettings = PROVIDER_SETTINGS.filter(
2177 (providerSettings) =>
2178 providerSettings.provider !== "cursor" ||
2179 serverProviders.some(
2180 (provider) =>
2181 provider.instanceId === defaultInstanceIdForDriver(ProviderDriverKind.make("cursor")),
2182 ),
2183 );
2184 const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders);
2185 const textGenInstanceId = textGenerationModelSelection.instanceId;
2186 const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings);
2187 const providerHealthPreset = getBackgroundActivityPresetSettings(
2188 resolvedBackgroundActivity.profile,
2189 ).providerHealthRefreshInterval;
2190 const providerHealthRefreshIntervalSeconds = durationToSeconds(
2191 resolvedBackgroundActivity.providerHealthRefreshInterval,
2192 );
2193 const defaultProviderHealthRefreshIntervalSeconds = durationToSeconds(providerHealthPreset);
2194 const lastCheckedAt =
2195 serverProviders.length > 0
2196 ? serverProviders.reduce(
2197 (latest, provider) => (provider.checkedAt > latest ? provider.checkedAt : latest),
2198 serverProviders[0]!.checkedAt,
2199 )
2200 : null;
2201
2202 const refreshProviders = useCallback(() => {
2203 if (refreshingRef.current) return;
2204 refreshingRef.current = true;
2205 setIsRefreshingProviders(true);
2206 if (!primaryEnvironment) {
2207 refreshingRef.current = false;
2208 setIsRefreshingProviders(false);
2209 return;
2210 }
2211 void (async () => {
2212 const result = await refreshServerProviders({
2213 environmentId: primaryEnvironment.environmentId,
2214 input: {},
2215 });
2216 refreshingRef.current = false;
2217 setIsRefreshingProviders(false);
2218 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
2219 console.warn("Failed to refresh providers", {
2220 operation: "refresh-providers",
2221 environmentId: primaryEnvironment.environmentId,
2222 ...safeErrorLogAttributes(squashAtomCommandFailure(result)),
2223 });
2224 }
2225 })();
2226 }, [primaryEnvironment, refreshServerProviders]);
2227
2228 const runProviderUpdate = useCallback(
2229 async (candidate: ProviderUpdateCandidate) => {
2230 if (!primaryEnvironment) return;
2231 let started = false;
2232 setUpdatingProviderDrivers((previous) => {
2233 if (previous.has(candidate.driver)) {
2234 return previous;
2235 }
2236 started = true;
2237 const next = new Set(previous);
2238 next.add(candidate.driver);
2239 return next;
2240 });
2241 if (!started) {
2242 return;
2243 }
2244
2245 const result = await updateProvider({
2246 environmentId: primaryEnvironment.environmentId,
2247 input: {
2248 provider: candidate.driver,
2249 instanceId: candidate.instanceId,
2250 },
2251 });
2252 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
2253 const error = squashAtomCommandFailure(result);
2254 toastManager.add(
2255 stackedThreadToast({
2256 type: "error",
2257 title: `Could not update ${PROVIDER_DISPLAY_NAMES[candidate.driver] ?? candidate.driver}`,
2258 description:
2259 error instanceof Error
2260 ? error.message
2261 : "The provider update command could not be started.",
2262 }),
2263 );
2264 }
2265 setUpdatingProviderDrivers((previous) => {
2266 if (!previous.has(candidate.driver)) {
2267 return previous;
2268 }
2269 const next = new Set(previous);
2270 next.delete(candidate.driver);
2271 return next;
2272 });
2273 },
2274 [primaryEnvironment, updateProvider],
2275 );
2276
2277 interface InstanceRow {
2278 readonly instanceId: ProviderInstanceId;
2279 readonly instance: ProviderInstanceConfig;
2280 readonly driver: ProviderDriverKind;
2281 readonly isDefault: boolean;
2282 readonly isDirty?: boolean;
2283 }
2284
2285 const instancesByDriver = new Map<
2286 ProviderDriverKind,
2287 Array<[ProviderInstanceId, ProviderInstanceConfig]>
2288 >();
2289 for (const [rawId, instance] of Object.entries(settings.providerInstances ?? {})) {
2290 const driver = instance.driver;
2291 const list = instancesByDriver.get(driver) ?? [];
2292 list.push([rawId as ProviderInstanceId, instance]);
2293 instancesByDriver.set(driver, list);
2294 }
2295
2296 const defaultSlotIdsBySource = new Set<string>(
2297 visibleProviderSettings.map((providerSettings) =>
2298 String(defaultInstanceIdForDriver(providerSettings.provider)),
2299 ),
2300 );
2301
2302 const rows: InstanceRow[] = [];
2303 const visibleDriverKinds = new Set<ProviderDriverKind>(
2304 visibleProviderSettings.map((providerSettings) => providerSettings.provider),
2305 );
2306
2307 for (const providerSettings of visibleProviderSettings) {
2308 type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers];
2309 const legacyProviders = settings.providers as Record<string, LegacyProviderSettings>;
2310 const defaultLegacyProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record<
2311 string,
2312 LegacyProviderSettings
2313 >;
2314 const driver = providerSettings.provider;
2315 const defaultInstanceId = defaultInstanceIdForDriver(driver);
2316 const explicitInstance = settings.providerInstances?.[defaultInstanceId];
2317 const legacyConfig = legacyProviders[providerSettings.provider]!;
2318 const defaultLegacyConfig = defaultLegacyProviders[providerSettings.provider]!;
2319 const effectiveInstance: ProviderInstanceConfig =
2320 explicitInstance ??
2321 ({
2322 driver,
2323 enabled: legacyConfig.enabled,
2324 config: legacyConfig,
2325 } satisfies ProviderInstanceConfig);
2326 const isDirty =
2327 explicitInstance !== undefined || !Equal.equals(legacyConfig, defaultLegacyConfig);
2328 rows.push({
2329 instanceId: defaultInstanceId,
2330 instance: effectiveInstance,
2331 driver,
2332 isDefault: true,
2333 isDirty,
2334 });
2335 for (const [id, instance] of instancesByDriver.get(providerSettings.provider) ?? []) {
2336 if (id === defaultInstanceId) continue;
2337 rows.push({ instanceId: id, instance, driver: instance.driver, isDefault: false });
2338 }
2339 }
2340 for (const [driver, list] of instancesByDriver) {
2341 if (visibleDriverKinds.has(driver)) continue;
2342 for (const [id, instance] of list) {
2343 rows.push({
2344 instanceId: id,
2345 instance,
2346 driver: instance.driver,
2347 isDefault: defaultSlotIdsBySource.has(String(id)),
2348 });
2349 }
2350 }
2351
2352 const updateProviderInstance = (
2353 row: InstanceRow,
2354 next: ProviderInstanceConfig,
2355 options?: {
2356 readonly textGenerationModelSelection?: Parameters<
2357 typeof buildProviderInstanceUpdatePatch
2358 >[0]["textGenerationModelSelection"];
2359 },
2360 ) => {
2361 updateSettings(
2362 buildProviderInstanceUpdatePatch({
2363 settings,
2364 instanceId: row.instanceId,
2365 instance: next,
2366 driver: row.driver,
2367 isDefault: row.isDefault,
2368 textGenerationModelSelection: options?.textGenerationModelSelection,
2369 }),
2370 );
2371 };
2372
2373 const deleteProviderInstance = (id: ProviderInstanceId) => {
2374 updateSettings({
2375 providerInstances: withoutProviderInstanceKey(settings.providerInstances, id),
2376 providerModelPreferences: withoutProviderInstanceKey(settings.providerModelPreferences, id),
2377 favorites: withoutProviderInstanceFavorites(settings.favorites ?? [], id),
2378 });
2379 };
2380
2381 const updateProviderModelPreferences = (
2382 instanceId: ProviderInstanceId,
2383 next: {
2384 readonly hiddenModels: ReadonlyArray<string>;
2385 readonly modelOrder: ReadonlyArray<string>;
2386 },
2387 ) => {
2388 const hiddenModels = [...new Set(next.hiddenModels.filter((slug) => slug.trim().length > 0))];
2389 const modelOrder = [...new Set(next.modelOrder.filter((slug) => slug.trim().length > 0))];
2390 const rest = withoutProviderInstanceKey(settings.providerModelPreferences, instanceId);
2391 updateSettings({
2392 providerModelPreferences:
2393 hiddenModels.length === 0 && modelOrder.length === 0
2394 ? rest
2395 : {
2396 ...rest,
2397 [instanceId]: {
2398 hiddenModels,
2399 modelOrder,
2400 },
2401 },
2402 });
2403 };
2404
2405 const updateProviderFavoriteModels = (
2406 instanceId: ProviderInstanceId,
2407 nextFavoriteModels: ReadonlyArray<string>,
2408 ) => {
2409 const favoriteModels = [
2410 ...new Set(
2411 Arr.filterMap(nextFavoriteModels, (slug) => {
2412 const trimmedSlug = slug.trim();
2413 return trimmedSlug.length > 0 ? Result.succeed(trimmedSlug) : Result.failVoid;
2414 }),
2415 ),
2416 ];
2417 updateSettings({
2418 favorites: [
2419 ...withoutProviderInstanceFavorites(settings.favorites ?? [], instanceId),
2420 ...favoriteModels.map((model) => ({ provider: instanceId, model })),
2421 ],
2422 });
2423 };
2424
2425 const resetDefaultInstance = (driverKind: ProviderDriverKind) => {
2426 type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers];
2427 const defaultLegacyProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record<
2428 string,
2429 LegacyProviderSettings | undefined
2430 >;
2431 const defaultInstanceId = defaultInstanceIdForDriver(driverKind);
2432 const defaultLegacyProvider = defaultLegacyProviders[driverKind];
2433 if (defaultLegacyProvider === undefined) return;
2434 updateSettings({
2435 providers: {
2436 ...settings.providers,
2437 [driverKind]: defaultLegacyProvider,
2438 } as typeof settings.providers,
2439 providerInstances: withoutProviderInstanceKey(settings.providerInstances, defaultInstanceId),
2440 providerModelPreferences: withoutProviderInstanceKey(
2441 settings.providerModelPreferences,
2442 defaultInstanceId,
2443 ),
2444 favorites: withoutProviderInstanceFavorites(settings.favorites ?? [], defaultInstanceId),
2445 });
2446 };
2447
2448 return (
2449 <SettingsPageContainer>
2450 <SettingsSection
2451 {...searchableSetting("providers")}
2452 headerAction={
2453 <div className="flex items-center gap-1.5">
2454 <ProviderLastChecked lastCheckedAt={lastCheckedAt} />
2455 <Tooltip>
2456 <TooltipTrigger
2457 render={
2458 <Button
2459 size="icon-xs"
2460 variant="ghost"
2461 className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground"
2462 onClick={() => setIsAddInstanceDialogOpen(true)}
2463 aria-label="Add provider instance"
2464 >
2465 <PlusIcon className="size-3" />
2466 </Button>
2467 }
2468 />
2469 <TooltipPopup side="top">Add provider instance</TooltipPopup>
2470 </Tooltip>
2471 <Tooltip>
2472 <TooltipTrigger
2473 render={
2474 <Button
2475 size="icon-xs"
2476 variant="ghost"
2477 className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground"
2478 disabled={isRefreshingProviders}
2479 onClick={() => void refreshProviders()}
2480 aria-label="Refresh provider status"
2481 >
2482 {isRefreshingProviders ? (
2483 <LoaderIcon className="size-3 animate-spin" />
2484 ) : (
2485 <RefreshCwIcon className="size-3" />
2486 )}
2487 </Button>
2488 }
2489 />
2490 <TooltipPopup side="top">Refresh provider status</TooltipPopup>
2491 </Tooltip>
2492 </div>
2493 }
2494 >
2495 <SettingsRow
2496 title={
2497 <span className="inline-flex items-center gap-1.5">
2498 Health check interval
2499 <PolicyTooltip>
2500 This interval is configured here, then the shared Background activity policy decides
2501 whether provider probes may run when the timer fires. Custom intervals appear as
2502 Advanced in General settings.
2503 </PolicyTooltip>
2504 </span>
2505 }
2506 description="Refresh provider availability, versions, auth state, and model metadata in the background. Set this to 0 seconds to rely on manual refreshes."
2507 resetAction={
2508 providerHealthRefreshIntervalSeconds !== defaultProviderHealthRefreshIntervalSeconds ? (
2509 <SettingResetButton
2510 label="provider health check interval"
2511 onClick={() =>
2512 updateSettings(
2513 backgroundActivityOverrideSettings(
2514 settings.backgroundActivity,
2515 resolvedBackgroundActivity,
2516 {
2517 providerHealthRefreshInterval: undefined,
2518 },
2519 ),
2520 )
2521 }
2522 />
2523 ) : null
2524 }
2525 control={
2526 <div className="flex shrink-0 items-center gap-2">
2527 <NumberField
2528 value={providerHealthRefreshIntervalSeconds}
2529 min={0}
2530 step={PROVIDER_HEALTH_INTERVAL_STEP_SECONDS}
2531 size="sm"
2532 className="w-32"
2533 onValueChange={(value) =>
2534 updateSettings(
2535 backgroundActivityOverrideSettings(
2536 settings.backgroundActivity,
2537 resolvedBackgroundActivity,
2538 {
2539 providerHealthRefreshInterval: Duration.seconds(
2540 normalizeIntervalSeconds(value),
2541 ),
2542 },
2543 ),
2544 )
2545 }
2546 >
2547 <NumberFieldGroup>
2548 <NumberFieldDecrement aria-label="Decrease provider health check interval" />
2549 <NumberFieldInput aria-label="Provider health check interval in seconds" />
2550 <NumberFieldIncrement aria-label="Increase provider health check interval" />
2551 </NumberFieldGroup>
2552 </NumberField>
2553 <span className="text-xs text-muted-foreground">seconds</span>
2554 </div>
2555 }
2556 />
2557
2558 {rows.map((row) => {
2559 const driverOption = getDriverOption(row.driver);
2560 const liveProvider = serverProviders.find(
2561 (candidate) => candidate.instanceId === row.instanceId,
2562 );
2563 const updateCandidate = liveProvider
2564 ? providerUpdateCandidateByInstanceId.get(liveProvider.instanceId)
2565 : undefined;
2566 const isDriverUpdateRunning =
2567 updateCandidate !== undefined &&
2568 (updatingProviderDrivers.has(updateCandidate.driver) ||
2569 serverProviders.some(
2570 (provider) =>
2571 provider.driver === updateCandidate.driver && isProviderUpdateActive(provider),
2572 ));
2573 const showInlineUpdateButton =
2574 updateCandidate !== undefined &&
2575 hasOneClickUpdateProviderCandidate(updateCandidate, serverProviders);
2576 const canRunInlineUpdate =
2577 updateCandidate !== undefined &&
2578 canOneClickUpdateProviderCandidate(updateCandidate, serverProviders) &&
2579 !updatingProviderDrivers.has(updateCandidate.driver);
2580 const modelPreferences = settings.providerModelPreferences?.[row.instanceId] ?? {
2581 hiddenModels: [],
2582 modelOrder: [],
2583 };
2584 const favoriteModels = Arr.filterMap(settings.favorites ?? [], (favorite) =>
2585 favorite.provider === row.instanceId ? Result.succeed(favorite.model) : Result.failVoid,
2586 );
2587 const resetLabel = driverOption?.label ?? String(row.driver);
2588 const headerAction =
2589 row.isDefault && row.isDirty ? (
2590 <SettingResetButton
2591 label={`${resetLabel} provider settings`}
2592 onClick={() => resetDefaultInstance(row.driver)}
2593 />
2594 ) : null;
2595 return (
2596 <ProviderInstanceCard
2597 key={row.instanceId}
2598 instanceId={row.instanceId}
2599 instance={row.instance}
2600 driverOption={driverOption}
2601 liveProvider={liveProvider}
2602 isExpanded={openInstanceDetails[row.instanceId] ?? false}
2603 onExpandedChange={(open) =>
2604 setOpenInstanceDetails((existing) => ({
2605 ...existing,
2606 [row.instanceId]: open,
2607 }))
2608 }
2609 onUpdate={(next) => {
2610 const wasEnabled = row.instance.enabled ?? true;
2611 const isDisabling = next.enabled === false && wasEnabled;
2612 const shouldClearTextGen = isDisabling && textGenInstanceId === row.instanceId;
2613 if (shouldClearTextGen) {
2614 updateProviderInstance(row, next, {
2615 textGenerationModelSelection:
2616 DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection,
2617 });
2618 } else {
2619 updateProviderInstance(row, next);
2620 }
2621 }}
2622 onDelete={row.isDefault ? undefined : () => deleteProviderInstance(row.instanceId)}
2623 headerAction={headerAction}
2624 hiddenModels={modelPreferences.hiddenModels}
2625 favoriteModels={favoriteModels}
2626 modelOrder={modelPreferences.modelOrder}
2627 onHiddenModelsChange={(hiddenModels) =>
2628 updateProviderModelPreferences(row.instanceId, {
2629 ...modelPreferences,
2630 hiddenModels,
2631 })
2632 }
2633 onFavoriteModelsChange={(favoriteModels) =>
2634 updateProviderFavoriteModels(row.instanceId, favoriteModels)
2635 }
2636 onModelOrderChange={(modelOrder) =>
2637 updateProviderModelPreferences(row.instanceId, {
2638 ...modelPreferences,
2639 modelOrder,
2640 })
2641 }
2642 onRunUpdate={
2643 showInlineUpdateButton && updateCandidate
2644 ? () => {
2645 if (!canRunInlineUpdate) {
2646 return;
2647 }
2648 void runProviderUpdate(updateCandidate);
2649 }
2650 : undefined
2651 }
2652 isUpdating={showInlineUpdateButton ? isDriverUpdateRunning : undefined}
2653 />
2654 );
2655 })}
2656 </SettingsSection>
2657
2658 {isAddInstanceDialogOpen ? (
2659 <AddProviderInstanceDialog open onOpenChange={setIsAddInstanceDialogOpen} />
2660 ) : null}
2661 </SettingsPageContainer>
2662 );
2663}
2664
26652000export function ArchivedThreadsPanel() {
26662001 const projects = useProjects();
26672002 const { unarchiveThread, confirmAndDeleteThread } = useThreadActions();
apps/web/src/components/settings/settingsLayout.tsx+23-1
......@@ -1,4 +1,4 @@
1import { Undo2Icon } from "lucide-react";
1import { InfoIcon, Undo2Icon } from "lucide-react";
22import { useLocation, useNavigate } from "@tanstack/react-router";
33import {
44 createContext,
......@@ -83,6 +83,28 @@ function useSettingsSearchTarget<T extends HTMLElement>(id: string | undefined)
8383 return targetRef;
8484}
8585
86/** Info affordance explaining how a setting interacts with the shared background policy. */
87export function PolicyTooltip({ children }: { readonly children: string }) {
88 return (
89 <Tooltip>
90 <TooltipTrigger
91 render={
92 <button
93 type="button"
94 className="inline-flex size-5 items-center justify-center rounded-sm text-muted-foreground hover:text-foreground"
95 aria-label="Background policy details"
96 >
97 <InfoIcon className="size-3.5" />
98 </button>
99 }
100 />
101 <TooltipPopup side="top" className="max-w-72">
102 {children}
103 </TooltipPopup>
104 </Tooltip>
105 );
106}
107
86108/** Re-render every `intervalMs`; return a stable timestamp snapshot for render-time relative labels. */
87109export function useRelativeTimeTick(intervalMs = 1_000) {
88110 const [nowMs, setNowMs] = useState(() => Date.now());
apps/web/src/routes/settings.providers.tsx+1-1
......@@ -1,6 +1,6 @@
11import { createFileRoute } from "@tanstack/react-router";
22
3import { ProviderSettingsPanel } from "../components/settings/SettingsPanels";
3import { ProviderSettingsPanel } from "../components/settings/ProviderSettingsPanel";
44
55function SettingsProvidersRoute() {
66 return <ProviderSettingsPanel />;
apps/web/src/state/server.ts+1-1
......@@ -33,7 +33,7 @@ interface PrimaryServerState {
3333}
3434
3535const EMPTY_AVAILABLE_EDITORS: ReadonlyArray<EditorId> = [];
36const EMPTY_SERVER_PROVIDERS: ReadonlyArray<ServerProvider> = [];
36export const EMPTY_SERVER_PROVIDERS: ReadonlyArray<ServerProvider> = [];
3737const EMPTY_PRIMARY_SERVER_STATE: PrimaryServerState = {
3838 config: null,
3939 latestEvent: null,
apps/web/src/state/session.ts+15-1
......@@ -2,7 +2,7 @@ import { useAtomValue } from "@effect/atom-react";
22import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session";
33import type { EnvironmentId } from "@t3tools/contracts";
44import * as Option from "effect/Option";
5import { Atom } from "effect/unstable/reactivity";
5import { AsyncResult, Atom } from "effect/unstable/reactivity";
66
77import { connectionAtomRuntime } from "../connection/runtime";
88import { appAtomRegistry } from "../rpc/atomRegistry";
......@@ -26,3 +26,17 @@ export function readPreparedConnection(environmentId: EnvironmentId) {
2626 appAtomRegistry.get(environmentSession.preparedConnectionValueAtom(environmentId)),
2727 );
2828}
29
30/**
31 * This client's authenticated session on one environment, as reported by that
32 * environment's `/api/auth/session` endpoint. `data` stays populated across
33 * SWR revalidations; `isPending` is only meaningful before the first resolve.
34 */
35export function useEnvironmentSessionState(environmentId: EnvironmentId) {
36 const result = useAtomValue(environmentSession.sessionStateAtom(environmentId));
37 return {
38 data: Option.getOrNull(AsyncResult.value(result)),
39 hasError: result._tag === "Failure",
40 isPending: result.waiting,
41 };
42}
apps/web/src/test/reactElementTree.ts created+27
......@@ -0,0 +1,27 @@
1import { isValidElement, type ReactElement } from "react";
2
3/**
4 * Depth-first search over a React element tree produced by calling a component
5 * as a plain function (see `reactHookHarness`). Descends through props so
6 * render-prop and slot-style children are reachable. Returns the first element
7 * the visitor accepts, or null.
8 */
9export function visitElements(
10 node: unknown,
11 visitor: (element: ReactElement<Record<string, unknown>>) => boolean,
12): ReactElement<Record<string, unknown>> | null {
13 if (Array.isArray(node)) {
14 for (const child of node) {
15 const found = visitElements(child, visitor);
16 if (found) return found;
17 }
18 return null;
19 }
20 if (!isValidElement<Record<string, unknown>>(node)) return null;
21 if (visitor(node)) return node;
22 for (const value of Object.values(node.props)) {
23 const found = visitElements(value, visitor);
24 if (found) return found;
25 }
26 return null;
27}
apps/web/src/test/reactHookHarness.ts created+89
......@@ -0,0 +1,89 @@
1import type { Dispatch, SetStateAction } from "react";
2
3/**
4 * Minimal React hook shim for tests that call components as plain functions
5 * instead of mounting a renderer. Slots are keyed by call order, mirroring
6 * React's own rules-of-hooks contract, and `useMemoCache` emulates the React
7 * Compiler runtime so compiled components can execute unmodified.
8 *
9 * This module must stay free of runtime `react` imports: it is loaded from
10 * inside `vi.mock("react", ...)` factories, and a value import would recurse
11 * into the in-progress mock. Wire it up in each test file (mock calls cannot
12 * live here because vitest hoists them per test module):
13 *
14 * ```ts
15 * import { reactHookHarness } from "~/test/reactHookHarness";
16 *
17 * vi.mock("react", async (importOriginal) => {
18 * const actual = await importOriginal<typeof import("react")>();
19 * const { reactHookHarness } = await import("~/test/reactHookHarness");
20 * return {
21 * ...actual,
22 * useCallback: reactHookHarness.useCallback,
23 * useMemo: reactHookHarness.useMemo,
24 * useRef: reactHookHarness.useRef,
25 * useState: reactHookHarness.useState,
26 * };
27 * });
28 * vi.mock("react/compiler-runtime", async () => {
29 * const { reactHookHarness } = await import("~/test/reactHookHarness");
30 * return { c: reactHookHarness.useMemoCache };
31 * });
32 * ```
33 *
34 * Call `beginRender()` before each component invocation and `reset()` in
35 * `beforeEach` to drop persisted state between tests.
36 */
37export function createReactHookHarness() {
38 let cursor = 0;
39 let slots: unknown[] = [];
40 const nextIndex = () => cursor++;
41
42 return {
43 beginRender() {
44 cursor = 0;
45 },
46 reset() {
47 cursor = 0;
48 slots = [];
49 },
50 useCallback<T>(callback: T): T {
51 nextIndex();
52 return callback;
53 },
54 useMemo<T>(factory: () => T): T {
55 nextIndex();
56 return factory();
57 },
58 useMemoCache(size: number): unknown[] {
59 const index = nextIndex();
60 if (!slots[index]) {
61 slots[index] = Array.from({ length: size }, () => Symbol.for("react.memo_cache_sentinel"));
62 }
63 return slots[index] as unknown[];
64 },
65 useRef<T>(initialValue: T): { current: T } {
66 const index = nextIndex();
67 if (!slots[index]) {
68 slots[index] = { current: initialValue };
69 }
70 return slots[index] as { current: T };
71 },
72 useState<T>(initialValue: T | (() => T)): [T, Dispatch<SetStateAction<T>>] {
73 const index = nextIndex();
74 if (index >= slots.length) {
75 slots[index] =
76 typeof initialValue === "function" ? (initialValue as () => T)() : initialValue;
77 }
78 const setValue: Dispatch<SetStateAction<T>> = (nextValue) => {
79 const previous = slots[index] as T;
80 slots[index] =
81 typeof nextValue === "function" ? (nextValue as (value: T) => T)(previous) : nextValue;
82 };
83 return [slots[index] as T, setValue];
84 },
85 };
86}
87
88/** Shared instance so `vi.mock` factories and test bodies see the same slots. */
89export const reactHookHarness = createReactHookHarness();
packages/client-runtime/src/state/session.ts+69-2
......@@ -1,14 +1,19 @@
1import type { EnvironmentId, ServerConfig } from "@t3tools/contracts";
1import type { AuthSessionState, EnvironmentId, ServerConfig } from "@t3tools/contracts";
22import * as Effect from "effect/Effect";
33import * as Option from "effect/Option";
44import * as Stream from "effect/Stream";
55import * as SubscriptionRef from "effect/SubscriptionRef";
6import type { HttpClient } from "effect/unstable/http";
67import { AsyncResult, Atom } from "effect/unstable/reactivity";
78
89import { EnvironmentRegistry } from "../connection/registry.ts";
910import type { PreparedConnection } from "../connection/model.ts";
1011import { EnvironmentSupervisor } from "../connection/supervisor.ts";
12import { environmentEndpointUrl } from "../environment/endpoint.ts";
13import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts";
1114import { safeErrorLogAttributes } from "../errors/safeLog.ts";
15import { executeEnvironmentHttpRequest, makeEnvironmentHttpApiClient } from "../rpc/http.ts";
16import { buildEnvironmentAuthHeaders, withEnvironmentCredentials } from "./environmentHttpAuth.ts";
1217import { followStreamInEnvironment } from "./runtime.ts";
1318
1419export function initialConfigOption<E>(
......@@ -25,8 +30,39 @@ export function initialConfigOption<E>(
2530 );
2631}
2732
33// Bounded like the snapshot fetches: a wedged environment must not pin the
34// permissions check (and with it the settings UI) in a loading state for long.
35const DEFAULT_SESSION_STATE_TIMEOUT_MS = 6_000;
36
37/**
38 * Read the granted scopes of this client's session on one environment via its
39 * `/api/auth/session` endpoint, authenticated with whatever credential the
40 * connection was prepared with (cookie, bearer, or DPoP).
41 */
42export const fetchEnvironmentSessionState = Effect.fn(
43 "clientRuntime.state.fetchEnvironmentSessionState",
44)(function* (input: {
45 readonly prepared: PreparedConnection;
46 readonly signer: Option.Option<ManagedRelayDpopSigner["Service"]>;
47 readonly timeoutMs?: number;
48}) {
49 const requestUrl = environmentEndpointUrl(input.prepared.httpBaseUrl, "/api/auth/session");
50 const client = yield* makeEnvironmentHttpApiClient(input.prepared.httpBaseUrl);
51 const headers = yield* buildEnvironmentAuthHeaders(
52 input.prepared.httpAuthorization,
53 "GET",
54 requestUrl,
55 input.signer,
56 );
57 return yield* executeEnvironmentHttpRequest(
58 requestUrl,
59 input.timeoutMs ?? DEFAULT_SESSION_STATE_TIMEOUT_MS,
60 withEnvironmentCredentials(input.prepared.httpAuthorization, client.auth.session({ headers })),
61 );
62});
63
2864export function createEnvironmentSessionAtoms<R, E>(
29 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,
65 runtime: Atom.AtomRuntime<EnvironmentRegistry | HttpClient.HttpClient | R, E>,
3066) {
3167 const initialConfigAtom = Atom.family((environmentId: EnvironmentId) =>
3268 runtime.atom(
......@@ -86,10 +122,41 @@ export function createEnvironmentSessionAtoms<R, E>(
86122 ).pipe(Atom.withLabel(`environment-prepared-connection:${environmentId}`)),
87123 );
88124
125 // Keyed on the prepared connection's identity: a reconnect (new credential,
126 // new base URL) swaps the prepared value, which re-runs the fetch, so scope
127 // changes from re-pairing are picked up without an explicit refresh.
128 const sessionStateAtom = Atom.family((environmentId: EnvironmentId) =>
129 runtime
130 .atom((get) => {
131 const prepared = Option.getOrNull(get(preparedConnectionValueAtom(environmentId)));
132 if (prepared === null) {
133 return Effect.never;
134 }
135 return Effect.gen(function* () {
136 const signer = yield* Effect.serviceOption(ManagedRelayDpopSigner);
137 return yield* fetchEnvironmentSessionState({ prepared, signer });
138 });
139 })
140 .pipe(
141 Atom.swr({ staleTime: 30_000, revalidateOnMount: true }),
142 Atom.setIdleTTL(5 * 60_000),
143 Atom.withLabel(`environment-session-state:${environmentId}`),
144 ),
145 );
146
147 const sessionStateValueAtom = Atom.family((environmentId: EnvironmentId) =>
148 Atom.make(
149 (get): AuthSessionState | null =>
150 Option.getOrNull(AsyncResult.value(get(sessionStateAtom(environmentId)))) ?? null,
151 ).pipe(Atom.withLabel(`environment-session-state-value:${environmentId}`)),
152 );
153
89154 return {
90155 initialConfigAtom,
91156 initialConfigValueAtom,
92157 preparedConnectionAtom,
93158 preparedConnectionValueAtom,
159 sessionStateAtom,
160 sessionStateValueAtom,
94161 };
95162}