1import type {
2 BackgroundActivityProfile,
3 BackgroundActivitySettings,
4 ProviderInstanceConfig,
5 PreviewViewportSetting,
6 ProviderInstanceId,
7 ServerSettings,
8 SidebarProjectGroupingMode,
9 UnifiedSettings,
10} from "@t3tools/contracts";
11import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
12import {
13 getBackgroundActivityBaseProfile,
14 normalizeBackgroundActivitySettings,
15 normalizeServerBackgroundActivitySettings,
16 resolveServerBackgroundActivitySettings,
17} from "@t3tools/shared/backgroundActivitySettings";
18import * as Duration from "effect/Duration";
19import * as Equal from "effect/Equal";
20
21export function isProjectGroupingEnabled(mode: SidebarProjectGroupingMode): boolean {
22 return mode !== "separate";
23}
24
25export function projectGroupingModeFromToggle(
26 enabled: boolean,
27 lastEnabledMode: SidebarProjectGroupingMode = "repository",
28): SidebarProjectGroupingMode {
29 if (!enabled) return "separate";
30 return lastEnabledMode === "repository_path" ? "repository_path" : "repository";
31}
32
33const LAST_ENABLED_PROJECT_GROUPING_MODE_KEY = "t3code:last-enabled-project-grouping-mode";
34
35export function readLastEnabledProjectGroupingMode(): SidebarProjectGroupingMode {
36 try {
37 return localStorage.getItem(LAST_ENABLED_PROJECT_GROUPING_MODE_KEY) === "repository_path"
38 ? "repository_path"
39 : "repository";
40 } catch {
41 return "repository";
42 }
43}
44
45export function rememberEnabledProjectGroupingMode(mode: SidebarProjectGroupingMode): void {
46 if (mode === "separate") return;
47 try {
48 localStorage.setItem(LAST_ENABLED_PROJECT_GROUPING_MODE_KEY, mode);
49 } catch {
50 // Storage can be unavailable in restricted browser contexts.
51 }
52}
53
54export function hasChangedBackgroundActivitySettings(
55 settings: Pick<
56 UnifiedSettings,
57 | "backgroundActivity"
58 | "backgroundActivityProfile"
59 | "automaticGitFetchInterval"
60 | "providerHealthRefreshInterval"
61 >,
62): boolean {
63 return (
64 !Equal.equals(settings.backgroundActivity, DEFAULT_UNIFIED_SETTINGS.backgroundActivity) ||
65 settings.backgroundActivityProfile !== DEFAULT_UNIFIED_SETTINGS.backgroundActivityProfile ||
66 !Equal.equals(
67 settings.automaticGitFetchInterval,
68 DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval,
69 ) ||
70 !Equal.equals(
71 settings.providerHealthRefreshInterval,
72 DEFAULT_UNIFIED_SETTINGS.providerHealthRefreshInterval,
73 )
74 );
75}
76
77type TypographySettings = Pick<
78 UnifiedSettings,
79 | "fontFamilySans"
80 | "fontFamilyComposer"
81 | "fontFamilyCode"
82 | "fontFamilyTerminal"
83 | "fontSizeInterface"
84 | "fontSizePrompt"
85 | "fontSizeCode"
86 | "fontSizeTerminal"
87>;
88
89/** Labels the font rows whose family or size differs from the defaults. */
90export function getChangedTypographySettingLabels(settings: TypographySettings): string[] {
91 return [
92 ...(settings.fontFamilySans !== DEFAULT_UNIFIED_SETTINGS.fontFamilySans ||
93 settings.fontSizeInterface !== DEFAULT_UNIFIED_SETTINGS.fontSizeInterface
94 ? ["Interface font"]
95 : []),
96 ...(settings.fontFamilyComposer !== DEFAULT_UNIFIED_SETTINGS.fontFamilyComposer ||
97 settings.fontSizePrompt !== DEFAULT_UNIFIED_SETTINGS.fontSizePrompt
98 ? ["Prompt font"]
99 : []),
100 ...(settings.fontFamilyCode !== DEFAULT_UNIFIED_SETTINGS.fontFamilyCode ||
101 settings.fontSizeCode !== DEFAULT_UNIFIED_SETTINGS.fontSizeCode
102 ? ["Code font"]
103 : []),
104 ...(settings.fontFamilyTerminal !== DEFAULT_UNIFIED_SETTINGS.fontFamilyTerminal ||
105 settings.fontSizeTerminal !== DEFAULT_UNIFIED_SETTINGS.fontSizeTerminal
106 ? ["Terminal font"]
107 : []),
108 ];
109}
110
111export type BrowserDefaultSettings = Pick<
112 UnifiedSettings,
113 | "browserDefaultViewport"
114 | "browserDefaultZoomFactor"
115 | "browserDefaultAppearance"
116 | "browserRecordingFrameRate"
117 | "browserRecordingShowKeyPresses"
118 | "browserRecordingShowMousePresses"
119 | "browserLinkTarget"
120 | "browserAutoShowFloatingPreview"
121>;
122
123/**
124 * True when two viewport settings describe the same viewport.
125 *
126 * The setting is a tagged union rather than a scalar, so identity comparison
127 * reports every stored viewport as changed — including one that matches the
128 * default.
129 */
130function isSamePreviewViewport(
131 left: PreviewViewportSetting,
132 right: PreviewViewportSetting,
133): boolean {
134 if (left._tag !== right._tag) return false;
135 if (left._tag === "fill" || right._tag === "fill") return true;
136 if (left.width !== right.width || left.height !== right.height) return false;
137 return left._tag === "preset" && right._tag === "preset"
138 ? left.presetId === right.presetId
139 : true;
140}
141
142/** Labels the browser-default rows that differ from the defaults. */
143export function getChangedBrowserSettingLabels(settings: BrowserDefaultSettings): string[] {
144 return [
145 ...(isSamePreviewViewport(
146 settings.browserDefaultViewport,
147 DEFAULT_UNIFIED_SETTINGS.browserDefaultViewport,
148 )
149 ? []
150 : ["Browser viewport"]),
151 ...(settings.browserDefaultZoomFactor !== DEFAULT_UNIFIED_SETTINGS.browserDefaultZoomFactor
152 ? ["Browser zoom"]
153 : []),
154 ...(settings.browserDefaultAppearance !== DEFAULT_UNIFIED_SETTINGS.browserDefaultAppearance
155 ? ["Browser appearance"]
156 : []),
157 ...(settings.browserRecordingFrameRate !== DEFAULT_UNIFIED_SETTINGS.browserRecordingFrameRate
158 ? ["Recording frame rate"]
159 : []),
160 ...(settings.browserRecordingShowKeyPresses ? ["Recording key presses"] : []),
161 ...(settings.browserRecordingShowMousePresses ? ["Recording mouse presses"] : []),
162 ...(settings.browserLinkTarget !== DEFAULT_UNIFIED_SETTINGS.browserLinkTarget
163 ? ["Open links in"]
164 : []),
165 ...(settings.browserAutoShowFloatingPreview !==
166 DEFAULT_UNIFIED_SETTINGS.browserAutoShowFloatingPreview
167 ? ["Floating preview"]
168 : []),
169 ];
170}
171
172export function resolveBackgroundActivityProfileOption(
173 settings: ServerSettings,
174): BackgroundActivityProfile | "advanced" {
175 const resolved = resolveServerBackgroundActivitySettings(settings);
176 const normalized = normalizeBackgroundActivitySettings({
177 schemaVersion: 1,
178 profile: "custom",
179 baseProfile: resolved.profile,
180 overrides: {
181 automaticGitFetchInterval: resolved.automaticGitFetchInterval,
182 providerHealthRefreshInterval: resolved.providerHealthRefreshInterval,
183 hostPowerMonitorActiveInterval: resolved.hostPowerMonitorActiveInterval,
184 hostPowerMonitorIdleInterval: resolved.hostPowerMonitorIdleInterval,
185 idleClientTtl: resolved.idleClientTtl,
186 pauseWhenHostLocked: resolved.pauseWhenHostLocked,
187 pauseWhenHostLowPower: resolved.pauseWhenHostLowPower,
188 pauseWhenClientLowPower: resolved.pauseWhenClientLowPower,
189 pauseWhenOnBattery: resolved.pauseWhenOnBattery,
190 },
191 });
192 return normalized.profile === "custom" ? "advanced" : normalized.profile;
193}
194
195export function backgroundActivitySharedPolicySettings(
196 settings: ServerSettings,
197 profile: BackgroundActivityProfile,
198): BackgroundActivitySettings {
199 const normalized = normalizeServerBackgroundActivitySettings(settings);
200 return {
201 schemaVersion: 1,
202 profile: "custom",
203 baseProfile: profile,
204 overrides: normalized.profile === "custom" ? normalized.overrides : {},
205 };
206}
207
208function collapseOtelSignalsUrl(input: {
209 readonly tracesUrl: string;
210 readonly metricsUrl: string;
211}): string | null {
212 const tracesSuffix = "/traces";
213 const metricsSuffix = "/metrics";
214 if (!input.tracesUrl.endsWith(tracesSuffix) || !input.metricsUrl.endsWith(metricsSuffix)) {
215 return null;
216 }
217
218 const tracesBase = input.tracesUrl.slice(0, -tracesSuffix.length);
219 const metricsBase = input.metricsUrl.slice(0, -metricsSuffix.length);
220 if (tracesBase !== metricsBase) {
221 return null;
222 }
223
224 return `${tracesBase}/{traces,metrics}`;
225}
226
227export function formatDiagnosticsDescription(input: {
228 readonly localTracingEnabled: boolean;
229 readonly otlpTracesEnabled: boolean;
230 readonly otlpTracesUrl?: string | undefined;
231 readonly otlpMetricsEnabled: boolean;
232 readonly otlpMetricsUrl?: string | undefined;
233}): string {
234 const mode = input.localTracingEnabled ? "Local trace file" : "Terminal logs only";
235 const tracesUrl = input.otlpTracesEnabled ? input.otlpTracesUrl : undefined;
236 const metricsUrl = input.otlpMetricsEnabled ? input.otlpMetricsUrl : undefined;
237
238 if (tracesUrl && metricsUrl) {
239 const collapsedUrl = collapseOtelSignalsUrl({ tracesUrl, metricsUrl });
240 return collapsedUrl
241 ? `${mode}. Exporting OTEL to ${collapsedUrl}.`
242 : `${mode}. Exporting OTEL traces to ${tracesUrl} and metrics to ${metricsUrl}.`;
243 }
244
245 if (tracesUrl) {
246 return `${mode}. Exporting OTEL traces to ${tracesUrl}.`;
247 }
248
249 if (metricsUrl) {
250 return `${mode}. Exporting OTEL metrics to ${metricsUrl}.`;
251 }
252
253 return `${mode}.`;
254}
255
256export function buildProviderInstanceUpdatePatch(input: {
257 readonly settings: Pick<ServerSettings, "providerInstances">;
258 readonly instanceId: ProviderInstanceId;
259 readonly instance: ProviderInstanceConfig;
260 readonly textGenerationModelSelection?:
261 | ServerSettings["textGenerationModelSelection"]
262 | undefined;
263}): Partial<UnifiedSettings> {
264 return {
265 providerInstances: {
266 ...input.settings.providerInstances,
267 [input.instanceId]: input.instance,
268 },
269 ...(input.textGenerationModelSelection !== undefined
270 ? { textGenerationModelSelection: input.textGenerationModelSelection }
271 : {}),
272 };
273}
274
275// ── Background-activity interval helpers ─────────────────────────────
276// Shared by the General panel's interval rows and the Providers panel's
277// health-check row.
278
279export const PROVIDER_HEALTH_INTERVAL_STEP_SECONDS = 30;
280
281type BackgroundActivityOverridePatch = Partial<{
282 [K in keyof BackgroundActivitySettings["overrides"]]:
283 | BackgroundActivitySettings["overrides"][K]
284 | undefined;
285}>;
286
287export function durationToSeconds(duration: Duration.Duration): number {
288 return Math.round(Duration.toMillis(duration) / 1_000);
289}
290
291export function normalizeIntervalSeconds(value: number | null, minimum = 0): number {
292 if (value === null || !Number.isFinite(value)) {
293 return minimum;
294 }
295 return Math.max(minimum, Math.round(value));
296}
297
298export function backgroundActivityOverrideSettings(
299 current: BackgroundActivitySettings,
300 resolved: ReturnType<typeof resolveServerBackgroundActivitySettings>,
301 overrides: BackgroundActivityOverridePatch,
302) {
303 const nextOverrides: BackgroundActivityOverridePatch = {
304 automaticGitFetchInterval: resolved.automaticGitFetchInterval,
305 providerHealthRefreshInterval: resolved.providerHealthRefreshInterval,
306 hostPowerMonitorActiveInterval: resolved.hostPowerMonitorActiveInterval,
307 hostPowerMonitorIdleInterval: resolved.hostPowerMonitorIdleInterval,
308 idleClientTtl: resolved.idleClientTtl,
309 pauseWhenHostLocked: resolved.pauseWhenHostLocked,
310 pauseWhenHostLowPower: resolved.pauseWhenHostLowPower,
311 pauseWhenClientLowPower: resolved.pauseWhenClientLowPower,
312 pauseWhenOnBattery: resolved.pauseWhenOnBattery,
313 ...overrides,
314 };
315 for (const [key, value] of Object.entries(nextOverrides)) {
316 if (value === undefined) {
317 delete nextOverrides[key as keyof typeof nextOverrides];
318 }
319 }
320 return {
321 backgroundActivity: {
322 schemaVersion: 1 as const,
323 profile: "custom" as const,
324 baseProfile: getBackgroundActivityBaseProfile(current),
325 overrides: nextOverrides as BackgroundActivitySettings["overrides"],
326 },
327 };
328}