1import { DeviceHostUpdates } from "../device/DeviceHostUpdates";
2import { DeviceToolVersions } from "../device/DeviceToolVersions";
3import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings";
4import { ScopedSwitch } from "./ScopedSwitch";
5import { DeviceHostsSettings } from "./DeviceHostsSettings";
6/**
7 * Integrations settings - preferences for surfaces T3 Code embeds rather than
8 * owns. Browser is the first section: the defaults a preview tab opens at,
9 * applied to both hand-opened tabs and agent `preview_open` calls that don't
10 * state their own size.
11 *
12 * @module IntegrationsSettings
13 */
14import {
15 AuthSettingsWriteScope,
16 BrowserImportFailureReason,
17 BROWSER_PROFILE_MAX_COUNT,
18 type BrowserLinkTarget,
19 type BrowserProfile,
20 type EnvironmentId,
21 BROWSER_PROFILE_NAME_MAX_LENGTH,
22 BROWSER_RECORDING_FRAME_RATES,
23 DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW,
24 DEFAULT_BROWSER_PROFILE_ID,
25 DEFAULT_BROWSER_LINK_TARGET,
26 DEFAULT_BROWSER_RECORDING_FRAME_RATE,
27 DEFAULT_BROWSER_VIEWPORT,
28 DEFAULT_PREVIEW_APPEARANCE,
29 DEFAULT_PREVIEW_ZOOM_FACTOR,
30 FILL_PREVIEW_VIEWPORT,
31 PREVIEW_VIEWPORT_MAX_AREA,
32 PREVIEW_VIEWPORT_MAX_DIMENSION,
33 PREVIEW_VIEWPORT_MIN_DIMENSION,
34 PREVIEW_ZOOM_LEVELS,
35 findBrowserProfile,
36 isBuiltInBrowserProfileId,
37 resolveBrowserProfiles,
38 type BrowserImportSource,
39 type PreviewAppearancePreference,
40 type PreviewViewportSetting,
41} from "@t3tools/contracts";
42import { PREVIEW_VIEWPORT_PRESETS } from "@t3tools/shared/previewViewport";
43import { MoreVertical, Plus as PlusIcon } from "lucide-react";
44import { useCallback, useRef, useState } from "react";
45
46import { ScreenRotationIcon } from "~/browser/ScreenRotationIcon";
47import { AnimatedHeight } from "~/components/AnimatedHeight";
48import { resolveEnvironmentOptionLabel } from "~/components/BranchToolbar.logic";
49import { previewBridge } from "~/components/preview/previewBridge";
50import { cn, randomUUID } from "~/lib/utils";
51import { useEnvironments, usePrimaryEnvironment } from "~/state/environments";
52import { deviceEnvironment, useDeviceState } from "~/state/device";
53import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
54import { useAtomCommand } from "~/state/use-atom-command";
55import { previewEnvironment } from "~/state/preview";
56import { useServerConfigs } from "~/state/entities";
57import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
58import {
59 AgentDeviceSetupStatus,
60 DeviceHubSetupStatus,
61 PlatformStatus,
62 platformSetupStatus,
63 deviceHubDescription,
64 agentDeviceDescription,
65} from "~/components/device/DeviceSetup";
66import { isElectron } from "../../env";
67
68import { Badge } from "../ui/badge";
69import {
70 Menu,
71 MenuGroup,
72 MenuGroupLabel,
73 MenuItem,
74 MenuPopup,
75 MenuSeparator,
76 MenuTrigger,
77} from "../ui/menu";
78import { readLocalApi } from "~/localApi";
79
80import { toastManager } from "../ui/toast";
81import {
82 AlertDialog,
83 AlertDialogClose,
84 AlertDialogDescription,
85 AlertDialogFooter,
86 AlertDialogHeader,
87 AlertDialogPopup,
88 AlertDialogTitle,
89} from "../ui/alert-dialog";
90import { Button } from "../ui/button";
91import { DraftInput } from "../ui/draft-input";
92import { NumberField, NumberFieldGroup, NumberFieldInput } from "../ui/number-field";
93import {
94 Select,
95 SelectGroup,
96 SelectGroupLabel,
97 SelectItem,
98 SelectPopup,
99 SelectTrigger,
100 SelectValue,
101} from "../ui/select";
102import { Switch } from "../ui/switch";
103import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
104import {
105 getClientSettings,
106 persistClientSettingsUpdate,
107 useClientSettings,
108 useClientSettingsHydrated,
109 useUpdatePrimarySettings,
110} from "~/hooks/useSettings";
111
112import {
113 SettingsUnavailableGroup,
114 SettingResetButton,
115 SettingsPageContainer,
116 SettingsRow,
117 SettingsSection,
118} from "./settingsLayout";
119import { searchableSetting } from "./settingsSearch";
120import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings";
121import { useSettingsScope } from "./SettingsScopeContext";
122import { BrowserImportWizard, type WizardTarget } from "./BrowserImportWizard";
123import type { ImportOutcome } from "./browserImportWizard.logic";
124
125const FILL_VALUE = "fill";
126const RESPONSIVE_VALUE = "responsive";
127
128type BrowserProfileDataBridge = Pick<
129 NonNullable<typeof previewBridge>,
130 "clearCookies" | "clearCache"
131>;
132
133/** Environments that host server-runtime tabs keep their own copy of each profile. */
134export interface ServerProfileData {
135 readonly environmentIds: ReadonlyArray<EnvironmentId>;
136 readonly clear: (environmentId: EnvironmentId, profileId: string) => Promise<void>;
137}
138
139export async function clearBrowserProfileData(
140 bridge: BrowserProfileDataBridge | null,
141 environmentIds: ReadonlyArray<EnvironmentId>,
142 profileId: string,
143 server?: ServerProfileData,
144): Promise<void> {
145 const desktopEnvironmentIds = bridge === null ? [] : environmentIds;
146 const serverEnvironmentIds = server?.environmentIds ?? [];
147 if (desktopEnvironmentIds.length === 0 && serverEnvironmentIds.length === 0) {
148 throw new Error("Browser profile data is not available to clear.");
149 }
150 await Promise.all([
151 ...desktopEnvironmentIds.flatMap((environmentId) => [
152 bridge!.clearCookies(environmentId, profileId),
153 bridge!.clearCache(environmentId, profileId),
154 ]),
155 ...serverEnvironmentIds.map((environmentId) => server!.clear(environmentId, profileId)),
156 ]);
157}
158
159export function browserProfileRemovalAvailable(
160 bridgeAvailable: boolean,
161 environmentsReady: boolean,
162 environmentCount: number,
163 serverBrowserCount = 0,
164): boolean {
165 return (bridgeAvailable || serverBrowserCount > 0) && environmentsReady && environmentCount > 0;
166}
167
168/**
169 * The size a "Responsive" default falls back to when the user switches away
170 * from Fill and hasn't typed dimensions yet. Fill has no dimensions to carry
171 * over, so the picker needs something concrete to seed the inputs with.
172 */
173const RESPONSIVE_SEED_SIZE = { width: 1280, height: 800 } as const;
174
175const NO_GROUPING: Intl.NumberFormatOptions = { useGrouping: false };
176
177const APPEARANCE_LABELS: Readonly<Record<PreviewAppearancePreference, string>> = {
178 system: "System",
179 light: "Light",
180 dark: "Dark",
181};
182
183const zoomLabel = (zoomFactor: number) => `${Math.round(zoomFactor * 100)}%`;
184
185/**
186 * IPC flattens the failure to its message, so the reason token travels inside
187 * it. Anything unrecognised reads as a plain read failure rather than leaking
188 * the raw message into a toast.
189 */
190/** Thrown from the post-import settings updater when the cap was hit meanwhile. */
191class ProfileLimitReachedError extends Error {
192 constructor() {
193 super("Browser profile limit reached.");
194 this.name = "ProfileLimitReachedError";
195 }
196}
197
198export const importFailureReason = (cause: unknown): BrowserImportFailureReason => {
199 const message = String((cause as { message?: unknown } | undefined)?.message ?? "");
200 return (
201 BrowserImportFailureReason.literals.find((reason) => message.includes(`failed: ${reason}.`)) ??
202 "readFailed"
203 );
204};
205
206const viewportSelectValue = (viewport: PreviewViewportSetting): string => {
207 if (viewport._tag === "fill") return FILL_VALUE;
208 if (
209 viewport._tag === "preset" &&
210 PREVIEW_VIEWPORT_PRESETS.some((preset) => preset.id === viewport.presetId)
211 ) {
212 return viewport.presetId;
213 }
214 return RESPONSIVE_VALUE;
215};
216
217/**
218 * The trigger renders this rather than a bare `SelectValue`, which would fall
219 * back to printing the raw stored value ("fill") because the options are built
220 * inline instead of from an `items` map.
221 */
222const viewportSelectLabel = (viewport: PreviewViewportSetting): string => {
223 const value = viewportSelectValue(viewport);
224 if (value === FILL_VALUE) return "Fill panel";
225 if (value === RESPONSIVE_VALUE) return "Responsive";
226 return PREVIEW_VIEWPORT_PRESETS.find((preset) => preset.id === value)?.label ?? "Responsive";
227};
228
229const isValidDimension = (value: number) =>
230 Number.isInteger(value) &&
231 value >= PREVIEW_VIEWPORT_MIN_DIMENSION &&
232 value <= PREVIEW_VIEWPORT_MAX_DIMENSION;
233
234/**
235 * A sized viewport with width and height swapped. Presets keep their identity
236 * through a rotation — `resolvePreviewViewport` already stores rotated presets
237 * as the preset id plus swapped dimensions — so a rotated iPad is still an
238 * iPad, not an anonymous custom size.
239 */
240const rotateViewport = (
241 viewport: Exclude<PreviewViewportSetting, { readonly _tag: "fill" }>,
242): PreviewViewportSetting => ({
243 ...viewport,
244 width: viewport.height,
245 height: viewport.width,
246});
247
248function BrowserViewportSetting({ disabled }: { readonly disabled: boolean }) {
249 const viewport = useClientSettings((settings) => settings.browserDefaultViewport);
250 const updateSettings = useUpdatePrimarySettings();
251
252 const sized = viewport._tag === "fill" ? null : viewport;
253 const presentedSize = {
254 width: sized?.width ?? RESPONSIVE_SEED_SIZE.width,
255 height: sized?.height ?? RESPONSIVE_SEED_SIZE.height,
256 };
257
258 const selectViewport = (value: string | null) => {
259 if (value === FILL_VALUE) {
260 updateSettings({ browserDefaultViewport: FILL_PREVIEW_VIEWPORT });
261 return;
262 }
263 if (value === RESPONSIVE_VALUE) {
264 updateSettings({
265 browserDefaultViewport: {
266 _tag: "freeform",
267 width: sized?.width ?? RESPONSIVE_SEED_SIZE.width,
268 height: sized?.height ?? RESPONSIVE_SEED_SIZE.height,
269 },
270 });
271 return;
272 }
273 const preset = PREVIEW_VIEWPORT_PRESETS.find((candidate) => candidate.id === value);
274 if (!preset) return;
275 updateSettings({
276 browserDefaultViewport: {
277 _tag: "preset",
278 width: preset.width,
279 height: preset.height,
280 presetId: preset.id,
281 },
282 });
283 };
284
285 // Committed on blur rather than per keystroke: typing "2560" passes through
286 // "256", which is a legal dimension, so an onValueChange handler would
287 // persist that intermediate size and churn the settings file on every key.
288 const commitDimension = (axis: "width" | "height", value: number | null) => {
289 if (value === null || !isValidDimension(value)) return;
290 const next = { ...presentedSize, [axis]: value };
291 if (next.width * next.height > PREVIEW_VIEWPORT_MAX_AREA) return;
292 if (sized && next.width === sized.width && next.height === sized.height) return;
293 // Typing a size means the preset no longer describes it.
294 updateSettings({ browserDefaultViewport: { _tag: "freeform", ...next } });
295 };
296
297 return (
298 <SettingsRow
299 {...searchableSetting("browser-default-viewport")}
300 description="Tab size for you and agents. Fill fits the panel; other sizes show the device toolbar."
301 resetAction={
302 !disabled && viewport._tag !== DEFAULT_BROWSER_VIEWPORT._tag ? (
303 <SettingResetButton
304 label="default browser viewport"
305 onClick={() => updateSettings({ browserDefaultViewport: DEFAULT_BROWSER_VIEWPORT })}
306 />
307 ) : null
308 }
309 control={
310 <div className="flex w-full flex-wrap items-center justify-end gap-2 sm:w-auto">
311 <Select
312 value={viewportSelectValue(viewport)}
313 onValueChange={selectViewport}
314 disabled={disabled}
315 >
316 <SelectTrigger
317 size="sm"
318 className="w-full min-w-0 sm:w-44"
319 aria-label="Default browser viewport"
320 >
321 <SelectValue>{viewportSelectLabel(viewport)}</SelectValue>
322 </SelectTrigger>
323 <SelectPopup align="end" alignItemWithTrigger={false}>
324 <SelectItem value={FILL_VALUE}>Fill panel</SelectItem>
325 <SelectItem value={RESPONSIVE_VALUE}>Responsive</SelectItem>
326 <SelectGroup>
327 <SelectGroupLabel>Standard</SelectGroupLabel>
328 {PREVIEW_VIEWPORT_PRESETS.map((preset) => (
329 <SelectItem key={preset.id} value={preset.id}>
330 <span className="flex w-full items-center justify-between gap-5">
331 <span>{preset.label}</span>
332 <span className="text-xs tabular-nums text-muted-foreground">
333 {preset.detail}
334 </span>
335 </span>
336 </SelectItem>
337 ))}
338 </SelectGroup>
339 </SelectPopup>
340 </Select>
341
342 {sized ? (
343 <div className="flex min-w-0 items-center gap-1">
344 <NumberField
345 value={presentedSize.width}
346 min={PREVIEW_VIEWPORT_MIN_DIMENSION}
347 max={PREVIEW_VIEWPORT_MAX_DIMENSION}
348 disabled={disabled}
349 // Pixel counts read as raw numbers; grouping would show "1,024".
350 format={NO_GROUPING}
351 size="sm"
352 className="w-20"
353 onValueCommitted={(value) => commitDimension("width", value)}
354 >
355 <NumberFieldGroup>
356 <NumberFieldInput aria-label="Default viewport width" />
357 </NumberFieldGroup>
358 </NumberField>
359 <span className="text-xs text-muted-foreground">×</span>
360 <NumberField
361 value={presentedSize.height}
362 min={PREVIEW_VIEWPORT_MIN_DIMENSION}
363 max={PREVIEW_VIEWPORT_MAX_DIMENSION}
364 disabled={disabled}
365 format={NO_GROUPING}
366 size="sm"
367 className="w-20"
368 onValueCommitted={(value) => commitDimension("height", value)}
369 >
370 <NumberFieldGroup>
371 <NumberFieldInput aria-label="Default viewport height" />
372 </NumberFieldGroup>
373 </NumberField>
374 <Tooltip>
375 <TooltipTrigger
376 render={
377 <Button
378 size="icon-sm"
379 variant="ghost-muted"
380 disabled={disabled}
381 aria-label={`Rotate to ${
382 presentedSize.height >= presentedSize.width ? "landscape" : "portrait"
383 }`}
384 onClick={() =>
385 updateSettings({ browserDefaultViewport: rotateViewport(sized) })
386 }
387 >
388 <ScreenRotationIcon />
389 </Button>
390 }
391 />
392 <TooltipPopup side="top">Rotate</TooltipPopup>
393 </Tooltip>
394 </div>
395 ) : null}
396 </div>
397 }
398 />
399 );
400}
401
402function BrowserZoomSetting({ disabled }: { readonly disabled: boolean }) {
403 const zoomFactor = useClientSettings((settings) => settings.browserDefaultZoomFactor);
404 const updateSettings = useUpdatePrimarySettings();
405
406 return (
407 <SettingsRow
408 {...searchableSetting("browser-default-zoom")}
409 description="Page zoom applied to new browser tabs."
410 resetAction={
411 !disabled && zoomFactor !== DEFAULT_PREVIEW_ZOOM_FACTOR ? (
412 <SettingResetButton
413 label="default browser zoom"
414 onClick={() =>
415 updateSettings({ browserDefaultZoomFactor: DEFAULT_PREVIEW_ZOOM_FACTOR })
416 }
417 />
418 ) : null
419 }
420 control={
421 <Select
422 disabled={disabled}
423 value={String(zoomFactor)}
424 onValueChange={(value) => {
425 const next = PREVIEW_ZOOM_LEVELS.find((level) => String(level) === value);
426 if (next !== undefined) updateSettings({ browserDefaultZoomFactor: next });
427 }}
428 >
429 <SelectTrigger size="sm" className="w-full sm:w-40" aria-label="Default browser zoom">
430 <SelectValue>{zoomLabel(zoomFactor)}</SelectValue>
431 </SelectTrigger>
432 <SelectPopup align="end" alignItemWithTrigger={false}>
433 {PREVIEW_ZOOM_LEVELS.map((level) => (
434 <SelectItem hideIndicator key={level} value={String(level)}>
435 {zoomLabel(level)}
436 </SelectItem>
437 ))}
438 </SelectPopup>
439 </Select>
440 }
441 />
442 );
443}
444
445function BrowserAppearanceSetting({ disabled }: { readonly disabled: boolean }) {
446 const appearance = useClientSettings((settings) => settings.browserDefaultAppearance);
447 const updateSettings = useUpdatePrimarySettings();
448
449 return (
450 <SettingsRow
451 {...searchableSetting("browser-default-appearance")}
452 description="The color scheme pages are told to prefer. System follows your OS setting."
453 resetAction={
454 !disabled && appearance !== DEFAULT_PREVIEW_APPEARANCE ? (
455 <SettingResetButton
456 label="default browser appearance"
457 onClick={() => updateSettings({ browserDefaultAppearance: DEFAULT_PREVIEW_APPEARANCE })}
458 />
459 ) : null
460 }
461 control={
462 <Select
463 disabled={disabled}
464 value={appearance}
465 onValueChange={(value) => {
466 if (value === "system" || value === "light" || value === "dark") {
467 updateSettings({ browserDefaultAppearance: value });
468 }
469 }}
470 >
471 <SelectTrigger
472 size="sm"
473 className="w-full sm:w-40"
474 aria-label="Default browser appearance"
475 >
476 <SelectValue>{APPEARANCE_LABELS[appearance]}</SelectValue>
477 </SelectTrigger>
478 <SelectPopup align="end" alignItemWithTrigger={false}>
479 {Object.entries(APPEARANCE_LABELS).map(([value, label]) => (
480 <SelectItem hideIndicator key={value} value={value}>
481 {label}
482 </SelectItem>
483 ))}
484 </SelectPopup>
485 </Select>
486 }
487 />
488 );
489}
490
491function BrowserRecordingInputSettings({ disabled }: { readonly disabled: boolean }) {
492 const showKeys = useClientSettings((settings) => settings.browserRecordingShowKeyPresses);
493 const showMouse = useClientSettings((settings) => settings.browserRecordingShowMousePresses);
494 const updateSettings = useUpdatePrimarySettings();
495 return (
496 <>
497 <SettingsRow
498 {...searchableSetting("browser-recording-key-presses")}
499 description="Show pressed keys and shortcuts in new recordings. Password fields are excluded."
500 control={
501 <Switch
502 disabled={disabled}
503 checked={showKeys}
504 aria-label="Show key presses in recordings"
505 onCheckedChange={(checked) =>
506 updateSettings({ browserRecordingShowKeyPresses: Boolean(checked) })
507 }
508 />
509 }
510 />
511 <SettingsRow
512 {...searchableSetting("browser-recording-mouse-presses")}
513 description="Highlight mouse presses and held buttons in new recordings."
514 control={
515 <Switch
516 disabled={disabled}
517 checked={showMouse}
518 aria-label="Show mouse presses in recordings"
519 onCheckedChange={(checked) =>
520 updateSettings({ browserRecordingShowMousePresses: Boolean(checked) })
521 }
522 />
523 }
524 />
525 </>
526 );
527}
528
529function BrowserRecordingFrameRateSetting({ disabled }: { readonly disabled: boolean }) {
530 const frameRate = useClientSettings((settings) => settings.browserRecordingFrameRate);
531 const updateSettings = useUpdatePrimarySettings();
532
533 return (
534 <SettingsRow
535 {...searchableSetting("browser-recording-frame-rate")}
536 description="Maximum recording rate. 30 fps saves CPU and storage; 60 fps is smoother."
537 resetAction={
538 !disabled && frameRate !== DEFAULT_BROWSER_RECORDING_FRAME_RATE ? (
539 <SettingResetButton
540 label="browser recording frame rate"
541 onClick={() =>
542 updateSettings({ browserRecordingFrameRate: DEFAULT_BROWSER_RECORDING_FRAME_RATE })
543 }
544 />
545 ) : null
546 }
547 control={
548 <Select
549 disabled={disabled}
550 value={String(frameRate)}
551 onValueChange={(value) => {
552 const next = BROWSER_RECORDING_FRAME_RATES.find((rate) => String(rate) === value);
553 if (next !== undefined) {
554 updateSettings({ browserRecordingFrameRate: next });
555 }
556 }}
557 >
558 <SelectTrigger
559 size="sm"
560 className="w-full sm:w-40"
561 aria-label="Browser recording frame rate"
562 >
563 <SelectValue>{frameRate} fps</SelectValue>
564 </SelectTrigger>
565 <SelectPopup align="end" alignItemWithTrigger={false}>
566 {BROWSER_RECORDING_FRAME_RATES.map((rate) => (
567 <SelectItem hideIndicator key={rate} value={String(rate)}>
568 {rate} fps
569 </SelectItem>
570 ))}
571 </SelectPopup>
572 </Select>
573 }
574 />
575 );
576}
577
578const LINK_TARGET_LABELS: Readonly<Record<BrowserLinkTarget, string>> = {
579 system: "Your default browser",
580 app: "The Knife",
581};
582
583function BrowserLinkTargetSetting({ disabled }: { readonly disabled: boolean }) {
584 const linkTarget = useClientSettings((settings) => settings.browserLinkTarget);
585 const updateSettings = useUpdatePrimarySettings();
586
587 return (
588 <SettingsRow
589 {...searchableSetting("browser-link-target")}
590 description="Where links in the chat and terminal open. Hold ⌘ or Ctrl while clicking a link to open it in your default browser either way."
591 resetAction={
592 !disabled && linkTarget !== DEFAULT_BROWSER_LINK_TARGET ? (
593 <SettingResetButton
594 label="link target"
595 onClick={() => updateSettings({ browserLinkTarget: DEFAULT_BROWSER_LINK_TARGET })}
596 />
597 ) : null
598 }
599 control={
600 <Select
601 disabled={disabled}
602 value={linkTarget}
603 onValueChange={(value) => {
604 if (value === "system" || value === "app") {
605 updateSettings({ browserLinkTarget: value });
606 }
607 }}
608 >
609 <SelectTrigger size="sm" className="w-full sm:w-40" aria-label="Open links in">
610 <SelectValue>{LINK_TARGET_LABELS[linkTarget]}</SelectValue>
611 </SelectTrigger>
612 <SelectPopup align="end" alignItemWithTrigger={false}>
613 {(Object.keys(LINK_TARGET_LABELS) as ReadonlyArray<BrowserLinkTarget>).map((target) => (
614 <SelectItem hideIndicator key={target} value={target}>
615 {LINK_TARGET_LABELS[target]}
616 </SelectItem>
617 ))}
618 </SelectPopup>
619 </Select>
620 }
621 />
622 );
623}
624
625function DeviceIntegrationSettings() {
626 const { search, environment: selected } = useSettingsScope();
627 const settings = useScopedSettings();
628 const connected = selected?.connection.phase === "connected" && selected.serverConfig !== null;
629 const environmentId = connected ? selected.environmentId : null;
630
631 return (
632 <SettingsSection id="devices" title="Devices">
633 <DeviceIntegrationControls
634 key={`${environmentId}:${JSON.stringify(search)}`}
635 environmentId={environmentId}
636 enabled={settings.enableDeviceSupport}
637 agentAccessEnabled={settings.enableAgentDeviceAccess}
638 />
639 </SettingsSection>
640 );
641}
642
643function DeviceIntegrationControls({
644 environmentId,
645 enabled,
646 agentAccessEnabled,
647}: {
648 environmentId: EnvironmentId | null;
649 enabled: boolean;
650 agentAccessEnabled: boolean;
651}) {
652 const canConfigure = useEnvironmentScope(environmentId, AuthSettingsWriteScope);
653 const { state, loaded } = useDeviceState(environmentId);
654 const { scope, environments, connectedEnvironments } = useSettingsScope();
655 const updateSettings = useUpdateScopedSettings();
656 const projectScope = scope.kind === "project" || scope.kind === "checkout";
657 const anyHubEnabled = connectedEnvironments.some(
658 (environment) => environment.serverConfig?.settings.enableDeviceSupport,
659 );
660 const configure = useAtomCommand(deviceEnvironment.configure, { reportFailure: false });
661 const list = useAtomCommand(deviceEnvironment.list, { reportFailure: false });
662 const [pending, setPending] = useState<
663 "hub" | "check" | "agent" | "update-hub" | "update-agent" | null
664 >(null);
665 const busy = state.hostStatus === "installing" || state.hostStatus === "starting";
666 const localPlatformsUnavailable = state.hosts.some(
667 (host) => host.kind === "local" && !host.platforms.some((platform) => platform.available),
668 );
669 const [platformsRevealed, setPlatformsRevealed] = useState(false);
670 // Keep diagnostics visible through subsequent agent setup and refresh phases.
671 if (platformsRevealed && !enabled) setPlatformsRevealed(false);
672 if (
673 enabled &&
674 !platformsRevealed &&
675 (state.hostStatus === "ready" || localPlatformsUnavailable) &&
676 pending !== "hub"
677 ) {
678 setPlatformsRevealed(true);
679 }
680
681 const update = async (
682 kind: NonNullable<typeof pending>,
683 input: { enabled?: boolean; agentAccessEnabled?: boolean },
684 ) => {
685 if (!environmentId || !readEnvironmentScope(environmentId, AuthSettingsWriteScope)) return;
686 setPending(kind);
687 try {
688 const results = await Promise.allSettled(
689 environments.map(async (environment) => {
690 if (environment.connection.phase !== "connected" || !environment.serverConfig) {
691 throw new Error("Environment disconnected");
692 }
693 if (!readEnvironmentScope(environment.environmentId, AuthSettingsWriteScope)) {
694 throw new Error("This connection cannot change device settings.");
695 }
696 return configure({
697 environmentId: environment.environmentId,
698 input: { ...input, ...(input.enabled ? { onboardingCompleted: true } : {}) },
699 });
700 }),
701 );
702 const failed = environments.filter((_, index) => {
703 const result = results[index];
704 return result?.status !== "fulfilled" || result.value._tag === "Failure";
705 });
706 if (failed.length > 0) {
707 toastManager.add({
708 type: "error",
709 title: "Device settings not saved on all environments",
710 description: `Could not update ${failed.map((environment) => environment.label).join(", ")}.`,
711 });
712 }
713 } finally {
714 setPending(null);
715 }
716 };
717
718 const [updateError, setUpdateError] = useState<{ tool: "hub" | "agent"; message: string } | null>(
719 null,
720 );
721 const localTools = state.hosts.find((host) => host.kind === "local")?.tools;
722 const versionActions = (tool: "hub" | "agent") => {
723 const version = localTools?.[tool];
724 const needsUpdate = version && !version.installedVersions.includes(version.requiredVersion);
725 return (
726 <div className="space-y-2">
727 <div className="flex flex-wrap gap-2">
728 {state.supportsToolUpdate && needsUpdate ? (
729 <Button
730 size="sm"
731 disabled={!environmentId || pending !== null || busy}
732 onClick={() => {
733 if (!environmentId) return;
734 setUpdateError(null);
735 setPending(`update-${tool}`);
736 void list({ environmentId, input: { updateTool: tool } })
737 .then((result) => {
738 if (result._tag === "Failure")
739 setUpdateError({
740 tool,
741 message:
742 "Update failed. Check this host's network connection and try again.",
743 });
744 })
745 .finally(() => setPending(null));
746 }}
747 >
748 {pending === `update-${tool}` ? "Updating…" : `Update to v${version.requiredVersion}`}
749 </Button>
750 ) : null}
751 {state.supportsToolInspection ? (
752 <Button
753 size="sm"
754 variant="outline"
755 disabled={!environmentId || pending !== null || busy}
756 onClick={() => {
757 if (!environmentId) return;
758 setPending("check");
759 void list({ environmentId, input: { inspectOnly: true } }).finally(() =>
760 setPending(null),
761 );
762 }}
763 >
764 {pending === "check" ? "Checking…" : "Check versions"}
765 </Button>
766 ) : null}
767 </div>
768 {updateError?.tool === tool ? (
769 <p role="alert" className="text-xs text-destructive">
770 {updateError.message}
771 </p>
772 ) : null}
773 </div>
774 );
775 };
776
777 return (
778 <>
779 <SettingsRow
780 {...searchableSetting("device-hub")}
781 serverScoped
782 settingKeys={["enableDeviceSupport"]}
783 description={deviceHubDescription}
784 control={
785 <>
786 <DeviceToolVersions
787 action={versionActions("hub")}
788 kind="hub"
789 tools={state.hosts.find((host) => host.kind === "local")?.tools}
790 />
791 {pending === "hub" ? <DeviceHubSetupStatus state={state} pending compact /> : null}
792 <ScopedSwitch
793 settingKeys={["enableDeviceSupport"]}
794 checked={enabled}
795 disabled={
796 !canConfigure ||
797 projectScope ||
798 !loaded ||
799 !environmentId ||
800 busy ||
801 pending !== null
802 }
803 aria-label="Device hub"
804 onCheckedChange={(checked) =>
805 void update("hub", {
806 enabled: Boolean(checked),
807 ...(checked ? {} : { agentAccessEnabled: false }),
808 })
809 }
810 />
811 </>
812 }
813 />
814 <AnimatedHeight>
815 {platformsRevealed ? (
816 <SettingsRow
817 {...searchableSetting("device-platform-support")}
818 description={
819 connectedEnvironments.length > 1
820 ? `Status for ${connectedEnvironments.find((environment) => environment.environmentId === environmentId)?.label}. Select an environment to inspect its simulator support.`
821 : undefined
822 }
823 status={
824 <div className="flex flex-wrap gap-x-5 gap-y-2">
825 <PlatformStatus compact platform="iOS" status={platformSetupStatus(state, "ios")} />
826 <PlatformStatus
827 compact
828 platform="Android"
829 status={platformSetupStatus(state, "android")}
830 />
831 </div>
832 }
833 control={
834 <Button
835 size="sm"
836 variant="outline"
837 disabled={!environmentId || !enabled || busy || pending !== null}
838 onClick={() => {
839 if (!environmentId) return;
840 setPending("check");
841 void list({ environmentId, input: {} }).finally(() => setPending(null));
842 }}
843 >
844 {pending === "check" ? "Checking…" : "Refresh"}
845 </Button>
846 }
847 />
848 ) : null}
849 </AnimatedHeight>
850 <SettingsRow
851 {...searchableSetting("agent-device-access")}
852 serverScoped
853 settingKeys={["enableAgentDeviceAccess"]}
854 description={agentDeviceDescription}
855 control={
856 <>
857 <DeviceToolVersions
858 action={versionActions("agent")}
859 kind="agent"
860 tools={state.hosts.find((host) => host.kind === "local")?.tools}
861 />
862 {pending === "agent" ? <AgentDeviceSetupStatus state={state} pending compact /> : null}
863 <ScopedSwitch
864 settingKeys={["enableAgentDeviceAccess"]}
865 checked={agentAccessEnabled}
866 disabled={
867 !canConfigure ||
868 connectedEnvironments.length === 0 ||
869 (!projectScope && (!loaded || !anyHubEnabled || busy)) ||
870 pending !== null
871 }
872 aria-label="Agent device access"
873 onCheckedChange={(checked) =>
874 projectScope
875 ? updateSettings({ enableAgentDeviceAccess: Boolean(checked) })
876 : void update("agent", { agentAccessEnabled: Boolean(checked) })
877 }
878 />
879 </>
880 }
881 />
882 {environmentId ? (
883 <DeviceHostUpdates
884 state={{ ...state, hosts: state.hosts.filter((host) => host.kind === "local") }}
885 environmentId={environmentId}
886 />
887 ) : null}
888 <DeviceHostsSettings environmentId={environmentId} />
889 </>
890 );
891}
892
893function BrowserAutoShowFloatingPreviewSetting({ disabled }: { readonly disabled: boolean }) {
894 const autoShow = useClientSettings((settings) => settings.browserAutoShowFloatingPreview);
895 const updateSettings = useUpdatePrimarySettings();
896
897 return (
898 <SettingsRow
899 {...searchableSetting("browser-auto-show-floating-preview")}
900 description="Show the floating preview when an agent opens a browser or device unless the agent says otherwise."
901 resetAction={
902 !disabled && autoShow !== DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW ? (
903 <SettingResetButton
904 label="auto-show floating preview"
905 onClick={() =>
906 updateSettings({
907 browserAutoShowFloatingPreview: DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW,
908 })
909 }
910 />
911 ) : null
912 }
913 control={
914 <Switch
915 disabled={disabled}
916 checked={autoShow}
917 onCheckedChange={(checked) =>
918 updateSettings({ browserAutoShowFloatingPreview: Boolean(checked) })
919 }
920 aria-label="Auto-show floating preview"
921 />
922 }
923 />
924 );
925}
926
927/**
928 * Profile list, its header menu, and the import flow.
929 *
930 * One menu creates profiles and imports into them, because the two are the
931 * same decision from the user's side: "I want a profile that has my Helium
932 * logins in it". Import targets include "New profile" so that case does not
933 * require creating one first and then finding a second control.
934 *
935 * Built-ins render without a rename field: they are synthesized rather than
936 * stored, so there is nothing to rename and removing them would strand every
937 * tab that opened under them.
938 *
939 * Sources are listed lazily on open: detection touches the other browser's
940 * files, and the answer changes while the app is running (quitting the browser
941 * clears `browserRunning`), so a value cached at mount would go stale.
942 */
943function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
944 const userProfiles = useClientSettings((settings) => settings.browserProfiles);
945 const defaultProfileId = useClientSettings((settings) => settings.browserDefaultProfileId);
946 const settingsHydrated = useClientSettingsHydrated();
947 const updateSettings = useUpdatePrimarySettings();
948 const { environments, isReady: environmentsReady } = useEnvironments();
949 const primaryEnvironment = usePrimaryEnvironment();
950 const [sources, setSources] = useState<ReadonlyArray<BrowserImportSource> | null>(null);
951 const [importSession, setImportSession] = useState<{
952 readonly source: BrowserImportSource;
953 readonly environmentId: EnvironmentId;
954 readonly environmentName: string;
955 } | null>(null);
956 const [profilePendingRemoval, setProfilePendingRemoval] = useState<BrowserProfile | null>(null);
957 const [profilePendingClear, setProfilePendingClear] = useState<BrowserProfile | null>(null);
958 const [profileRemovalError, setProfileRemovalError] = useState<string | null>(null);
959 const [profileRemovalInFlight, setProfileRemovalInFlight] = useState(false);
960 const serverConfigs = useServerConfigs();
961 const runClearServerProfile = useAtomCommand(previewEnvironment.clearProfile, {
962 reportFailure: false,
963 });
964 const serverProfileData: ServerProfileData = {
965 environmentIds: environments
966 .map((environment) => environment.environmentId)
967 .filter(
968 (environmentId) =>
969 serverConfigs.get(environmentId)?.environment.capabilities.serverBrowser === true,
970 ),
971 clear: async (environmentId, profileId) => {
972 const result = await runClearServerProfile({ environmentId, input: { profileId } });
973 if (result._tag === "Failure") throw squashAtomCommandFailure(result);
974 },
975 };
976 const removalAvailable = browserProfileRemovalAvailable(
977 previewBridge !== null,
978 environmentsReady,
979 environments.length,
980 serverProfileData.environmentIds.length,
981 );
982 const importInFlightRef = useRef(false);
983 const [importInFlight, setImportInFlight] = useState(false);
984 const profileWritesDisabled = disabled || !settingsHydrated;
985
986 const profiles = resolveBrowserProfiles(userProfiles);
987 // Incognito is deliberately not a row — it holds nothing to manage — so the
988 // default has to resolve against the list that renders. A stored
989 // `browserDefaultProfileId` of "incognito" would otherwise leave the section
990 // with no Default badge at all.
991 const listedProfiles = profiles.filter((profile) => profile.kind !== "incognito");
992 const resolvedDefaultId =
993 findBrowserProfile(listedProfiles, defaultProfileId)?.id ?? DEFAULT_BROWSER_PROFILE_ID;
994
995 const createProfile = (baseName: string) => {
996 if (!settingsHydrated || importInFlightRef.current) return undefined;
997 const currentProfiles = getClientSettings().browserProfiles;
998 // Checked against the live settings, not the rendered list: two clicks
999 // before a re-render would otherwise both pass the disabled control.
1000 if (currentProfiles.length >= BROWSER_PROFILE_MAX_COUNT) return undefined;
1001 const resolvedProfiles = resolveBrowserProfiles(currentProfiles);
1002 const taken = new Set(resolvedProfiles.map((profile) => profile.name));
1003 let name = baseName;
1004 for (let index = 2; taken.has(name); index += 1) name = `${baseName} ${index}`;
1005 const profile = { id: `profile-${randomUUID()}`, name, kind: "persistent" as const };
1006 updateSettings({ browserProfiles: [...currentProfiles, profile] });
1007 return profile;
1008 };
1009
1010 const renameProfile = (id: string, next: string) => {
1011 if (!settingsHydrated || importInFlightRef.current) return;
1012 const name = next.trim().slice(0, BROWSER_PROFILE_NAME_MAX_LENGTH);
1013 if (name === "") return;
1014 const currentProfiles = getClientSettings().browserProfiles;
1015 updateSettings({
1016 browserProfiles: currentProfiles.map((profile) =>
1017 profile.id === id ? { ...profile, name } : profile,
1018 ),
1019 });
1020 };
1021
1022 const clearProfileData = (id: string, name: string) => {
1023 if (!settingsHydrated || importInFlightRef.current) return;
1024 if (!removalAvailable) {
1025 toastManager.add({
1026 type: "error",
1027 title: `Could not clear ${name}'s data`,
1028 description: "You're not connected to a server yet.",
1029 });
1030 return;
1031 }
1032 void clearBrowserProfileData(
1033 previewBridge,
1034 environments.map((environment) => environment.environmentId),
1035 id,
1036 serverProfileData,
1037 )
1038 .then(() => {
1039 toastManager.add({ type: "success", title: `Cleared ${name}'s cookies and cache` });
1040 })
1041 .catch(() => {
1042 toastManager.add({ type: "error", title: `Could not clear ${name}'s data` });
1043 });
1044 };
1045
1046 const removeProfile = async (id: string) => {
1047 if (!settingsHydrated || importInFlightRef.current) return;
1048 if (!removalAvailable) {
1049 setProfileRemovalError("Connect to an environment before removing this profile.");
1050 return;
1051 }
1052 setProfileRemovalError(null);
1053 setProfileRemovalInFlight(true);
1054 // Drop the partition's data too, otherwise a removed profile's cookies
1055 // stay on disk with nothing in the UI pointing at them.
1056 try {
1057 await clearBrowserProfileData(
1058 previewBridge,
1059 environmentsReady ? environments.map((environment) => environment.environmentId) : [],
1060 id,
1061 serverProfileData,
1062 );
1063 } catch {
1064 setProfileRemovalError("Profile data could not be deleted. Try again.");
1065 setProfileRemovalInFlight(false);
1066 return;
1067 }
1068 const currentSettings = getClientSettings();
1069 updateSettings({
1070 browserProfiles: currentSettings.browserProfiles.filter((profile) => profile.id !== id),
1071 // Reassign the default rather than leaving it pointing at nothing.
1072 ...(currentSettings.browserDefaultProfileId === id
1073 ? { browserDefaultProfileId: DEFAULT_BROWSER_PROFILE_ID }
1074 : {}),
1075 });
1076 setProfileRemovalInFlight(false);
1077 setProfilePendingRemoval(null);
1078 };
1079
1080 // A browser that is not on this machine is left out rather than listed as a
1081 // dead row: there is nothing to act on, and the menu is a list of things you
1082 // can import from. An unsupported one is left out for the same reason — the
1083 // blocked wizard step can't be fixed from here. Every other unavailable
1084 // reason stays, since each names a step the user can take.
1085 const importableSources = (sources ?? []).filter(
1086 (source) =>
1087 source.unavailable !== "notInstalled" && source.unavailable !== "unsupportedPlatform",
1088 );
1089
1090 // Refreshed without blanking the last result: the menu shows the cached list
1091 // straight away so it doesn't reflow on open, and the source list is stable
1092 // (names only) since choosing what to import happens in the wizard, not here.
1093 const loadSources = useCallback(() => {
1094 if (!previewBridge) return;
1095 void previewBridge
1096 .listBrowserImportSources()
1097 .then(setSources)
1098 .catch(() => setSources((previous) => previous ?? []));
1099 }, []);
1100
1101 // Runs one import for the wizard. A new profile is registered only once the
1102 // import succeeds — the cookies land in its partition first — so a blocked
1103 // attempt never leaves an empty profile behind.
1104 const runWizardImport = async (
1105 source: BrowserImportSource,
1106 environmentId: EnvironmentId,
1107 input: { readonly sourceProfileDirectory: string; readonly target: WizardTarget },
1108 ): Promise<ImportOutcome> => {
1109 if (!previewBridge) return { kind: "blocked", reason: "sessionUnavailable" };
1110 if (!settingsHydrated) return { kind: "blocked", reason: "sessionUnavailable" };
1111 if (
1112 input.target.kind === "existing" &&
1113 !resolveBrowserProfiles(getClientSettings().browserProfiles).some(
1114 (profile) => profile.id === input.target.profileId,
1115 )
1116 ) {
1117 return { kind: "blocked", reason: "readFailed" };
1118 }
1119 if (importInFlightRef.current) return { kind: "blocked", reason: "readFailed" };
1120 importInFlightRef.current = true;
1121 setImportInFlight(true);
1122 try {
1123 const result = await previewBridge.importBrowserCookies({
1124 environmentId,
1125 sourceId: source.id,
1126 sourceProfileDirectory: input.sourceProfileDirectory,
1127 targetProfileId: input.target.profileId,
1128 });
1129 if (
1130 input.target.kind === "existing" &&
1131 !resolveBrowserProfiles(getClientSettings().browserProfiles).some(
1132 (profile) => profile.id === input.target.profileId,
1133 )
1134 ) {
1135 return { kind: "blocked", reason: "readFailed" };
1136 }
1137 let targetName: string;
1138 if (input.target.kind === "new") {
1139 // Registered only when something actually came over: an import that
1140 // found no cookies should not leave a new, empty profile behind.
1141 if (result.imported > 0) {
1142 try {
1143 const persisted = await persistClientSettingsUpdate((current) => {
1144 const existing = current.browserProfiles.find(
1145 (profile) => profile.id === input.target.profileId,
1146 );
1147 if (existing) return current;
1148 // The wizard refuses a new target at the cap, but the cap can be
1149 // reached while the import runs; the updater sees the newest
1150 // settings, so this is the check that holds.
1151 if (current.browserProfiles.length >= BROWSER_PROFILE_MAX_COUNT) {
1152 throw new ProfileLimitReachedError();
1153 }
1154 const taken = new Set(
1155 resolveBrowserProfiles(current.browserProfiles).map((profile) => profile.name),
1156 );
1157 let name = source.name;
1158 for (let index = 2; taken.has(name); index += 1) name = `${source.name} ${index}`;
1159 return {
1160 ...current,
1161 browserProfiles: [
1162 ...current.browserProfiles,
1163 { id: input.target.profileId, name, kind: "persistent" as const },
1164 ],
1165 };
1166 });
1167 targetName =
1168 persisted.browserProfiles.find((profile) => profile.id === input.target.profileId)
1169 ?.name ?? source.name;
1170 } catch (cause) {
1171 // This target id belongs only to the attempted new profile. Clear
1172 // its partition so a failed registration cannot strand imported
1173 // cookies behind a profile that disappears on restart.
1174 await clearBrowserProfileData(
1175 previewBridge,
1176 [environmentId],
1177 input.target.profileId,
1178 ).catch(() => undefined);
1179 // Not a read failure: the cookies came over and were cleared again
1180 // because the profile could not be kept. Name that, in the same
1181 // token form `importFailureReason` recovers from a bridge error.
1182 const reason =
1183 cause instanceof ProfileLimitReachedError ? "profileLimitReached" : "profileNotSaved";
1184 throw new Error(`Importing cookies from ${source.id} failed: ${reason}.`, { cause });
1185 }
1186 } else {
1187 targetName = source.name;
1188 }
1189 } else {
1190 targetName = input.target.name;
1191 }
1192 return {
1193 kind: "imported",
1194 imported: result.imported,
1195 skipped: result.skipped,
1196 skippedDomains: result.skippedDomains,
1197 targetName,
1198 };
1199 } catch (cause) {
1200 return { kind: "blocked", reason: importFailureReason(cause) };
1201 } finally {
1202 importInFlightRef.current = false;
1203 setImportInFlight(false);
1204 }
1205 };
1206
1207 // Re-checks a source's availability after the user quits the browser, and
1208 // keeps the cached list in step so the menu reflects it too.
1209 const refreshImportSource = async (
1210 sourceId: BrowserImportSource["id"],
1211 ): Promise<BrowserImportSource | undefined> => {
1212 if (!previewBridge) return undefined;
1213 try {
1214 const latest = await previewBridge.listBrowserImportSources();
1215 setSources(latest);
1216 return latest.find((source) => source.id === sourceId);
1217 } catch {
1218 return undefined;
1219 }
1220 };
1221
1222 const atProfileLimit = userProfiles.length >= BROWSER_PROFILE_MAX_COUNT;
1223
1224 return (
1225 <SettingsRow
1226 {...searchableSetting("browser-profiles")}
1227 description="Profiles separate cookies and logins. Incognito data is cleared when the app closes."
1228 control={
1229 <Menu onOpenChange={(open) => open && loadSources()}>
1230 <MenuTrigger
1231 render={
1232 <Button
1233 size="sm"
1234 variant="outline"
1235 disabled={profileWritesDisabled || importInFlight}
1236 />
1237 }
1238 >
1239 <PlusIcon />
1240 Add profile
1241 </MenuTrigger>
1242 <MenuPopup align="end">
1243 <MenuItem
1244 disabled={!settingsHydrated || atProfileLimit}
1245 onClick={() => createProfile("New profile")}
1246 >
1247 Blank profile
1248 </MenuItem>
1249 {atProfileLimit ? (
1250 <MenuItem disabled>You&rsquo;ve reached the profile limit</MenuItem>
1251 ) : null}
1252 <MenuSeparator />
1253 <MenuGroup>
1254 <MenuGroupLabel>Import from</MenuGroupLabel>
1255 {sources === null ? (
1256 <MenuItem disabled>Looking for browsers…</MenuItem>
1257 ) : importableSources.length === 0 ? (
1258 <MenuItem disabled>No supported browsers found</MenuItem>
1259 ) : (
1260 // Every source is a plain row — running, needs-permission and
1261 // ready all look the same here. The wizard picks up whatever
1262 // state the source is in and walks the user forward from there.
1263 <>
1264 {importableSources.map((source) => (
1265 <MenuItem
1266 key={source.id}
1267 disabled={!settingsHydrated || primaryEnvironment == null}
1268 onClick={() => {
1269 if (!settingsHydrated || primaryEnvironment == null) return;
1270 setImportSession({
1271 source,
1272 environmentId: primaryEnvironment.environmentId,
1273 environmentName: resolveEnvironmentOptionLabel({
1274 isPrimary: true,
1275 environmentId: primaryEnvironment.environmentId,
1276 runtimeLabel: primaryEnvironment.label,
1277 }),
1278 });
1279 }}
1280 >
1281 {source.name}
1282 </MenuItem>
1283 ))}
1284 {primaryEnvironment == null ? (
1285 <MenuItem disabled>Connect to an environment to import cookies</MenuItem>
1286 ) : null}
1287 </>
1288 )}
1289 </MenuGroup>
1290 </MenuPopup>
1291 </Menu>
1292 }
1293 >
1294 {/*
1295 The bordered container groups rows unambiguously at any width, and
1296 carries the bottom spacing `SettingsRow` leaves to its children
1297 (`pt-3 pb-1`).
1298 */}
1299 <div className="mt-2 mb-2 overflow-hidden rounded-lg border border-border/60">
1300 {listedProfiles.map((profile, index) => {
1301 const builtIn = isBuiltInBrowserProfileId(profile.id);
1302 const isDefault = profile.id === resolvedDefaultId;
1303 return (
1304 <div
1305 key={profile.id}
1306 className={cn(
1307 "flex items-center gap-3 px-3 py-2",
1308 index > 0 && "border-t border-border/60",
1309 )}
1310 >
1311 <span className="flex min-w-0 flex-1 items-center gap-2">
1312 {builtIn ? (
1313 // Dimmed here rather than on the table: a wrapper-level dim
1314 // stacks with the rename field's and the row menu button's
1315 // own, landing them near 0.41 while every other disabled
1316 // control in the block sits at 0.64.
1317 <span
1318 className={cn(
1319 "truncate text-sm text-foreground",
1320 profileWritesDisabled && "opacity-64",
1321 )}
1322 >
1323 {profile.name}
1324 </span>
1325 ) : (
1326 <DraftInput
1327 nativeInput
1328 size="sm"
1329 className="w-full max-w-56"
1330 aria-label={`Rename ${profile.name}`}
1331 disabled={profileWritesDisabled || importInFlight}
1332 maxLength={BROWSER_PROFILE_NAME_MAX_LENGTH}
1333 value={profile.name}
1334 onCommit={(next) => renameProfile(profile.id, next)}
1335 />
1336 )}
1337 {/* Dimmed with the rest of the row, whose controls are all disabled. */}
1338 {isDefault ? (
1339 <span className={cn("flex", profileWritesDisabled && "opacity-64")}>
1340 <Badge>Default</Badge>
1341 </span>
1342 ) : null}
1343 </span>
1344 <Menu>
1345 <MenuTrigger
1346 render={
1347 <Button
1348 size="icon-xs"
1349 variant="ghost-muted"
1350 disabled={profileWritesDisabled || importInFlight}
1351 aria-label={`${profile.name} options`}
1352 />
1353 }
1354 >
1355 <MoreVertical />
1356 </MenuTrigger>
1357 <MenuPopup align="end">
1358 <MenuItem
1359 disabled={!settingsHydrated || isDefault}
1360 onClick={() => {
1361 if (settingsHydrated) {
1362 updateSettings({ browserDefaultProfileId: profile.id });
1363 }
1364 }}
1365 >
1366 Set as default
1367 </MenuItem>
1368 <MenuItem
1369 disabled={!settingsHydrated || !removalAvailable}
1370 onClick={() => {
1371 // Clearing a server copy closes its open tabs, so it asks first.
1372 if (serverProfileData.environmentIds.length > 0) {
1373 setProfilePendingClear(profile);
1374 } else clearProfileData(profile.id, profile.name);
1375 }}
1376 >
1377 Clear cookies and cache
1378 </MenuItem>
1379 {builtIn ? null : (
1380 <MenuItem
1381 variant="destructive"
1382 disabled={!settingsHydrated || !removalAvailable}
1383 onClick={() => {
1384 if (settingsHydrated) setProfilePendingRemoval(profile);
1385 }}
1386 >
1387 Remove profile and data
1388 </MenuItem>
1389 )}
1390 {!removalAvailable ? (
1391 <>
1392 <MenuSeparator />
1393 <MenuItem disabled>
1394 {environmentsReady
1395 ? "Connect to an environment to clear profile data"
1396 : "Checking environments…"}
1397 </MenuItem>
1398 </>
1399 ) : null}
1400 </MenuPopup>
1401 </Menu>
1402 </div>
1403 );
1404 })}
1405 </div>
1406 <AlertDialog
1407 open={profilePendingRemoval !== null}
1408 onOpenChange={(open) => {
1409 if (!open && !profileRemovalInFlight) {
1410 setProfilePendingRemoval(null);
1411 setProfileRemovalError(null);
1412 }
1413 }}
1414 >
1415 <AlertDialogPopup>
1416 <AlertDialogHeader>
1417 <AlertDialogTitle>Remove “{profilePendingRemoval?.name}”?</AlertDialogTitle>
1418 <AlertDialogDescription>
1419 Its cookies and logins are deleted. Desktop tabs already open in this profile stay
1420 open until you close them; server browser tabs close now.
1421 </AlertDialogDescription>
1422 {profileRemovalError ? (
1423 <p aria-live="polite" className="text-sm text-destructive">
1424 {profileRemovalError}
1425 </p>
1426 ) : null}
1427 {!removalAvailable ? (
1428 <p className="text-sm text-muted-foreground">
1429 Connect to an environment to remove this profile and its data.
1430 </p>
1431 ) : null}
1432 </AlertDialogHeader>
1433 <AlertDialogFooter>
1434 <AlertDialogClose
1435 disabled={profileRemovalInFlight}
1436 render={<Button variant="outline" disabled={profileRemovalInFlight} />}
1437 >
1438 Cancel
1439 </AlertDialogClose>
1440 <Button
1441 variant="destructive"
1442 disabled={profileRemovalInFlight || !settingsHydrated || !removalAvailable}
1443 onClick={() => {
1444 if (profilePendingRemoval && settingsHydrated && removalAvailable) {
1445 void removeProfile(profilePendingRemoval.id);
1446 }
1447 }}
1448 >
1449 {profileRemovalInFlight ? "Removing…" : "Remove profile"}
1450 </Button>
1451 </AlertDialogFooter>
1452 </AlertDialogPopup>
1453 </AlertDialog>
1454 <AlertDialog
1455 open={profilePendingClear !== null}
1456 onOpenChange={(open) => {
1457 if (!open) setProfilePendingClear(null);
1458 }}
1459 >
1460 <AlertDialogPopup>
1461 <AlertDialogHeader>
1462 <AlertDialogTitle>
1463 Clear “{profilePendingClear?.name}”’s cookies and cache?
1464 </AlertDialogTitle>
1465 <AlertDialogDescription>
1466 You are signed out of its sites. Server browser tabs open in this profile close now.
1467 </AlertDialogDescription>
1468 </AlertDialogHeader>
1469 <AlertDialogFooter>
1470 <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>
1471 <Button
1472 variant="destructive"
1473 onClick={() => {
1474 if (profilePendingClear)
1475 clearProfileData(profilePendingClear.id, profilePendingClear.name);
1476 setProfilePendingClear(null);
1477 }}
1478 >
1479 Clear data
1480 </Button>
1481 </AlertDialogFooter>
1482 </AlertDialogPopup>
1483 </AlertDialog>
1484 {importSession ? (
1485 <BrowserImportWizard
1486 source={importSession.source}
1487 destinationEnvironmentName={importSession.environmentName}
1488 targetProfiles={listedProfiles.map((profile) => ({ id: profile.id, name: profile.name }))}
1489 canCreateProfile={settingsHydrated && !atProfileLimit}
1490 onImport={(input) =>
1491 runWizardImport(importSession.source, importSession.environmentId, input)
1492 }
1493 onRefreshSource={() => refreshImportSource(importSession.source.id)}
1494 onCheckFullDiskAccess={
1495 window.desktopBridge?.checkSystemPermission
1496 ? () => window.desktopBridge!.checkSystemPermission!("full-disk-access")
1497 : undefined
1498 }
1499 onOpenFullDiskAccessSettings={async () => {
1500 // Rejects outside the desktop shell (and on shells that predate the
1501 // method), so the one toast covers every way the link can fail.
1502 await readLocalApi()
1503 ?.shell.openSystemSettings("full-disk-access")
1504 .catch(() => {
1505 toastManager.add({
1506 type: "error",
1507 title: "Could not open System Settings",
1508 description: "Open Privacy & Security → Full Disk Access manually.",
1509 });
1510 });
1511 }}
1512 onClose={() => setImportSession(null)}
1513 />
1514 ) : null}
1515 </SettingsRow>
1516 );
1517}
1518
1519export function IntegrationsSettingsPanel() {
1520 // Client-local preview defaults are editable only where the preview exists.
1521 const previewDefaultsDisabled = !isElectron;
1522 const previewDefaults = (
1523 <>
1524 <BrowserProfilesSetting disabled={previewDefaultsDisabled} />
1525 <BrowserViewportSetting disabled={previewDefaultsDisabled} />
1526 <BrowserZoomSetting disabled={previewDefaultsDisabled} />
1527 <BrowserAppearanceSetting disabled={previewDefaultsDisabled} />
1528 <BrowserRecordingFrameRateSetting disabled={previewDefaultsDisabled} />
1529 <BrowserRecordingInputSettings disabled={previewDefaultsDisabled} />
1530 <BrowserLinkTargetSetting disabled={previewDefaultsDisabled} />
1531 <BrowserAutoShowFloatingPreviewSetting disabled={previewDefaultsDisabled} />
1532 </>
1533 );
1534
1535 return (
1536 <SettingsPageContainer>
1537 {/* Server-authoritative agent access is scoped by the header selection;
1538 the preview defaults below are device-local and ignore it. */}
1539 <ProjectDefaultsSettings category="integrations" />
1540 <SettingsSection id="browser" title="Browser">
1541 {previewDefaultsDisabled ? (
1542 <SettingsUnavailableGroup message="Only available in the desktop app.">
1543 {previewDefaults}
1544 </SettingsUnavailableGroup>
1545 ) : (
1546 previewDefaults
1547 )}
1548 </SettingsSection>
1549 <DeviceIntegrationSettings />
1550 </SettingsPageContainer>
1551 );
1552}