| 1 | import type { |
| 2 | BackgroundActivityProfile, |
| 3 | BackgroundActivitySettings, |
| 4 | ProviderInstanceConfig, |
| 5 | PreviewViewportSetting, |
| 6 | ProviderInstanceId, |
| 7 | ServerSettings, |
| 8 | SidebarProjectGroupingMode, |
| 9 | UnifiedSettings, |
| 10 | } from "@t3tools/contracts"; |
| 11 | import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; |
| 12 | import { |
| 13 | getBackgroundActivityBaseProfile, |
| 14 | normalizeBackgroundActivitySettings, |
| 15 | normalizeServerBackgroundActivitySettings, |
| 16 | resolveServerBackgroundActivitySettings, |
| 17 | } from "@t3tools/shared/backgroundActivitySettings"; |
| 18 | import * as Duration from "effect/Duration"; |
| 19 | import * as Equal from "effect/Equal"; |
| 20 | |
| 21 | export function isProjectGroupingEnabled(mode: SidebarProjectGroupingMode): boolean { |
| 22 | return mode !== "separate"; |
| 23 | } |
| 24 | |
| 25 | export 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 | |
| 33 | const LAST_ENABLED_PROJECT_GROUPING_MODE_KEY = "t3code:last-enabled-project-grouping-mode"; |
| 34 | |
| 35 | export 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 | |
| 45 | export 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 | |
| 54 | export 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 | |
| 77 | type 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. */ |
| 90 | export 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 | |
| 111 | export 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 | */ |
| 130 | function 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. */ |
| 143 | export 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 | |
| 172 | export 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 | |
| 195 | export 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 | |
| 208 | function 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 | |
| 227 | export 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 | |
| 256 | export 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 | |
| 279 | export const PROVIDER_HEALTH_INTERVAL_STEP_SECONDS = 30; |
| 280 | |
| 281 | type BackgroundActivityOverridePatch = Partial<{ |
| 282 | [K in keyof BackgroundActivitySettings["overrides"]]: |
| 283 | | BackgroundActivitySettings["overrides"][K] |
| 284 | | undefined; |
| 285 | }>; |
| 286 | |
| 287 | export function durationToSeconds(duration: Duration.Duration): number { |
| 288 | return Math.round(Duration.toMillis(duration) / 1_000); |
| 289 | } |
| 290 | |
| 291 | export 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 | |
| 298 | export 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 | } |