aidevops 3.29.21 → 3.29.23

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
+ }
@@ -0,0 +1,99 @@
1
+ import { type ReactElement, useState } from "react";
2
+ import type { InventoryColumn } from "./app-model";
3
+ import { installationRows, text } from "./app-model";
4
+
5
+ interface DraftInventoryRow {
6
+ id: string;
7
+ values: Record<string, string>;
8
+ }
9
+
10
+ let draftRowCounter = 0;
11
+
12
+ export function InstallationSurface(): ReactElement {
13
+ return (
14
+ <section className="panel" aria-label={text.installation}>
15
+ <div className="section-heading">
16
+ <p className="eyebrow">{text.setup}</p>
17
+ <h2>{text.installation}</h2>
18
+ <p>{text.installationIntro}</p>
19
+ </div>
20
+ <div className="installation-list">
21
+ {installationRows.map((row) => (
22
+ <article className="install-row" key={row.name}>
23
+ <div><strong>{row.name}</strong><small>{row.scope}</small></div>
24
+ <TogglePill checked={row.install} label={text.install} />
25
+ <TogglePill checked={row.update} label={text.update} />
26
+ </article>
27
+ ))}
28
+ </div>
29
+ <p className="empty-state">{text.plannedNotice}</p>
30
+ </section>
31
+ );
32
+ }
33
+
34
+ export function EditableInventorySurface({ columns, initialRows, intro, title }: {
35
+ columns: InventoryColumn[];
36
+ initialRows: Record<string, string>[];
37
+ intro: string;
38
+ title: string;
39
+ }): ReactElement {
40
+ const [draftRows, setDraftRows] = useState(() => initialRows.map((row) => createDraftRow(row)));
41
+
42
+ function updateDraftRow(rowId: string, key: string, value: string): void {
43
+ setDraftRows((currentRows) => currentRows.map((row) => row.id === rowId ? { ...row, values: { ...row.values, [key]: value } } : row));
44
+ }
45
+
46
+ function addDraftRow(): void {
47
+ setDraftRows((currentRows) => [...currentRows, createDraftRow(emptyRow(columns))]);
48
+ }
49
+
50
+ return (
51
+ <section className="panel" aria-label={title}>
52
+ <div className="section-heading split-heading">
53
+ <div>
54
+ <p className="eyebrow">{text.infrastructure}</p>
55
+ <h2>{title}</h2>
56
+ <p>{intro}</p>
57
+ </div>
58
+ <button className="secondary-action" onClick={addDraftRow} type="button">{text.addRow}</button>
59
+ </div>
60
+ <p className="notice compact-notice">{text.draftOnly}</p>
61
+ <div className="editable-table">
62
+ <div className="editable-row header-row">
63
+ {columns.map((column) => <span key={column.key}>{column.label}</span>)}
64
+ </div>
65
+ {draftRows.map((row) => (
66
+ <div className="editable-row" key={row.id}>
67
+ {columns.map((column) => (
68
+ <input
69
+ aria-label={`${title} ${column.label}`}
70
+ key={column.key}
71
+ onChange={(event) => updateDraftRow(row.id, column.key, event.currentTarget.value)}
72
+ placeholder={column.label}
73
+ value={row.values[column.key] ?? ""}
74
+ />
75
+ ))}
76
+ </div>
77
+ ))}
78
+ </div>
79
+ </section>
80
+ );
81
+ }
82
+
83
+ function TogglePill({ checked, label }: { checked: boolean; label: string }): ReactElement {
84
+ return <span className={checked ? "toggle-pill checked" : "toggle-pill"}>{label}</span>;
85
+ }
86
+
87
+ function createDraftRow(values: Record<string, string>): DraftInventoryRow {
88
+ draftRowCounter += 1;
89
+ return { id: `draft-row-${draftRowCounter}`, values };
90
+ }
91
+
92
+ function emptyRow(columns: InventoryColumn[]): Record<string, string> {
93
+ const row: Record<string, string> = {};
94
+ for (const column of columns) {
95
+ row[column.key] = "";
96
+ }
97
+
98
+ return row;
99
+ }