aidevops 3.29.22 → 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.
- package/README.md +1 -1
- package/VERSION +1 -1
- package/aidevops.sh +1 -1
- package/package.json +1 -1
- package/packages/gui-api/src/status-adapter-utils.ts +9 -5
- package/packages/gui-desktop/scripts/install-macos-app.sh +31 -8
- package/packages/gui-shared/src/notifications.ts +3 -2
- package/packages/gui-web/src/App.tsx +37 -8
- package/packages/gui-web/src/AppNavigation.tsx +11 -189
- package/packages/gui-web/src/AppWorkspace.tsx +21 -29
- package/packages/gui-web/src/AppearanceControls.tsx +244 -0
- package/packages/gui-web/src/InventorySurfaces.tsx +12 -2
- package/packages/gui-web/src/ManagedAppPanel.tsx +33 -32
- package/packages/gui-web/src/RecommendedAppsSurface.tsx +6 -30
- package/packages/gui-web/src/app-model.ts +18 -0
- package/packages/gui-web/src/dashboard.ts +1 -1
- package/packages/gui-web/src/external-links.ts +60 -0
- package/packages/gui-web/src/styles.css +135 -3
- package/packages/gui-web/src/workspace-header-state.ts +107 -0
- package/setup.sh +1 -1
|
@@ -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
|
|
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
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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={
|
|
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={
|
|
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={
|
|
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
|
+
}
|
|
@@ -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
|
-
|
|
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=
|
|
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
|
|
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}
|
|
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"
|
|
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)}
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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 {
|
|
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"
|
|
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)}
|
|
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)}
|
|
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>`,
|