aidevops 3.29.22 → 3.29.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,6 +11,7 @@ import { FileExplorerSurface } from "./FileExplorerSurface";
11
11
  import { AppsSurface, EditableInventorySurface, InstallationSurface } from "./InventorySurfaces";
12
12
  import { AiProvidersSurface, LocalReposSurface, LockedVaultGate, OverviewSurface, PlannedSurface, ProjectsSurface, SecuritySurface, VaultSurface } from "./StatusSurfaces";
13
13
  import { isVaultSurfaceLocked, vaultCollectionForSurface } from "./VaultBadges";
14
+ import { applyCommandPaletteSelection, useHeaderMenuState } from "./workspace-header-state";
14
15
 
15
16
  const communityLinks = {
16
17
  github: "https://github.com/marcusquinn/aidevops",
@@ -64,11 +65,9 @@ function WorkspaceHeader({ activeItem, activeSectionLabel, activeSurface, canGoB
64
65
  setShellMode: (mode: ShellMode) => void;
65
66
  status: GuiStatusData;
66
67
  }): ReactElement {
67
- const [assistantOpen, setAssistantOpen] = useState(false);
68
68
  const [commandInitialQuery, setCommandInitialQuery] = useState("");
69
69
  const [commandOpen, setCommandOpen] = useState(false);
70
- const [notificationsOpen, setNotificationsOpen] = useState(false);
71
- const [profileOpen, setProfileOpen] = useState(false);
70
+ const headerMenus = useHeaderMenuState();
72
71
  const userInitials = status.machine.initials || "AI";
73
72
  const userName = status.machine.username || "Local user";
74
73
  const activeNotifications = status.notifications.filter((notification) => notification.status === "active");
@@ -89,24 +88,17 @@ function WorkspaceHeader({ activeItem, activeSectionLabel, activeSurface, canGoB
89
88
  return () => window.removeEventListener("keydown", openCommandPalette);
90
89
  }, [goBack, goForward]);
91
90
 
92
- const closeMenus = () => {
93
- setNotificationsOpen(false);
94
- setProfileOpen(false);
95
- };
96
-
97
- const openItem = ({ conversationMode, repoPathRef, sessionId, shellMode, surface }: CommandPaletteSelection) => {
98
- const repoIndex = status.local_repos.repos.findIndex((repo) => repo.path_ref === repoPathRef);
99
- setShellMode(shellMode ?? "devices");
100
- if (repoIndex >= 0) {
101
- setSelectedLocalRepoIndex(repoIndex);
102
- }
103
- setSelectedSessionId(sessionId);
104
- if (conversationMode) {
105
- setConversationMode(conversationMode);
106
- }
107
- setActiveSurface(surface);
108
- closeMenus();
109
- setCommandOpen(false);
91
+ const openItem = (selection: CommandPaletteSelection) => {
92
+ applyCommandPaletteSelection(selection, {
93
+ closeCommandPalette: () => setCommandOpen(false),
94
+ closeHeaderMenus: headerMenus.closeHeaderMenus,
95
+ setActiveSurface,
96
+ setConversationMode,
97
+ setSelectedLocalRepoIndex,
98
+ setSelectedSessionId,
99
+ setShellMode,
100
+ status,
101
+ });
110
102
  };
111
103
 
112
104
  return (
@@ -129,28 +121,28 @@ function WorkspaceHeader({ activeItem, activeSectionLabel, activeSurface, canGoB
129
121
  <strong>{text.searchPlaceholder}</strong>
130
122
  </button>
131
123
  </div>
132
- <div className="header-actions">
124
+ <div className="header-actions" onPointerEnter={headerMenus.clearScheduledHeaderClose} onPointerLeave={headerMenus.scheduleHeaderMenusClose} ref={headerMenus.headerActionsRef}>
133
125
  <div className="header-action-menu">
134
- <button aria-label="Open signposts and help" className="header-icon-button" onClick={() => openItem({ surface: "help" })} title="Signposts and help (?)" type="button">
126
+ <button aria-label="Open signposts and help" className="header-icon-button" onClick={() => { headerMenus.closeHeaderMenus(); openItem({ surface: "help" }); }} title="Signposts and help (?)" type="button">
135
127
  <FiHelpCircle aria-hidden="true" />
136
128
  </button>
137
- <button aria-expanded={notificationsOpen} aria-label="Open notifications" className="header-icon-button" onClick={() => { setNotificationsOpen((current) => !current); setProfileOpen(false); }} type="button">
129
+ <button aria-expanded={headerMenus.notificationsOpen} aria-label="Open notifications" className="header-icon-button" onClick={headerMenus.toggleNotifications} type="button">
138
130
  <FiBell aria-hidden="true" />
139
131
  {activeNotifications.length > 0 ? <span className="notification-dot" aria-hidden="true" /> : null}
140
132
  </button>
141
- {notificationsOpen ? <NotificationsMenu notifications={status.notifications} openSurface={(surface) => openItem({ surface })} /> : null}
133
+ {headerMenus.notificationsOpen ? <NotificationsMenu notifications={status.notifications} openSurface={(surface) => openItem({ surface })} /> : null}
142
134
  </div>
143
- <button aria-pressed={assistantOpen} aria-label="Toggle AI Assistant" className={assistantOpen ? "header-icon-button active" : "header-icon-button"} onClick={() => setAssistantOpen((current) => !current)} title="AI sessions (_)" type="button">
135
+ <button aria-pressed={headerMenus.assistantOpen} aria-label="Toggle AI Assistant" className={headerMenus.assistantOpen ? "header-icon-button active" : "header-icon-button"} onClick={headerMenus.toggleAssistant} title="AI sessions (_)" type="button">
144
136
  <FiTerminal aria-hidden="true" />
145
137
  </button>
146
138
  <div className="header-action-menu">
147
- <button aria-expanded={profileOpen} aria-label={`Open profile menu for ${userName}`} className="profile-avatar-button" onClick={() => { setProfileOpen((current) => !current); setNotificationsOpen(false); }} title={userName} type="button">
139
+ <button aria-expanded={headerMenus.profileOpen} aria-label={`Open profile menu for ${userName}`} className="profile-avatar-button" onClick={headerMenus.toggleProfile} title={userName} type="button">
148
140
  <span>{userInitials}</span>
149
141
  </button>
150
- {profileOpen ? <ProfileMenu openSurface={(surface) => openItem({ surface })} userName={userName} /> : null}
142
+ {headerMenus.profileOpen ? <ProfileMenu openSurface={(surface) => openItem({ surface })} userName={userName} /> : null}
151
143
  </div>
152
144
  </div>
153
- {assistantOpen ? <AssistantPanel activeSurface={activeSurface} userName={userName} /> : null}
145
+ {headerMenus.assistantOpen ? <AssistantPanel activeSurface={activeSurface} userName={userName} /> : null}
154
146
  {commandOpen ? <CommandPalette activeSurface={activeSurface} close={() => setCommandOpen(false)} initialQuery={commandInitialQuery} openItem={openItem} status={status} /> : null}
155
147
  </header>
156
148
  );
@@ -0,0 +1,244 @@
1
+ import { type ReactElement, useEffect, useState } from "react";
2
+ import { FiChevronDown, FiChevronLeft, FiChevronRight, FiChevronUp, FiRotateCcw } from "react-icons/fi";
3
+ import type { ContrastPreference, FontPreference, FontSizePreference, ThemePreference } from "./app-model";
4
+ import { DEFAULT_ACCENT_HUE, contrastOptions, fontFamilyForPreference, fontOptions, fontSizeOptions, text } from "./app-model";
5
+
6
+ export function hueFromInputValue(value: string): number | null {
7
+ const trimmedValue = value.trim();
8
+
9
+ if (trimmedValue.length === 0) {
10
+ return null;
11
+ }
12
+
13
+ const hue = Number(trimmedValue);
14
+
15
+ if (!Number.isInteger(hue)) {
16
+ return null;
17
+ }
18
+
19
+ return Math.min(359, Math.max(0, hue));
20
+ }
21
+
22
+ export function SidebarFooter(props: SidebarFooterProps): ReactElement {
23
+ const [appearanceOpen, setAppearanceOpen] = useState(true);
24
+ const AppearanceChevron = appearanceOpen ? FiChevronDown : FiChevronUp;
25
+
26
+ return (
27
+ <footer className="sidebar-footer">
28
+ <section className={appearanceOpen ? "appearance-panel open" : "appearance-panel collapsed"}>
29
+ <button
30
+ aria-expanded={appearanceOpen}
31
+ className="appearance-panel-tab"
32
+ onClick={() => setAppearanceOpen((current) => !current)}
33
+ type="button"
34
+ >
35
+ {text.appearance}
36
+ <AppearanceChevron aria-hidden="true" className="appearance-chevron" />
37
+ </button>
38
+ {appearanceOpen ? <AppearancePanelBody {...props} /> : null}
39
+ </section>
40
+ </footer>
41
+ );
42
+ }
43
+
44
+ interface SidebarFooterProps {
45
+ accentHue: number;
46
+ contrastPreference: ContrastPreference;
47
+ fontPreference: FontPreference;
48
+ fontSizePreference: FontSizePreference;
49
+ setAccentHue: (hue: number) => void;
50
+ setContrastPreference: (contrast: ContrastPreference) => void;
51
+ setFontPreference: (font: FontPreference) => void;
52
+ setFontSizePreference: (size: FontSizePreference) => void;
53
+ setShowBorders: (show: boolean) => void;
54
+ setShowNavCounts: (show: boolean) => void;
55
+ setThemePreference: (theme: ThemePreference) => void;
56
+ showBorders: boolean;
57
+ showNavCounts: boolean;
58
+ themePreference: ThemePreference;
59
+ }
60
+
61
+ function AppearancePanelBody({ accentHue, contrastPreference, fontPreference, fontSizePreference, setAccentHue, setContrastPreference, setFontPreference, setFontSizePreference, setShowBorders, setShowNavCounts, setThemePreference, showBorders, showNavCounts, themePreference }: SidebarFooterProps): ReactElement {
62
+ return (
63
+ <div className="appearance-panel-body">
64
+ <ThemeControl setThemePreference={setThemePreference} themePreference={themePreference} />
65
+ <HueControl accentHue={accentHue} setAccentHue={setAccentHue} />
66
+ <ContrastControl contrastPreference={contrastPreference} setContrastPreference={setContrastPreference} />
67
+ <AppearanceSwitches setShowBorders={setShowBorders} setShowNavCounts={setShowNavCounts} showBorders={showBorders} showNavCounts={showNavCounts} />
68
+ <FontSizeControl fontSizePreference={fontSizePreference} setFontSizePreference={setFontSizePreference} />
69
+ <FontControl fontPreference={fontPreference} setFontPreference={setFontPreference} />
70
+ </div>
71
+ );
72
+ }
73
+
74
+ function ThemeControl({ setThemePreference, themePreference }: { setThemePreference: (theme: ThemePreference) => void; themePreference: ThemePreference }): ReactElement {
75
+ return (
76
+ <fieldset className="theme-control compact" aria-label={text.theme}>
77
+ {(["system", "light", "dark"] as const).map((theme) => (
78
+ <button
79
+ aria-pressed={themePreference === theme}
80
+ className={themePreference === theme ? "theme-option active" : "theme-option"}
81
+ key={theme}
82
+ onClick={() => setThemePreference(theme)}
83
+ type="button"
84
+ >
85
+ {theme}
86
+ </button>
87
+ ))}
88
+ </fieldset>
89
+ );
90
+ }
91
+
92
+ function HueControl({ accentHue, setAccentHue }: { accentHue: number; setAccentHue: (hue: number) => void }): ReactElement {
93
+ const [hueInput, setHueInput] = useState(() => String(accentHue));
94
+ const updateAccentHue = (value: number) => {
95
+ if (Number.isFinite(value)) {
96
+ setAccentHue(Math.min(359, Math.max(0, value)));
97
+ }
98
+ };
99
+ const updateHueInput = (value: string) => {
100
+ setHueInput(value);
101
+ const nextHue = hueFromInputValue(value);
102
+ if (nextHue !== null) {
103
+ updateAccentHue(nextHue);
104
+ }
105
+ };
106
+
107
+ useEffect(() => {
108
+ setHueInput(String(accentHue));
109
+ }, [accentHue]);
110
+
111
+ return (
112
+ <div className="theme-hue-control">
113
+ <div className="theme-control-heading">
114
+ <div className="hue-label-row">
115
+ <label htmlFor="theme-hue-slider">{text.hue}</label>
116
+ <input
117
+ aria-label="Hue value"
118
+ className="hue-number-input"
119
+ max="359"
120
+ min="0"
121
+ onChange={(event) => updateHueInput(event.currentTarget.value)}
122
+ type="number"
123
+ value={hueInput}
124
+ />
125
+ </div>
126
+ <button aria-label="Reset hue to default" className="icon-reset-button" onClick={() => setAccentHue(DEFAULT_ACCENT_HUE)} title={text.reset} type="button"><FiRotateCcw aria-hidden="true" /></button>
127
+ </div>
128
+ <input
129
+ id="theme-hue-slider"
130
+ max="359"
131
+ min="0"
132
+ onChange={(event) => updateAccentHue(Number.parseInt(event.currentTarget.value, 10))}
133
+ type="range"
134
+ value={accentHue}
135
+ />
136
+ </div>
137
+ );
138
+ }
139
+
140
+ function ContrastControl({ contrastPreference, setContrastPreference }: { contrastPreference: ContrastPreference; setContrastPreference: (contrast: ContrastPreference) => void }): ReactElement {
141
+ return (
142
+ <fieldset className="contrast-control" aria-label={text.contrast}>
143
+ <legend>{text.contrast}</legend>
144
+ <div className="contrast-options">
145
+ {contrastOptions.map((option) => (
146
+ <button
147
+ aria-pressed={contrastPreference === option.value}
148
+ className={contrastPreference === option.value ? "active" : ""}
149
+ key={option.value}
150
+ onClick={() => setContrastPreference(option.value)}
151
+ type="button"
152
+ >
153
+ {option.label}
154
+ </button>
155
+ ))}
156
+ </div>
157
+ </fieldset>
158
+ );
159
+ }
160
+
161
+ function AppearanceSwitches({ setShowBorders, setShowNavCounts, showBorders, showNavCounts }: { setShowBorders: (show: boolean) => void; setShowNavCounts: (show: boolean) => void; showBorders: boolean; showNavCounts: boolean }): ReactElement {
162
+ return (
163
+ <>
164
+ <label className="switch-control appearance-switch">
165
+ <strong>{text.showBorders}</strong>
166
+ <input checked={showBorders} onChange={(event) => setShowBorders(event.currentTarget.checked)} type="checkbox" />
167
+ <span aria-hidden="true" />
168
+ </label>
169
+ <label className="switch-control appearance-switch">
170
+ <strong>{text.showCounts}</strong>
171
+ <input checked={showNavCounts} onChange={(event) => setShowNavCounts(event.currentTarget.checked)} type="checkbox" />
172
+ <span aria-hidden="true" />
173
+ </label>
174
+ </>
175
+ );
176
+ }
177
+
178
+ function FontSizeControl({ fontSizePreference, setFontSizePreference }: { fontSizePreference: FontSizePreference; setFontSizePreference: (size: FontSizePreference) => void }): ReactElement {
179
+ const fontSizeIndex = Math.max(0, fontSizeOptions.findIndex((option) => option.value === fontSizePreference));
180
+
181
+ return (
182
+ <div className="font-size-control">
183
+ <label htmlFor="font-size-slider">{text.fontSize}</label>
184
+ <input
185
+ id="font-size-slider"
186
+ max={fontSizeOptions.length - 1}
187
+ min="0"
188
+ onChange={(event) => setFontSizePreference(fontSizeOptions[Number.parseInt(event.currentTarget.value, 10)]?.value ?? "xs")}
189
+ step="1"
190
+ type="range"
191
+ value={fontSizeIndex}
192
+ />
193
+ <fieldset className="range-labels">
194
+ <legend className="sr-only">Font size shortcuts</legend>
195
+ {fontSizeOptions.map((option) => (
196
+ <button
197
+ aria-pressed={option.value === fontSizePreference}
198
+ className={option.value === fontSizePreference ? "active" : ""}
199
+ key={option.value}
200
+ onClick={() => setFontSizePreference(option.value)}
201
+ type="button"
202
+ >
203
+ {option.label}
204
+ </button>
205
+ ))}
206
+ </fieldset>
207
+ </div>
208
+ );
209
+ }
210
+
211
+ function FontControl({ fontPreference, setFontPreference }: { fontPreference: FontPreference; setFontPreference: (font: FontPreference) => void }): ReactElement {
212
+ const selectedFontFamily = fontFamilyForPreference(fontPreference);
213
+ const fontIndex = Math.max(0, fontOptions.findIndex((option) => option.value === fontPreference));
214
+ const canSelectPreviousFont = fontIndex > 0;
215
+ const canSelectNextFont = fontIndex < fontOptions.length - 1;
216
+ const setFontByIndex = (index: number) => {
217
+ const nextFont = fontOptions[index]?.value;
218
+ if (nextFont !== undefined) {
219
+ setFontPreference(nextFont);
220
+ }
221
+ };
222
+
223
+ return (
224
+ <div className="font-control">
225
+ <span id="appearance-font-selector-label">{text.font}</span>
226
+ <div className="selector-with-stepper font-selector-row">
227
+ <button aria-label="Previous font" className="selector-step-button" disabled={!canSelectPreviousFont} onClick={() => setFontByIndex(fontIndex - 1)} type="button"><FiChevronLeft aria-hidden="true" /></button>
228
+ <select
229
+ aria-labelledby="appearance-font-selector-label"
230
+ onChange={(event) => setFontPreference(event.currentTarget.value as FontPreference)}
231
+ style={{ fontFamily: selectedFontFamily }}
232
+ value={fontPreference}
233
+ >
234
+ {fontOptions.map((option) => (
235
+ <option key={option.value} style={{ fontFamily: option.fontFamily }} value={option.value}>
236
+ {option.label}
237
+ </option>
238
+ ))}
239
+ </select>
240
+ <button aria-label="Next font" className="selector-step-button" disabled={!canSelectNextFont} onClick={() => setFontByIndex(fontIndex + 1)} type="button"><FiChevronRight aria-hidden="true" /></button>
241
+ </div>
242
+ </div>
243
+ );
244
+ }
@@ -6,7 +6,7 @@ import { conversationThreads } from "./conversation-fixtures";
6
6
  export function CommsConversationSurface({ mode, threads = conversationThreads }: { mode: "channels" | "directMessages" | "people"; threads?: GuiConversationThread[] }) {
7
7
  const availableThreads = threads ?? [];
8
8
  const conversations = mode === "directMessages" ? availableThreads.filter((thread) => thread.conversation.type === "dm" || thread.conversation.type === "group_dm") : availableThreads.filter((thread) => thread.conversation.type === "channel");
9
- const selectedThread = conversations[0] ?? availableThreads[0];
9
+ const selectedThread = conversations[0];
10
10
  const partsByMessage = new Map<string, GuiConversationMessagePart[]>();
11
11
 
12
12
  if (!selectedThread) {
@@ -104,7 +104,7 @@ function participantSummary(thread: GuiConversationThread): string {
104
104
  }
105
105
 
106
106
  function unreadCount(thread: GuiConversationThread): number {
107
- const lastSequence = Math.max(0, ...conversationMessages(thread).map((message) => message.sequence));
107
+ const lastSequence = conversationMessages(thread).reduce((max, message) => Math.max(max, message.sequence), 0);
108
108
  const localRead = conversationReadStates(thread)[0]?.last_read_sequence ?? 0;
109
109
  return Math.max(0, lastSequence - localRead);
110
110
  }
@@ -238,6 +238,7 @@ function writeManagedPolicyToggles(policy: ManagedPolicyToggleState): void {
238
238
  }
239
239
 
240
240
  interface AppTooltipState {
241
+ align: "start" | "center" | "end";
241
242
  text: string;
242
243
  x: number;
243
244
  y: number;
@@ -270,7 +271,16 @@ function tooltipForElement(element: HTMLElement): AppTooltipState | null {
270
271
  }
271
272
 
272
273
  const rect = element.getBoundingClientRect();
273
- return { text: textValue, x: rect.left + (rect.width / 2), y: rect.top };
274
+ const viewportWidth = document.documentElement.clientWidth || window.innerWidth;
275
+ const edgeSafeWidth = 240;
276
+ if (rect.left < edgeSafeWidth) {
277
+ return { align: "start", text: textValue, x: rect.left, y: rect.top };
278
+ }
279
+ if (viewportWidth - rect.right < edgeSafeWidth) {
280
+ return { align: "end", text: textValue, x: rect.right, y: rect.top };
281
+ }
282
+
283
+ return { align: "center", text: textValue, x: rect.left + (rect.width / 2), y: rect.top };
274
284
  }
275
285
 
276
286
  function AppTooltip({ tooltip }: { tooltip: AppTooltipState }): ReactElement {
@@ -279,5 +289,5 @@ function AppTooltip({ tooltip }: { tooltip: AppTooltipState }): ReactElement {
279
289
  "--tooltip-y": `${tooltip.y}px`,
280
290
  } as CSSProperties;
281
291
 
282
- return <div className="app-global-tooltip" role="tooltip" style={style}>{tooltip.text}</div>;
292
+ return <div className={`app-global-tooltip ${tooltip.align}`} role="tooltip" style={style}>{tooltip.text}</div>;
283
293
  }
@@ -1,8 +1,9 @@
1
1
  import type { GuiAppActionId, GuiAppActionJobSummary, GuiManagedAppSummary, GuiResponseEnvelope } from "@aidevops/gui-shared";
2
- import { type MouseEvent as ReactMouseEvent, type ReactElement, useState } from "react";
2
+ import { type ReactElement, useState } from "react";
3
3
  import { FiChevronDown, FiDownload, FiExternalLink, FiRefreshCw, FiRepeat, FiTrash2 } from "react-icons/fi";
4
4
  import { AppActionTerminal } from "./AppActionTerminal";
5
5
  import { text } from "./app-model";
6
+ import { openExternalLink } from "./external-links";
6
7
 
7
8
  type ManagedPolicyId = "setup" | "update";
8
9
 
@@ -55,7 +56,7 @@ function isEssentialManagedApp(app: GuiManagedAppSummary): boolean {
55
56
 
56
57
  function SummaryChip({ label, value }: { label: string; value: string }): ReactElement {
57
58
  const tooltip = `${label}: ${value}`;
58
- return <span className="managed-summary-chip" data-tooltip={tooltip} title={tooltip}><small>{label}</small><span>{value}</span></span>;
59
+ return <span className="managed-summary-chip" data-tooltip={tooltip}><small>{label}</small><span>{value}</span></span>;
59
60
  }
60
61
 
61
62
  function OriginLink({ href, label }: { href: string; label: string }): ReactElement {
@@ -63,11 +64,11 @@ function OriginLink({ href, label }: { href: string; label: string }): ReactElem
63
64
  return <span className="origin-missing">{label}: source pending</span>;
64
65
  }
65
66
 
66
- return <a data-tooltip={href} href={href} onClick={(event) => openExternalLink(event, href)} rel="noreferrer" target="_blank" title={href}>{label} <FiExternalLink aria-hidden="true" /></a>;
67
+ return <a aria-label={`${label}: ${href}`} data-tooltip={href} href={href} onClick={(event) => openExternalLink(event, href)} rel="noreferrer" target="_blank">{label} <FiExternalLink aria-hidden="true" /></a>;
67
68
  }
68
69
 
69
70
  function ToggleSwitch({ checked, disabled = false, label, onChange }: { checked: boolean; disabled?: boolean; label: string; onChange: (checked: boolean) => void }): ReactElement {
70
- return <button aria-pressed={checked} className={checked ? "managed-toggle checked" : "managed-toggle"} data-tooltip={disabled ? "Essential aidevops component; policy is locked on" : undefined} disabled={disabled} onClick={() => onChange(!checked)} title={disabled ? "Essential aidevops component; policy is locked on" : undefined} type="button"><span className="managed-toggle-label">{label}</span><span aria-hidden="true" className={checked ? "switch-track checked" : "switch-track"}><span /></span></button>;
71
+ return <button aria-pressed={checked} className={checked ? "managed-toggle checked" : "managed-toggle"} data-tooltip={disabled ? "Essential aidevops component; policy is locked on" : undefined} disabled={disabled} onClick={() => onChange(!checked)} type="button"><span className="managed-toggle-label">{label}</span><span aria-hidden="true" className={checked ? "switch-track checked" : "switch-track"}><span /></span></button>;
71
72
  }
72
73
 
73
74
  function AppMeta({ className = "", label, value }: { className?: string; label: string; value: string }): ReactElement {
@@ -87,9 +88,34 @@ function AppActionButton({ action, app, commandPreview, disabled, onJob }: { act
87
88
  return;
88
89
  }
89
90
 
90
- const response = await fetch(`/api/apps/${encodeURIComponent(app.id)}/actions/${action}`, { method: "POST" });
91
- const envelope = await response.json() as GuiResponseEnvelope<GuiAppActionJobSummary>;
92
- onJob(envelope.data);
91
+ try {
92
+ const response = await fetch(`/api/apps/${encodeURIComponent(app.id)}/actions/${action}`, { method: "POST" });
93
+ if (!response.ok) {
94
+ console.error(`Failed to run ${action} for ${app.id}: ${response.status} ${response.statusText}`);
95
+ return;
96
+ }
97
+
98
+ const envelope = await response.json() as Partial<GuiResponseEnvelope<GuiAppActionJobSummary>> | null;
99
+ if (envelope === null || typeof envelope !== "object") {
100
+ console.error(`Action ${action} for ${app.id} returned an invalid response envelope`);
101
+ return;
102
+ }
103
+
104
+ if (envelope.ok !== true) {
105
+ const errors = Array.isArray(envelope.errors) && envelope.errors.length > 0 ? envelope.errors.join("; ") : "unknown error";
106
+ console.error(`Action ${action} for ${app.id} returned an error envelope: ${errors}`);
107
+ return;
108
+ }
109
+
110
+ if (!("data" in envelope)) {
111
+ console.error(`Action ${action} for ${app.id} returned a response envelope without job data`);
112
+ return;
113
+ }
114
+
115
+ onJob(envelope.data as GuiAppActionJobSummary);
116
+ } catch (error) {
117
+ console.error(`Network error running ${action} for ${app.id}:`, error);
118
+ }
93
119
  }
94
120
 
95
121
  return <>
@@ -114,28 +140,3 @@ function ConfirmActionModal({ action, app, close, commandPreview, confirm }: { a
114
140
  </div>
115
141
  );
116
142
  }
117
-
118
- function openExternalLink(event: ReactMouseEvent<HTMLAnchorElement>, href: string): void {
119
- event.stopPropagation();
120
- if (!shouldUseBrowserDefault(event)) {
121
- event.preventDefault();
122
- openExternalDestination(href);
123
- }
124
- }
125
-
126
- function shouldUseBrowserDefault(event: ReactMouseEvent<HTMLAnchorElement>): boolean {
127
- return [event.defaultPrevented, event.metaKey, event.ctrlKey, event.shiftKey, event.altKey].some(Boolean);
128
- }
129
-
130
- function openExternalDestination(href: string): void {
131
- if (document.documentElement.dataset.desktopShell === "macos") {
132
- window.location.assign(href);
133
- } else {
134
- const opened = window.open(href, "_blank", "noopener,noreferrer");
135
- if (opened === null) {
136
- window.location.assign(href);
137
- } else {
138
- opened.opener = null;
139
- }
140
- }
141
- }
@@ -1,10 +1,11 @@
1
- import type { MouseEvent as ReactMouseEvent, ReactElement } from "react";
1
+ import type { ReactElement } from "react";
2
2
  import type { IconType } from "react-icons";
3
3
  import { FaApple, FaLinux, FaWindows } from "react-icons/fa";
4
4
  import { FiCode, FiExternalLink, FiGlobe, FiMonitor, FiTerminal } from "react-icons/fi";
5
5
  import { IoLogoAndroid } from "react-icons/io";
6
6
  import { SiIos } from "react-icons/si";
7
7
  import { text } from "./app-model";
8
+ import { openExternalLink } from "./external-links";
8
9
 
9
10
  export type RecommendedOsId = "all" | "macos" | "linux" | "windows" | "ios" | "android";
10
11
  export type RecommendedPlatformId = "webapp" | "saas" | "cli" | "api";
@@ -117,7 +118,7 @@ export function RecommendedAppsSurface({ apps, os, platform, setOs, setPlatform
117
118
  function RecommendedAppCard({ app, setOs, setPlatform }: { app: RecommendedApp; setOs: (os: RecommendedOsId) => void; setPlatform: (platform: RecommendedPlatformFilterId) => void }): ReactElement {
118
119
  return <article className="recommended-app-card">
119
120
  <div>
120
- <strong>{app.name}</strong>
121
+ <a aria-label={`${app.name}: ${app.websiteUrl}`} className="recommended-app-title-link" data-tooltip={app.websiteUrl} href={app.websiteUrl} onClick={(event) => openExternalLink(event, app.websiteUrl)} rel="noreferrer" target="_blank"><strong>{app.name}</strong></a>
121
122
  <p>{app.description}</p>
122
123
  </div>
123
124
  <div className="app-icon-groups">
@@ -141,7 +142,7 @@ function OriginLink({ href, label }: { href: string; label: string }): ReactElem
141
142
  return <span className="origin-missing">{label}: source pending</span>;
142
143
  }
143
144
 
144
- return <a data-tooltip={href} href={href} onClick={(event) => openExternalLink(event, href)} rel="noreferrer" target="_blank" title={href}>{label} <FiExternalLink aria-hidden="true" /></a>;
145
+ return <a aria-label={`${label}: ${href}`} data-tooltip={href} href={href} onClick={(event) => openExternalLink(event, href)} rel="noreferrer" target="_blank">{label} <FiExternalLink aria-hidden="true" /></a>;
145
146
  }
146
147
 
147
148
  function PlatformIconList({ platforms, setPlatform }: { platforms: RecommendedPlatformId[]; setPlatform: (platform: RecommendedPlatformFilterId) => void }): ReactElement | null {
@@ -161,7 +162,7 @@ function PlatformIcon({ id, setPlatform }: { id: RecommendedPlatformId; setPlatf
161
162
  };
162
163
  const { Icon, label } = iconMap[id];
163
164
 
164
- return <button aria-label={`Filter by ${label}`} data-tooltip={`Filter by ${label}`} onClick={() => setPlatform(id)} title={`Filter by ${label}`} type="button"><Icon aria-hidden="true" focusable="false" /></button>;
165
+ return <button aria-label={`Filter by ${label}`} data-tooltip={`Filter by ${label}`} onClick={() => setPlatform(id)} type="button"><Icon aria-hidden="true" focusable="false" /></button>;
165
166
  }
166
167
 
167
168
  function OsIconList({ os, setOs }: { os: RecommendedOsId[]; setOs: (os: RecommendedOsId) => void }): ReactElement | null {
@@ -183,7 +184,7 @@ function OsIcon({ id, setOs }: { id: RecommendedOsId; setOs: (os: RecommendedOsI
183
184
  };
184
185
  const { Icon, label } = iconMap[id];
185
186
 
186
- return <button aria-label={`Filter by ${label}`} data-tooltip={`Filter by ${label}`} onClick={() => setOs(id)} title={`Filter by ${label}`} type="button"><Icon aria-hidden="true" focusable="false" /></button>;
187
+ return <button aria-label={`Filter by ${label}`} data-tooltip={`Filter by ${label}`} onClick={() => setOs(id)} type="button"><Icon aria-hidden="true" focusable="false" /></button>;
187
188
  }
188
189
 
189
190
  export function recommendedAppMatchesFilters(app: RecommendedApp, platform: RecommendedPlatformFilterId, os: RecommendedOsId): boolean {
@@ -196,28 +197,3 @@ export function recommendedAppMatchesFilters(app: RecommendedApp, platform: Reco
196
197
  export function nextRecommendedFilterValue<T extends string>(current: T, next: T, defaultValue: T): T {
197
198
  return current === next && next !== defaultValue ? defaultValue : next;
198
199
  }
199
-
200
- function openExternalLink(event: ReactMouseEvent<HTMLAnchorElement>, href: string): void {
201
- event.stopPropagation();
202
- if (!shouldUseBrowserDefault(event)) {
203
- event.preventDefault();
204
- openExternalDestination(href);
205
- }
206
- }
207
-
208
- function shouldUseBrowserDefault(event: ReactMouseEvent<HTMLAnchorElement>): boolean {
209
- return [event.defaultPrevented, event.metaKey, event.ctrlKey, event.shiftKey, event.altKey].some(Boolean);
210
- }
211
-
212
- function openExternalDestination(href: string): void {
213
- if (document.documentElement.dataset.desktopShell === "macos") {
214
- window.location.assign(href);
215
- } else {
216
- const opened = window.open(href, "_blank", "noopener,noreferrer");
217
- if (opened === null) {
218
- window.location.assign(href);
219
- } else {
220
- opened.opener = null;
221
- }
222
- }
223
- }
@@ -1,6 +1,7 @@
1
1
  import type { GuiFileRootId, GuiStatusData } from "@aidevops/gui-shared";
2
2
 
3
3
  export type ThemePreference = "system" | "light" | "dark";
4
+ export type ContrastPreference = "low" | "medium" | "high";
4
5
  export type SidebarMode = "devops" | "comms";
5
6
  export type ShellMode = "devices" | "sessions";
6
7
  export type ConversationMode = "ai" | "people";
@@ -129,6 +130,11 @@ export interface FontSizeOption {
129
130
  cssSize: string;
130
131
  }
131
132
 
133
+ export interface ContrastOption {
134
+ label: string;
135
+ value: ContrastPreference;
136
+ }
137
+
132
138
  export interface InventoryColumn {
133
139
  key: string;
134
140
  label: string;
@@ -153,6 +159,7 @@ export const text = {
153
159
  brandsIntro: "Draft brand inventory for names and website references.",
154
160
  codeView: "Code",
155
161
  config: "Config",
162
+ contrast: "Contrast",
156
163
  configIntro: "Read-only file explorer for ~/.config/aidevops. Contents stay hidden until redaction rules land.",
157
164
  copyPath: "Copy path",
158
165
  dashboard: "Dashboard",
@@ -303,9 +310,16 @@ export const text = {
303
310
  } as const;
304
311
 
305
312
  export const DEFAULT_ACCENT_HUE = 123;
313
+ export const DEFAULT_CONTRAST: ContrastPreference = "low";
306
314
  export const DEFAULT_FONT: FontPreference = "Inter";
307
315
  export const DEFAULT_FONT_SIZE: FontSizePreference = "xs";
308
316
 
317
+ export const contrastOptions: ContrastOption[] = [
318
+ { value: "low", label: "Low" },
319
+ { value: "medium", label: "Medium" },
320
+ { value: "high", label: "High" },
321
+ ];
322
+
309
323
  export const fontOptions: FontOption[] = [
310
324
  { value: "IBM Plex Mono", label: "IBM Plex Mono", fontFamily: '"IBM Plex Mono", Menlo, Monaco, Consolas, monospace' },
311
325
  { value: "IBM Plex Sans", label: "IBM Plex Sans", fontFamily: '"IBM Plex Sans", Inter, system-ui, sans-serif' },
@@ -558,6 +572,10 @@ export function isFontSizePreference(value: string | null): value is FontSizePre
558
572
  return fontSizeOptions.some((option) => option.value === value);
559
573
  }
560
574
 
575
+ export function isContrastPreference(value: string | null): value is ContrastPreference {
576
+ return contrastOptions.some((option) => option.value === value);
577
+ }
578
+
561
579
  export function sidebarModeForSurface(id: SurfaceId): SidebarMode {
562
580
  if (utilityNavItems.some((item) => item.id === id)) {
563
581
  return "comms";
@@ -55,7 +55,7 @@ export function renderDashboardHtml(status: GuiResponseEnvelope<GuiStatusData>):
55
55
  `<h1>aidevops app interface</h1>`,
56
56
  `<p>Made for creators.</p>`,
57
57
  `<p>AI-assisted development workflows, code quality, and deployment automation.</p>`,
58
- `<p>Theme follows system preferences with light and dark overrides. Sidebar modes: DevOps and Comms. Appearance controls can be hidden or shown, and include editable Hue, icon Reset, Show borders toggle, Show counts toggle, Font size choices xs, s, m, lg, xl, and Font options: IBM Plex Mono, IBM Plex Sans, IBM Plex Serif, Inter (default), Menlo, Playpen Sans, Poppins, Source Sans, Source Serif, Tilt Neon, Ubuntu Mono. The workspace header includes a command palette, reserved signposts/help slot immediately left of notifications, notifications menu, AI Assistant panel, and profile menu with Settings, Theme, Language, Community, Admin, and Logout entries. Notifications reuse the OpenCode toast status cache and GUI readiness data. A desktop status bar shows local readiness, repo totals, secret reference count, provider accounts, and update state.</p>`,
58
+ `<p>Theme follows system preferences with light and dark overrides. Sidebar modes: DevOps and Comms. Appearance controls can be hidden or shown, and include editable Hue, Contrast levels low, medium, high, icon Reset, Show borders toggle, Show counts toggle, Font size choices xs, s, m, lg, xl, and Font options: IBM Plex Mono, IBM Plex Sans, IBM Plex Serif, Inter (default), Menlo, Playpen Sans, Poppins, Source Sans, Source Serif, Tilt Neon, Ubuntu Mono. The workspace header includes a command palette, reserved signposts/help slot immediately left of notifications, notifications menu, AI Assistant panel, and profile menu with Settings, Theme, Language, Community, Admin, and Logout entries. Notifications reuse the OpenCode toast status cache and GUI readiness data. A desktop status bar shows local readiness, repo totals, secret reference count, provider accounts, and update state.</p>`,
59
59
  `<p>Secret references stay read-only and redacted.</p>`,
60
60
  `<p>Vault surfaces show padlock indicators. Encrypted by aidevops Vault; contents visible only when unlocked through app or authorised vault commands.</p>`,
61
61
  `<p>${escapeHtml(status.data.update.message)}</p>`,