Home

mweinbach / agent-coworker

publicmweinbach/agent-coworker
Overview Code History Branches Pull requestsIssuesInsights
main
HomeOverview Code PRsIssues
mweinbach/agent-coworker/apps/desktop/src/ui/settings/SettingsShell.tsx
Raw
1import {2  ActivityIcon,3  ArchiveRestoreIcon,4  ArrowLeftIcon,5  BarChart3Icon,6  BotIcon,7  FlaskConicalIcon,8  HistoryIcon,9  type LucideIcon,10  MonitorIcon,11  PanelLeftIcon,12
RefreshCcwIcon,
13 ShieldCheckIcon,
14 SlidersHorizontalIcon,
15 UserRoundCogIcon,
16 UsersRoundIcon,
17 WifiIcon,
18 WrenchIcon,
19} from "lucide-react";
20import { type CSSProperties, type ReactNode, useCallback, useEffect, useState } from "react";
21import { includeDevelopmentSettings } from "../../app/settingsPageAvailability";
22import { useAppStore } from "../../app/store";
23import type { SettingsPageId } from "../../app/types";
24import { Button } from "../../components/ui/button";
25import { isPackagedDesktopApp } from "../../lib/desktopCommands";
26import { type DesktopPlatformInfo, getDesktopPlatformInfo } from "../../lib/desktopPlatform";
27import { onDesktopRailCommand } from "../../lib/desktopRailCommands";
28import { useAdaptiveLayout } from "../../lib/useAdaptiveLayout";
29import { cn } from "../../lib/utils";
30import { InlineErrorBoundary } from "../CrashReportingErrorBoundary";
31import { AdaptiveRailSurface } from "../layout/AdaptiveRailSurface";
32import { BackupPage } from "./pages/BackupPage";
33import { DesktopPage } from "./pages/DesktopPage";
34import { DeveloperPage } from "./pages/DeveloperPage";
35import { FeatureFlagsPage } from "./pages/FeatureFlagsPage";
36import { PrivacyTelemetryPage } from "./pages/PrivacyTelemetryPage";
37import { RemoteAccessPage } from "./pages/RemoteAccessPage";
38import {
39 ChatsSettingsPage,
40 DefaultsSettingsPage,
41 ModelsSettingsPage,
42 ProfileMemorySettingsPage,
43 ToolAccessSettingsPage,
44} from "./pages/SettingsIntentPages";
45import { SubagentsPage } from "./pages/SubagentsPage";
46import { UpdatesPage } from "./pages/UpdatesPage";
47import { UsagePage } from "./pages/UsagePage";
48import { SettingsChromeProvider, type SettingsChromeState } from "./SettingsChromeContext";
49 
50type SettingsPageDefinition = {
51 id: SettingsPageId;
52 label: string;
53 icon: LucideIcon;
54 render: () => ReactNode;
55};
56 
57const SETTINGS_PAGE_META: Record<SettingsPageId, { title: string; description: string }> = {
58 models: {
59 title: "Models",
60 description: "Provider health, model defaults, and subagent routing.",
61 },
62 subagents: {
63 title: "Subagents",
64 description: "Specialized child-agent profiles and scoped tool access.",
65 },
66 toolAccess: {
67 title: "Tool Access",
68 description: "Connect external tools and data sources.",
69 },
70 desktop: {
71 title: "Desktop",
72 description: "Menu bar, tray, and quick chat controls for the desktop app.",
73 },
74 defaults: {
75 title: "Behavior",
76 description: "Defaults for models, tools, and behavior everywhere.",
77 },
78 profileMemory: {
79 title: "Profile & Memory",
80 description: "How Cowork should understand you and what it should remember.",
81 },
82 remoteAccess: {
83 title: "Remote access",
84 description: "Pair a phone to this folder or chat over the relay when the app is open.",
85 },
86 backup: {
87 title: "Backups",
88 description: "Recovery snapshots and restore points for chat sessions.",
89 },
90 chats: {
91 title: "Chats",
92 description: "Archived chat history, restore actions, and retention.",
93 },
94 usage: {
95 title: "Usage",
96 description: "Token usage and estimated cost across sessions.",
97 },
98 privacyTelemetry: {
99 title: "Privacy & Telemetry",
100 description: "Optional crash reports, product analytics, and AI trace consent.",
101 },
102 experiments: {
103 title: "Experiments",
104 description: "Enable or disable experimental capabilities.",
105 },
106 diagnostics: {
107 title: "Diagnostics",
108 description: "Debug visibility, runtime checks, and advanced output handling.",
109 },
110 updates: {
111 title: "Updates",
112 description: "App version and restart-based updates.",
113 },
114 providers: {
115 title: "Models",
116 description: "Provider health, model defaults, and subagent routing.",
117 },
118 openAiNativeConnectors: {
119 title: "Tool Access",
120 description: "Connect external tools and data sources.",
121 },
122 mcp: {
123 title: "Tool Access",
124 description: "Connect external tools and data sources.",
125 },
126 workspaces: {
127 title: "Behavior",
128 description: "Defaults for models, tools, and behavior everywhere.",
129 },
130 memory: {
131 title: "Profile & Memory",
132 description: "How Cowork should understand you and what it should remember.",
133 },
134 featureFlags: {
135 title: "Experiments",
136 description: "Enable or disable experimental capabilities.",
137 },
138 developer: {
139 title: "Diagnostics",
140 description: "Debug visibility, runtime checks, and advanced output handling.",
141 },
142 archivedChats: {
143 title: "Chats",
144 description: "Archived chat history, restore actions, and retention.",
145 },
146};
147 
148// Legacy settings page ids that no longer appear in the nav but may still
149// arrive via deep links or persisted state. Map them to their canonical id so
150// lookups land on the right page instead of silently falling back to "models".
151export const SETTINGS_PAGE_ALIASES: Partial<Record<SettingsPageId, SettingsPageId>> = {
152 providers: "models",
153 openAiNativeConnectors: "toolAccess",
154 mcp: "toolAccess",
155 workspaces: "defaults",
156 memory: "profileMemory",
157 featureFlags: "experiments",
158 developer: "diagnostics",
159 archivedChats: "chats",
160};
161 
162export function getSettingsGroups(
163 remoteAccessAvailable: boolean,
164 opts: { includeDevelopmentPages?: boolean } = {},
165): Array<{
166 label: string;
167 pages: SettingsPageDefinition[];
168}> {
169 const includeDevelopmentPages = opts.includeDevelopmentPages ?? true;
170 return [
171 {
172 label: "Models & tools",
173 pages: [
174 { id: "models", label: "Models", icon: BotIcon, render: () => <ModelsSettingsPage /> },
175 {
176 id: "subagents",
177 label: "Subagents",
178 icon: UsersRoundIcon,
179 render: () => <SubagentsPage />,
180 },
181 {
182 id: "toolAccess",
183 label: "Tool Access",
184 icon: WrenchIcon,
185 render: () => <ToolAccessSettingsPage />,
186 },
187 ],
188 },
189 {
190 label: "Workspace",
191 pages: [
192 {
193 id: "defaults",
194 label: "Behavior",
195 icon: SlidersHorizontalIcon,
196 render: () => <DefaultsSettingsPage />,
197 },
198 {
199 id: "profileMemory",
200 label: "Profile & Memory",
201 icon: UserRoundCogIcon,
202 render: () => <ProfileMemorySettingsPage />,
203 },
204 ...(remoteAccessAvailable
205 ? [
206 {
207 id: "remoteAccess",
208 label: "Remote access",
209 icon: WifiIcon,
210 render: () => <RemoteAccessPage />,
211 } satisfies SettingsPageDefinition,
212 ]
213 : []),
214 ],
215 },
216 {
217 label: "History & Data",
218 pages: [
219 { id: "backup", label: "Backups", icon: ArchiveRestoreIcon, render: () => <BackupPage /> },
220 { id: "chats", label: "Chats", icon: HistoryIcon, render: () => <ChatsSettingsPage /> },
221 { id: "usage", label: "Usage", icon: BarChart3Icon, render: () => <UsagePage /> },
222 ],
223 },
224 {
225 label: "App",
226 pages: [
227 {
228 id: "privacyTelemetry",
229 label: "Privacy & Telemetry",
230 icon: ShieldCheckIcon,
231 render: () => <PrivacyTelemetryPage />,
232 },
233 { id: "desktop", label: "Desktop", icon: MonitorIcon, render: () => <DesktopPage /> },
234 { id: "updates", label: "Updates", icon: RefreshCcwIcon, render: () => <UpdatesPage /> },
235 ],
236 },
237 {
238 label: "Advanced",
239 pages: [
240 ...(includeDevelopmentPages
241 ? [
242 {
243 id: "experiments",
244 label: "Experiments",
245 icon: FlaskConicalIcon,
246 render: () => <FeatureFlagsPage />,
247 } satisfies SettingsPageDefinition,
248 ]
249 : []),
250 {
251 id: "diagnostics",
252 label: "Diagnostics",
253 icon: ActivityIcon,
254 render: () => <DeveloperPage />,
255 },
256 ],
257 },
258 ];
259}
260 
261export function getSettingsDragZoneStyle(
262 sidebarWidth: number,
263 platformInfo: DesktopPlatformInfo,
264): CSSProperties | undefined {
265 if (platformInfo.sidebarTitlebandMode === "native") {
266 return { "--settings-sidebar-width": `${sidebarWidth}px` } as CSSProperties;
267 }
268 return undefined;
269}
270 
271function SettingsNavigation({
272 activePage,
273 onSelectPage,
274 onBack,
275 settingsGroups,
276}: {
277 activePage: SettingsPageId;
278 onSelectPage: (page: SettingsPageId) => void;
279 onBack: () => void;
280 settingsGroups: Array<{
281 label: string;
282 pages: SettingsPageDefinition[];
283 }>;
284}) {
285 const currentWorkspace = useAppStore((s) =>
286 s.workspaces.find((w) => w.id === s.selectedWorkspaceId),
287 );
288 const perWorkspaceSettings = useAppStore((s) => s.perWorkspaceSettings);
289 
290 return (
291 <>
292 <div className="shrink-0 settings-shell__nav-header border-b border-border/50">
293 <div className="settings-shell__nav-titleband">
294 <div className="settings-shell__nav-titleband-drag-zone" aria-hidden="true" />
295 <div className="settings-shell__nav-titleband-row px-3 flex items-center">
296 <button
297 className="settings-shell__back-button flex h-9 w-full items-center justify-start gap-2 rounded-md px-2.5 text-left text-[13px] font-medium app-text-secondary transition-colors duration-150 hover:bg-foreground/[0.045] hover:text-foreground"
298 type="button"
299 onClick={onBack}
300 >
301 <ArrowLeftIcon className="h-4 w-4 shrink-0" />
302 Back
303 </button>
304 </div>
305 </div>
306 {perWorkspaceSettings && currentWorkspace ? (
307 <div
308 className="app-type-caption truncate px-4 pb-3 app-text-secondary"
309 title={currentWorkspace.name}
310 >
311 {currentWorkspace.name}
312 </div>
313 ) : null}
314 </div>
315 
316 <nav
317 className="min-h-0 flex-1 overflow-y-auto px-2.5 py-2 pb-4"
318 aria-label="Settings sections"
319 >
320 <div className="flex flex-col gap-3">
321 {settingsGroups.map((group) => (
322 <div key={group.label} className="flex min-w-0 flex-col">
323 <div className="app-type-label mb-1 px-2 py-1.5 uppercase tracking-[0.08em] app-text-muted">
324 {group.label}
325 </div>
326 <div className="flex flex-col gap-0.5">
327 {group.pages.map((page) => {
328 const Icon = page.icon;
329 return (
330 <button
331 key={page.id}
332 className={cn(
333 "settings-shell__nav-button flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-left text-[13px] transition-colors duration-150",
334 activePage === page.id
335 ? "settings-shell__nav-button--active font-semibold text-foreground"
336 : "font-medium app-text-secondary hover:bg-foreground/[0.045] hover:text-foreground",
337 )}
338 type="button"
339 onClick={() => onSelectPage(page.id)}
340 >
341 <Icon
342 className={cn(
343 "size-4 shrink-0",
344 activePage === page.id
345 ? "text-[var(--text-settings-nav-active-icon)]"
346 : "text-muted-foreground",
347 )}
348 />
349 <span className="min-w-0 truncate">{page.label}</span>
350 </button>
351 );
352 })}
353 </div>
354 </div>
355 ))}
356 </div>
357 </nav>
358 </>
359 );
360}
361 
362export function SettingsShell() {
363 const desktopFeatureFlags = useAppStore((s) => s.desktopFeatureFlags);
364 const remoteAccessAvailable = desktopFeatureFlags.remoteAccess === true;
365 const packaged = useAppStore((s) => s.updateState.packaged);
366 const settingsPage = useAppStore((s) => s.settingsPage);
367 const setSettingsPage = useAppStore((s) => s.setSettingsPage);
368 const closeSettings = useAppStore((s) => s.closeSettings);
369 const sidebarWidth = useAppStore((s) => s.sidebarWidth);
370 const [navigationOpen, setNavigationOpen] = useState(false);
371 const adaptiveLayout = useAdaptiveLayout({
372 contextSidebarCollapsed: true,
373 hasContextSidebar: false,
374 leftSidebarWidth: sidebarWidth,
375 rightSidebarMaximumWidth: 0,
376 rightSidebarMinimumWidth: 0,
377 rightSidebarWidth: 0,
378 sidebarCollapsed: false,
379 });
380 const effectiveSidebarWidth = adaptiveLayout.leftOverlay
381 ? Math.max(160, Math.min(440, sidebarWidth))
382 : adaptiveLayout.leftWidth;
383 const navigationActive =
384 adaptiveLayout.leftInline || (adaptiveLayout.leftOverlay && navigationOpen);
385 useEffect(
386 () =>
387 onDesktopRailCommand((command) => {
388 if (command !== "toggle-sidebar") return;
389 if (adaptiveLayout.leftOverlay) {
390 setNavigationOpen((open) => !open);
391 }
392 }),
393 [adaptiveLayout.leftOverlay],
394 );
395 const settingsGroups = getSettingsGroups(remoteAccessAvailable, {
396 includeDevelopmentPages: includeDevelopmentSettings(packaged || isPackagedDesktopApp()),
397 });
398 const settingsPages = settingsGroups.flatMap((group) => group.pages);
399 const resolvedSettingsPage = SETTINGS_PAGE_ALIASES[settingsPage] ?? settingsPage;
400 const activePage =
401 settingsPages.find((page) => page.id === resolvedSettingsPage) ?? settingsPages[0];
402 const meta = SETTINGS_PAGE_META[activePage.id];
403 
404 const [pageChrome, setPageChromeState] = useState<SettingsChromeState>({});
405 const handleChromeChange = useCallback((next: SettingsChromeState) => {
406 setPageChromeState(next);
407 }, []);
408 const handleSelectPage = useCallback(
409 (page: SettingsPageId) => {
410 setSettingsPage(page);
411 if (adaptiveLayout.leftOverlay) {
412 setNavigationOpen(false);
413 }
414 },
415 [adaptiveLayout.leftOverlay, setSettingsPage],
416 );
417 const platformInfo = getDesktopPlatformInfo();
418 const settingsDragZoneStyle = getSettingsDragZoneStyle(
419 adaptiveLayout.leftInline ? adaptiveLayout.leftWidth : 0,
420 platformInfo,
421 );
422 const isMacos = platformInfo.platform === "macos";
423 
424 const isBackupPage = activePage.id === "backup";
425 const navigationToggle = adaptiveLayout.leftOverlay ? (
426 <Button
427 aria-expanded={navigationOpen}
428 aria-label={navigationOpen ? "Close settings navigation" : "Open settings navigation"}
429 className="app-native-no-drag shrink-0"
430 onClick={() => setNavigationOpen((open) => !open)}
431 size="icon-sm"
432 type="button"
433 variant="ghost"
434 >
435 <PanelLeftIcon aria-hidden="true" />
436 </Button>
437 ) : null;
438 
439 return (
440 <div
441 className="settings-shell relative grid h-full min-h-0 min-w-0 bg-transparent"
442 data-layout-tier={adaptiveLayout.tier}
443 style={
444 {
445 "--settings-sidebar-width": `${effectiveSidebarWidth}px`,
446 gridTemplateColumns: adaptiveLayout.leftInline
447 ? `${adaptiveLayout.leftWidth}px minmax(0, 1fr)`
448 : "minmax(0, 1fr)",
449 } as CSSProperties
450 }
451 >
452 {platformInfo.sidebarTitlebandMode === "native" ? (
453 <div
454 className="settings-shell__titleband-fill absolute inset-x-0 top-0"
455 style={settingsDragZoneStyle}
456 aria-hidden="true"
457 />
458 ) : null}
459 <div
460 className="settings-shell__drag-zone absolute inset-x-0 top-0"
461 style={settingsDragZoneStyle}
462 aria-hidden="true"
463 />
464 <AdaptiveRailSurface
465 active={navigationActive}
466 className="settings-shell__nav app-left-sidebar-pane flex h-full min-h-0 min-w-0 flex-col border-r border-border/50"
467 label="Settings navigation"
468 onClose={() => setNavigationOpen(false)}
469 overlay={adaptiveLayout.leftOverlay}
470 side="left"
471 width={effectiveSidebarWidth}
472 >
473 <SettingsNavigation
474 activePage={activePage.id}
475 onSelectPage={handleSelectPage}
476 onBack={closeSettings}
477 settingsGroups={settingsGroups}
478 />
479 </AdaptiveRailSurface>
480 
481 <main className="settings-shell__main app-main-content flex min-h-0 min-w-0 flex-col">
482 <SettingsChromeProvider onChromeChange={handleChromeChange}>
483 <div
484 className={cn(
485 "flex min-h-0 min-w-0 flex-1 flex-col",
486 isBackupPage ? "overflow-hidden" : "",
487 )}
488 >
489 <header
490 className={cn(
491 "settings-shell__page-header shrink-0 backdrop-blur-sm",
492 isMacos
493 ? isBackupPage
494 ? ""
495 : "sticky top-0 z-10"
496 : cn(
497 "px-5 max-[719px]:px-4",
498 isBackupPage ? "pb-3 pt-4" : "sticky top-0 z-10 py-4",
499 ),
500 )}
501 >
502 {isMacos ? (
503 <>
504 <div className="settings-shell__page-titleband">
505 <div className="settings-shell__page-titleband-row flex flex-col gap-3 px-5 max-[719px]:px-4 sm:flex-row sm:items-end sm:justify-between">
506 <div className="flex min-w-0 items-center gap-2">
507 {navigationToggle}
508 <h1 className="min-w-0 text-xl font-semibold tracking-tight text-foreground">
509 {meta.title}
510 </h1>
511 </div>
512 {pageChrome.headerActions ? (
513 <div className="settings-shell__header-actions flex shrink-0 flex-wrap items-center justify-end gap-2">
514 {pageChrome.headerActions}
515 </div>
516 ) : null}
517 </div>
518 </div>
519 <div
520 className={cn(
521 "settings-shell__page-intro px-5 max-[719px]:px-4",
522 isBackupPage ? "pb-3 pt-3" : "pb-4 pt-3",
523 )}
524 >
525 <p className="max-w-xl text-sm leading-relaxed text-muted-foreground">
526 {meta.description}
527 </p>
528 </div>
529 </>
530 ) : (
531 <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
532 <div className="min-w-0 space-y-1">
533 <div className="flex min-w-0 items-center gap-2">
534 {navigationToggle}
535 <h1 className="min-w-0 text-xl font-semibold tracking-tight text-foreground">
536 {meta.title}
537 </h1>
538 </div>
539 <p className="max-w-xl text-sm leading-relaxed text-muted-foreground">
540 {meta.description}
541 </p>
542 </div>
543 {pageChrome.headerActions ? (
544 <div className="settings-shell__header-actions flex shrink-0 flex-wrap items-center justify-end gap-2 sm:pt-0.5">
545 {pageChrome.headerActions}
546 </div>
547 ) : null}
548 </div>
549 )}
550 </header>
551 
552 <div className="settings-shell__scroll min-h-0 min-w-0 flex-1 overflow-y-auto">
553 <div
554 className={cn(
555 "settings-shell__content w-full",
556 isBackupPage
557 ? "flex min-h-0 flex-1 flex-col max-[719px]:px-4 max-[719px]:pb-4 min-[720px]:px-5 min-[720px]:pb-6"
558 : "max-[719px]:p-4 min-[720px]:px-5 min-[720px]:pb-6 min-[720px]:pt-4",
559 )}
560 >
561 <div
562 data-settings-page={activePage.id}
563 className={cn(isBackupPage ? "flex min-h-0 flex-1 flex-col" : "")}
564 >
565 <InlineErrorBoundary label="This settings page couldn't be rendered.">
566 {activePage.render()}
567 </InlineErrorBoundary>
568 </div>
569 </div>
570 </div>
571 </div>
572 </SettingsChromeProvider>
573 </main>
574 </div>
575 );
576}
577