aidevops 3.28.0 → 3.29.0
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/app.ts +125 -1
- package/packages/gui-api/src/status-adapter-utils.ts +35 -0
- package/packages/gui-api/src/status-adapter.ts +12 -39
- package/packages/gui-api/src/status-managed-apps.ts +228 -0
- package/packages/gui-shared/src/contracts.ts +63 -2
- package/packages/gui-shared/src/fixtures.ts +1 -0
- package/packages/gui-web/src/App.tsx +61 -1
- package/packages/gui-web/src/AppNavigation.tsx +13 -9
- package/packages/gui-web/src/AppWorkspace.tsx +20 -9
- package/packages/gui-web/src/CommandPalette.tsx +9 -2
- package/packages/gui-web/src/InventorySurfaces.tsx +107 -7
- package/packages/gui-web/src/app-model.ts +2 -11
- package/packages/gui-web/src/status-client.ts +1 -0
- package/packages/gui-web/src/styles.css +236 -5
- package/setup.sh +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export type GuiRouteClassification = "read" | "write" | "destructive";
|
|
2
2
|
|
|
3
|
-
export type GuiOperationId = "setup.status.read" | "capabilities.read" | "filesystem.read" | "vault.status.read";
|
|
3
|
+
export type GuiOperationId = "setup.status.read" | "capabilities.read" | "filesystem.read" | "vault.status.read" | "apps.action.run" | "apps.action.status";
|
|
4
4
|
|
|
5
5
|
export type GuiFileRootId = "agents" | "config" | "localSetup" | "git";
|
|
6
6
|
|
|
@@ -23,7 +23,7 @@ export interface GuiResponseEnvelope<TData> {
|
|
|
23
23
|
|
|
24
24
|
export interface GuiRouteManifest {
|
|
25
25
|
route: string;
|
|
26
|
-
method: "GET";
|
|
26
|
+
method: "GET" | "POST";
|
|
27
27
|
operation_id: GuiOperationId;
|
|
28
28
|
classification: GuiRouteClassification;
|
|
29
29
|
source_surface: string;
|
|
@@ -68,6 +68,44 @@ export interface GuiFileExplorerData {
|
|
|
68
68
|
entry_limit: number;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
export type GuiAppActionId = "install" | "update" | "reinstall" | "remove";
|
|
72
|
+
|
|
73
|
+
export interface GuiManagedAppActionSummary {
|
|
74
|
+
id: GuiAppActionId;
|
|
75
|
+
label: string;
|
|
76
|
+
enabled: boolean;
|
|
77
|
+
command_preview: string;
|
|
78
|
+
confirmation: "none" | "recommended" | "required";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface GuiManagedAppSummary {
|
|
82
|
+
id: string;
|
|
83
|
+
name: string;
|
|
84
|
+
description: string;
|
|
85
|
+
category: string;
|
|
86
|
+
origin_website_url: string;
|
|
87
|
+
origin_repo_url: string;
|
|
88
|
+
aidevops_install: boolean;
|
|
89
|
+
aidevops_update: boolean;
|
|
90
|
+
installed_version: string;
|
|
91
|
+
latest_version: string;
|
|
92
|
+
install_path_ref: string;
|
|
93
|
+
status: "found" | "missing" | "unchecked";
|
|
94
|
+
actions: GuiManagedAppActionSummary[];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface GuiAppActionJobSummary {
|
|
98
|
+
id: string;
|
|
99
|
+
app_id: string;
|
|
100
|
+
action: GuiAppActionId;
|
|
101
|
+
status: "running" | "completed" | "failed" | "rejected";
|
|
102
|
+
command_preview: string;
|
|
103
|
+
started_at: string;
|
|
104
|
+
finished_at: string | null;
|
|
105
|
+
exit_code: number | null;
|
|
106
|
+
output: string[];
|
|
107
|
+
}
|
|
108
|
+
|
|
71
109
|
export interface GuiSecretReference {
|
|
72
110
|
name: string;
|
|
73
111
|
status: "configured" | "missing" | "unchecked";
|
|
@@ -318,6 +356,7 @@ export interface GuiStatusData {
|
|
|
318
356
|
oauth_pool: GuiOAuthPoolSummary;
|
|
319
357
|
setup_targets: GuiSetupTargetSummary[];
|
|
320
358
|
ai_apps: GuiAiAppSummary[];
|
|
359
|
+
managed_apps: GuiManagedAppSummary[];
|
|
321
360
|
vault: GuiVaultStatusData;
|
|
322
361
|
capabilities: GuiCapabilitySummary[];
|
|
323
362
|
secrets: GuiSecretReference[];
|
|
@@ -357,6 +396,28 @@ export const VAULT_STATUS_ROUTE_MANIFEST: GuiRouteManifest = {
|
|
|
357
396
|
redactions: ["secret_values", "credential_paths", "private_key_material", "vault_passphrases", "recovery_material"],
|
|
358
397
|
};
|
|
359
398
|
|
|
399
|
+
export const APP_ACTION_ROUTE_MANIFEST: GuiRouteManifest = {
|
|
400
|
+
route: "/api/apps/:appId/actions/:action",
|
|
401
|
+
method: "POST",
|
|
402
|
+
operation_id: "apps.action.run",
|
|
403
|
+
classification: "write",
|
|
404
|
+
source_surface: "apps",
|
|
405
|
+
adapter: "appActions.startAppAction",
|
|
406
|
+
command_pattern: ["allowlisted", "aidevops", "setup/update", "background-job"],
|
|
407
|
+
redactions: ["secret_values", "credential_paths", "private_key_material"],
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
export const APP_ACTION_STATUS_ROUTE_MANIFEST: GuiRouteManifest = {
|
|
411
|
+
route: "/api/apps/jobs/:jobId",
|
|
412
|
+
method: "GET",
|
|
413
|
+
operation_id: "apps.action.status",
|
|
414
|
+
classification: "read",
|
|
415
|
+
source_surface: "apps",
|
|
416
|
+
adapter: "appActions.readAppActionJob",
|
|
417
|
+
command_pattern: ["background-job", "metadata-and-output-only"],
|
|
418
|
+
redactions: ["secret_values", "credential_paths", "private_key_material"],
|
|
419
|
+
};
|
|
420
|
+
|
|
360
421
|
export const GUI_FILE_ROOTS: readonly GuiFileRootDefinition[] = [
|
|
361
422
|
{
|
|
362
423
|
id: "agents",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { GuiResponseEnvelope, GuiStatusData } from "@aidevops/gui-shared";
|
|
2
|
-
import { type ReactElement, useEffect, useState } from "react";
|
|
2
|
+
import { type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactElement, useEffect, useState } from "react";
|
|
3
3
|
import { MachineRail, Sidebar } from "./AppNavigation";
|
|
4
4
|
import { Workspace } from "./AppWorkspace";
|
|
5
5
|
import type { ConversationMode, FontPreference, FontSizePreference, ShellMode, SurfaceId, ThemePreference } from "./app-model";
|
|
@@ -32,9 +32,14 @@ export const appearanceStorageKeys = {
|
|
|
32
32
|
machineRail: "aidevops-gui-show-machine-rail",
|
|
33
33
|
showBorders: "aidevops-gui-show-borders",
|
|
34
34
|
showNavCounts: "aidevops-gui-show-nav-counts",
|
|
35
|
+
sidebarWidth: "aidevops-gui-sidebar-width",
|
|
35
36
|
theme: "aidevops-gui-theme",
|
|
36
37
|
} as const;
|
|
37
38
|
|
|
39
|
+
const defaultSidebarWidth = 302;
|
|
40
|
+
const minSidebarWidth = 248;
|
|
41
|
+
const maxSidebarWidth = 520;
|
|
42
|
+
|
|
38
43
|
export interface StoredAppearancePreferences {
|
|
39
44
|
accentHue: number;
|
|
40
45
|
fontPreference: FontPreference;
|
|
@@ -76,6 +81,7 @@ export function App(): ReactElement {
|
|
|
76
81
|
const [shellMode, setShellMode] = useState<ShellMode>("devices");
|
|
77
82
|
const [conversationMode, setConversationMode] = useState<ConversationMode>("ai");
|
|
78
83
|
const [selectedLocalRepoIndex, setSelectedLocalRepoIndex] = useState(0);
|
|
84
|
+
const [selectedSessionId, setSelectedSessionId] = useState<string | undefined>();
|
|
79
85
|
const [systemTheme, setSystemTheme] = useState<"light" | "dark">("light");
|
|
80
86
|
const resolvedTheme = themePreference === "system" ? systemTheme : themePreference;
|
|
81
87
|
const activeSurface: SurfaceId = navigation.entries[navigation.index] ?? "overview";
|
|
@@ -184,12 +190,14 @@ export function App(): ReactElement {
|
|
|
184
190
|
fontSizePreference={fontSizePreference}
|
|
185
191
|
fontPreference={fontPreference}
|
|
186
192
|
selectedLocalRepoIndex={selectedLocalRepoIndex}
|
|
193
|
+
selectedSessionId={selectedSessionId}
|
|
187
194
|
setAccentHue={setAccentHue}
|
|
188
195
|
setActiveSurface={setActiveSurface}
|
|
189
196
|
setConversationMode={setConversationMode}
|
|
190
197
|
setFontSizePreference={setFontSizePreference}
|
|
191
198
|
setFontPreference={setFontPreference}
|
|
192
199
|
setSelectedLocalRepoIndex={setSelectedLocalRepoIndex}
|
|
200
|
+
setSelectedSessionId={setSelectedSessionId}
|
|
193
201
|
setShellMode={setShellMode}
|
|
194
202
|
setShowBorders={setShowBorders}
|
|
195
203
|
setShowNavCounts={setShowNavCounts}
|
|
@@ -200,6 +208,7 @@ export function App(): ReactElement {
|
|
|
200
208
|
status={status.data}
|
|
201
209
|
themePreference={themePreference}
|
|
202
210
|
/>
|
|
211
|
+
<SidebarResizeHandle />
|
|
203
212
|
<Workspace
|
|
204
213
|
activeItem={activeItem}
|
|
205
214
|
activeSectionLabel={activeSectionLabel}
|
|
@@ -211,8 +220,11 @@ export function App(): ReactElement {
|
|
|
211
220
|
goBack={goBack}
|
|
212
221
|
goForward={goForward}
|
|
213
222
|
selectedLocalRepoIndex={selectedLocalRepoIndex}
|
|
223
|
+
selectedSessionId={selectedSessionId}
|
|
214
224
|
setActiveSurface={setActiveSurface}
|
|
215
225
|
setConversationMode={setConversationMode}
|
|
226
|
+
setSelectedLocalRepoIndex={setSelectedLocalRepoIndex}
|
|
227
|
+
setSelectedSessionId={setSelectedSessionId}
|
|
216
228
|
setShellMode={setShellMode}
|
|
217
229
|
shellMode={shellMode}
|
|
218
230
|
status={status.data}
|
|
@@ -222,6 +234,54 @@ export function App(): ReactElement {
|
|
|
222
234
|
);
|
|
223
235
|
}
|
|
224
236
|
|
|
237
|
+
export function clampSidebarWidth(width: number): number {
|
|
238
|
+
return Math.min(maxSidebarWidth, Math.max(minSidebarWidth, Math.round(width)));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function SidebarResizeHandle(): ReactElement {
|
|
242
|
+
const [sidebarWidth, setSidebarWidth] = useState(() => readStoredSidebarWidth());
|
|
243
|
+
|
|
244
|
+
useEffect(() => {
|
|
245
|
+
document.documentElement.style.setProperty("--sidebar-width", `${sidebarWidth}px`);
|
|
246
|
+
persistAppearancePreference(appearanceStorageKeys.sidebarWidth, String(sidebarWidth));
|
|
247
|
+
}, [sidebarWidth]);
|
|
248
|
+
|
|
249
|
+
const startSidebarResize = (event: ReactMouseEvent<HTMLElement>) => {
|
|
250
|
+
const startX = event.clientX;
|
|
251
|
+
const startWidth = sidebarWidth;
|
|
252
|
+
const resizeSidebar = (moveEvent: MouseEvent) => setSidebarWidth(clampSidebarWidth(startWidth + moveEvent.clientX - startX));
|
|
253
|
+
const stopResize = () => {
|
|
254
|
+
document.documentElement.classList.remove("resizing-sidebar");
|
|
255
|
+
window.removeEventListener("mousemove", resizeSidebar);
|
|
256
|
+
window.removeEventListener("mouseup", stopResize);
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
event.preventDefault();
|
|
260
|
+
document.documentElement.classList.add("resizing-sidebar");
|
|
261
|
+
window.addEventListener("mousemove", resizeSidebar);
|
|
262
|
+
window.addEventListener("mouseup", stopResize);
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const resizeSidebarWithKeyboard = (event: ReactKeyboardEvent<HTMLElement>) => {
|
|
266
|
+
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
|
|
267
|
+
event.preventDefault();
|
|
268
|
+
setSidebarWidth((current) => clampSidebarWidth(current + (event.key === "ArrowRight" ? 16 : -16)));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (event.key === "Home" || event.key === "End") {
|
|
272
|
+
event.preventDefault();
|
|
273
|
+
setSidebarWidth(event.key === "Home" ? minSidebarWidth : maxSidebarWidth);
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
return <hr aria-label="Resize sidebar" aria-orientation="vertical" aria-valuemax={maxSidebarWidth} aria-valuemin={minSidebarWidth} aria-valuenow={sidebarWidth} className="sidebar-resize-handle" onKeyDown={resizeSidebarWithKeyboard} onMouseDown={startSidebarResize} tabIndex={0} />;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function readStoredSidebarWidth(storage: ReadableAppearanceStorage | undefined = browserLocalStorage()): number {
|
|
281
|
+
const savedWidth = Number.parseInt(storage?.getItem(appearanceStorageKeys.sidebarWidth) ?? "", 10);
|
|
282
|
+
return Number.isFinite(savedWidth) ? clampSidebarWidth(savedWidth) : defaultSidebarWidth;
|
|
283
|
+
}
|
|
284
|
+
|
|
225
285
|
function sendNativeAccentHue(hue: number): void {
|
|
226
286
|
const webkit = (window as WebKitBridgeWindow).webkit;
|
|
227
287
|
webkit?.messageHandlers?.accentHue?.postMessage(hue);
|
|
@@ -110,19 +110,21 @@ export function MachineRail({ machine }: { machine?: GuiMachineSummary }) {
|
|
|
110
110
|
);
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
-
export function Sidebar({ activeSurface, accentHue, conversationMode, fontPreference, fontSizePreference, selectedLocalRepoIndex, setAccentHue, setActiveSurface, setConversationMode, setFontPreference, setFontSizePreference, setSelectedLocalRepoIndex, setShellMode, setShowBorders, setShowNavCounts, setThemePreference, shellMode, showBorders, showNavCounts, status, themePreference }: {
|
|
113
|
+
export function Sidebar({ activeSurface, accentHue, conversationMode, fontPreference, fontSizePreference, selectedLocalRepoIndex, selectedSessionId, setAccentHue, setActiveSurface, setConversationMode, setFontPreference, setFontSizePreference, setSelectedLocalRepoIndex, setSelectedSessionId, setShellMode, setShowBorders, setShowNavCounts, setThemePreference, shellMode, showBorders, showNavCounts, status, themePreference }: {
|
|
114
114
|
activeSurface: SurfaceId;
|
|
115
115
|
accentHue: number;
|
|
116
116
|
conversationMode: ConversationMode;
|
|
117
117
|
fontPreference: FontPreference;
|
|
118
118
|
fontSizePreference: FontSizePreference;
|
|
119
119
|
selectedLocalRepoIndex: number;
|
|
120
|
+
selectedSessionId: string | undefined;
|
|
120
121
|
setAccentHue: (hue: number) => void;
|
|
121
122
|
setActiveSurface: (surface: SurfaceId) => void;
|
|
122
123
|
setConversationMode: (mode: ConversationMode) => void;
|
|
123
124
|
setFontPreference: (font: FontPreference) => void;
|
|
124
125
|
setFontSizePreference: (size: FontSizePreference) => void;
|
|
125
126
|
setSelectedLocalRepoIndex: (index: number) => void;
|
|
127
|
+
setSelectedSessionId: (id: string | undefined) => void;
|
|
126
128
|
setShellMode: (mode: ShellMode) => void;
|
|
127
129
|
setShowBorders: (show: boolean) => void;
|
|
128
130
|
setShowNavCounts: (show: boolean) => void;
|
|
@@ -154,8 +156,10 @@ export function Sidebar({ activeSurface, accentHue, conversationMode, fontPrefer
|
|
|
154
156
|
</> : <ConversationSidebar
|
|
155
157
|
conversationMode={conversationMode}
|
|
156
158
|
selectedLocalRepoIndex={selectedLocalRepoIndex}
|
|
159
|
+
selectedSessionId={selectedSessionId}
|
|
157
160
|
setConversationMode={setConversationMode}
|
|
158
161
|
setSelectedLocalRepoIndex={setSelectedLocalRepoIndex}
|
|
162
|
+
setSelectedSessionId={setSelectedSessionId}
|
|
159
163
|
status={status}
|
|
160
164
|
/>}
|
|
161
165
|
</nav>
|
|
@@ -203,11 +207,13 @@ function IconSwitch<TValue extends string>({ ariaLabel, options, value }: {
|
|
|
203
207
|
);
|
|
204
208
|
}
|
|
205
209
|
|
|
206
|
-
function ConversationSidebar({ conversationMode, selectedLocalRepoIndex, setConversationMode, setSelectedLocalRepoIndex, status }: {
|
|
210
|
+
function ConversationSidebar({ conversationMode, selectedLocalRepoIndex, selectedSessionId, setConversationMode, setSelectedLocalRepoIndex, setSelectedSessionId, status }: {
|
|
207
211
|
conversationMode: ConversationMode;
|
|
208
212
|
selectedLocalRepoIndex: number;
|
|
213
|
+
selectedSessionId: string | undefined;
|
|
209
214
|
setConversationMode: (mode: ConversationMode) => void;
|
|
210
215
|
setSelectedLocalRepoIndex: (index: number) => void;
|
|
216
|
+
setSelectedSessionId: (id: string | undefined) => void;
|
|
211
217
|
status: GuiStatusData;
|
|
212
218
|
}) {
|
|
213
219
|
const repos = status.local_repos.repos;
|
|
@@ -215,6 +221,7 @@ function ConversationSidebar({ conversationMode, selectedLocalRepoIndex, setConv
|
|
|
215
221
|
const sessions = selectedRepo === undefined
|
|
216
222
|
? []
|
|
217
223
|
: status.opencode_sessions.sessions.filter((session) => session.repo_path_ref === selectedRepo.path_ref);
|
|
224
|
+
const activeSessionId = sessions.some((session) => session.id_ref === selectedSessionId) ? selectedSessionId : sessions[0]?.id_ref;
|
|
218
225
|
const canSelectPreviousRepo = selectedLocalRepoIndex > 0;
|
|
219
226
|
const canSelectNextRepo = selectedLocalRepoIndex < repos.length - 1;
|
|
220
227
|
|
|
@@ -226,19 +233,18 @@ function ConversationSidebar({ conversationMode, selectedLocalRepoIndex, setConv
|
|
|
226
233
|
<section className="repo-session-selector" aria-label={text.localRepoSelector}>
|
|
227
234
|
<span className="repo-selector-heading" id="local-repo-selector-label">{text.localRepos}</span>
|
|
228
235
|
<div className="selector-with-stepper repo-selector-row">
|
|
229
|
-
<button aria-label="Previous local repo" className="selector-step-button" disabled={!canSelectPreviousRepo} onClick={() => setSelectedLocalRepoIndex(Math.max(0, selectedLocalRepoIndex - 1))} type="button"><FiChevronLeft aria-hidden="true" /></button>
|
|
230
|
-
<select aria-labelledby="local-repo-selector-label" onChange={(event) => setSelectedLocalRepoIndex(Number.parseInt(event.currentTarget.value, 10))} value={Math.min(selectedLocalRepoIndex, Math.max(0, repos.length - 1))}>
|
|
236
|
+
<button aria-label="Previous local repo" className="selector-step-button" disabled={!canSelectPreviousRepo} onClick={() => { setSelectedLocalRepoIndex(Math.max(0, selectedLocalRepoIndex - 1)); setSelectedSessionId(undefined); }} type="button"><FiChevronLeft aria-hidden="true" /></button>
|
|
237
|
+
<select aria-labelledby="local-repo-selector-label" onChange={(event) => { setSelectedLocalRepoIndex(Number.parseInt(event.currentTarget.value, 10)); setSelectedSessionId(undefined); }} value={Math.min(selectedLocalRepoIndex, Math.max(0, repos.length - 1))}>
|
|
231
238
|
{repos.length === 0 ? <option value={0}>No local repos</option> : repos.map((repo, index) => <option key={repo.path_ref} value={index}>{repo.name}</option>)}
|
|
232
239
|
</select>
|
|
233
|
-
<button aria-label="Next local repo" className="selector-step-button" disabled={!canSelectNextRepo} onClick={() => setSelectedLocalRepoIndex(Math.min(repos.length - 1, selectedLocalRepoIndex + 1))} type="button"><FiChevronRight aria-hidden="true" /></button>
|
|
240
|
+
<button aria-label="Next local repo" className="selector-step-button" disabled={!canSelectNextRepo} onClick={() => { setSelectedLocalRepoIndex(Math.min(repos.length - 1, selectedLocalRepoIndex + 1)); setSelectedSessionId(undefined); }} type="button"><FiChevronRight aria-hidden="true" /></button>
|
|
234
241
|
</div>
|
|
235
242
|
</section>
|
|
236
243
|
<section className="sidebar-group session-history-list">
|
|
237
|
-
<h2>{text.sessionHistory}</h2>
|
|
238
244
|
{selectedRepo ? <ul>
|
|
239
245
|
{sessions.length === 0
|
|
240
246
|
? <li><button className="surface-link active" type="button"><span className="surface-icon" aria-hidden="true"><FiHash /></span><span className="surface-copy"><strong>{selectedRepo.name}</strong><small>No OpenCode sessions found for this repo yet.</small></span><em>{text.planned}</em></button></li>
|
|
241
|
-
: sessions.map((session
|
|
247
|
+
: sessions.map((session) => <li key={session.id_ref}><button className={activeSessionId === session.id_ref ? "surface-link active" : "surface-link"} onClick={() => setSelectedSessionId(session.id_ref)} type="button"><span className="surface-icon" aria-hidden="true"><FiHash /></span><span className="surface-copy"><strong>{session.title}</strong><small>{session.updated_at}</small></span></button></li>)}
|
|
242
248
|
</ul> : <p className="empty-sidebar-state">No local repos discovered.</p>}
|
|
243
249
|
</section>
|
|
244
250
|
</>}
|
|
@@ -251,13 +257,11 @@ function PeopleChannelList() {
|
|
|
251
257
|
<section aria-label="People channels">
|
|
252
258
|
<div className="notice compact-notice">{text.simplexReady}</div>
|
|
253
259
|
<section className="sidebar-group session-history-list">
|
|
254
|
-
<h2>{text.teams}</h2>
|
|
255
260
|
<ul>
|
|
256
261
|
{["aidevops", "clients", "ops"].map((team) => <li key={team}><button className="surface-link" type="button"><span className="surface-icon" aria-hidden="true"><FiHash /></span><span className="surface-copy"><strong>{team}</strong><small>SimpleX team channel placeholder</small></span></button></li>)}
|
|
257
262
|
</ul>
|
|
258
263
|
</section>
|
|
259
264
|
<section className="sidebar-group session-history-list">
|
|
260
|
-
<h2>{text.directMessages}</h2>
|
|
261
265
|
<ul>
|
|
262
266
|
{["Marcus", "AI DevOps"].map((person) => <li key={person}><button className="surface-link" type="button"><span className="surface-icon" aria-hidden="true"><FiMessageSquare /></span><span className="surface-copy"><strong>{person}</strong><small>encrypted DM placeholder</small></span></button></li>)}
|
|
263
267
|
</ul>
|
|
@@ -5,7 +5,7 @@ import type { GuiFileRootId, GuiStatusData } from "../../gui-shared/src";
|
|
|
5
5
|
import { SurfaceGlyph } from "./AppNavigation";
|
|
6
6
|
import type { ConversationMode, ShellMode, SurfaceId, SurfaceNavItem } from "./app-model";
|
|
7
7
|
import { inventorySurfaceConfigs, text } from "./app-model";
|
|
8
|
-
import { CommandPalette, commandPaletteShortcutQuery } from "./CommandPalette";
|
|
8
|
+
import { CommandPalette, type CommandPaletteSelection, commandPaletteShortcutQuery } from "./CommandPalette";
|
|
9
9
|
import { FileExplorerSurface } from "./FileExplorerSurface";
|
|
10
10
|
import { AppsSurface, EditableInventorySurface, InstallationSurface } from "./InventorySurfaces";
|
|
11
11
|
import { AiProvidersSurface, LocalReposSurface, LockedVaultGate, OverviewSurface, PlannedSurface, ProjectsSurface, SecuritySurface, VaultSurface } from "./StatusSurfaces";
|
|
@@ -16,7 +16,7 @@ const communityLinks = {
|
|
|
16
16
|
x: "https://x.com/marcuswquinn",
|
|
17
17
|
} as const;
|
|
18
18
|
|
|
19
|
-
export function Workspace({ activeItem, activeSectionLabel, activeSurface, canGoBack, canGoForward, conversationMode, fileRoot, goBack, goForward, selectedLocalRepoIndex, setActiveSurface, setConversationMode, setShellMode, shellMode, status }: {
|
|
19
|
+
export function Workspace({ activeItem, activeSectionLabel, activeSurface, canGoBack, canGoForward, conversationMode, fileRoot, goBack, goForward, selectedLocalRepoIndex, selectedSessionId, setActiveSurface, setConversationMode, setSelectedLocalRepoIndex, setSelectedSessionId, setShellMode, shellMode, status }: {
|
|
20
20
|
activeItem: SurfaceNavItem;
|
|
21
21
|
activeSectionLabel: string;
|
|
22
22
|
activeSurface: SurfaceId;
|
|
@@ -27,25 +27,28 @@ export function Workspace({ activeItem, activeSectionLabel, activeSurface, canGo
|
|
|
27
27
|
goBack: () => void;
|
|
28
28
|
goForward: () => void;
|
|
29
29
|
selectedLocalRepoIndex: number;
|
|
30
|
+
selectedSessionId: string | undefined;
|
|
30
31
|
setActiveSurface: (surface: SurfaceId) => void;
|
|
31
32
|
setConversationMode: (mode: ConversationMode) => void;
|
|
33
|
+
setSelectedLocalRepoIndex: (index: number) => void;
|
|
34
|
+
setSelectedSessionId: (id: string | undefined) => void;
|
|
32
35
|
setShellMode: (mode: ShellMode) => void;
|
|
33
36
|
shellMode: ShellMode;
|
|
34
37
|
status: GuiStatusData;
|
|
35
38
|
}) {
|
|
36
39
|
return (
|
|
37
40
|
<section className="app-inset" aria-label={text.workspaceLabel}>
|
|
38
|
-
<WorkspaceHeader activeItem={activeItem} activeSectionLabel={activeSectionLabel} activeSurface={activeSurface} canGoBack={canGoBack} canGoForward={canGoForward} goBack={goBack} goForward={goForward} setActiveSurface={setActiveSurface} setConversationMode={setConversationMode} setShellMode={setShellMode} status={status} />
|
|
41
|
+
<WorkspaceHeader activeItem={activeItem} activeSectionLabel={activeSectionLabel} activeSurface={activeSurface} canGoBack={canGoBack} canGoForward={canGoForward} goBack={goBack} goForward={goForward} setActiveSurface={setActiveSurface} setConversationMode={setConversationMode} setSelectedLocalRepoIndex={setSelectedLocalRepoIndex} setSelectedSessionId={setSelectedSessionId} setShellMode={setShellMode} status={status} />
|
|
39
42
|
<div className="workspace-scroll">
|
|
40
43
|
{shellMode === "sessions"
|
|
41
|
-
? <ConversationWorkspace conversationMode={conversationMode} selectedLocalRepoIndex={selectedLocalRepoIndex} status={status} />
|
|
44
|
+
? <ConversationWorkspace conversationMode={conversationMode} selectedLocalRepoIndex={selectedLocalRepoIndex} selectedSessionId={selectedSessionId} status={status} />
|
|
42
45
|
: <SurfaceContent activeItem={activeItem} activeSurface={activeSurface} fileRoot={fileRoot} status={status} />}
|
|
43
46
|
</div>
|
|
44
47
|
</section>
|
|
45
48
|
);
|
|
46
49
|
}
|
|
47
50
|
|
|
48
|
-
function WorkspaceHeader({ activeItem, activeSectionLabel, activeSurface, canGoBack, canGoForward, goBack, goForward, setActiveSurface, setConversationMode, setShellMode, status }: {
|
|
51
|
+
function WorkspaceHeader({ activeItem, activeSectionLabel, activeSurface, canGoBack, canGoForward, goBack, goForward, setActiveSurface, setConversationMode, setSelectedLocalRepoIndex, setSelectedSessionId, setShellMode, status }: {
|
|
49
52
|
activeItem: SurfaceNavItem;
|
|
50
53
|
activeSectionLabel: string;
|
|
51
54
|
activeSurface: SurfaceId;
|
|
@@ -55,6 +58,8 @@ function WorkspaceHeader({ activeItem, activeSectionLabel, activeSurface, canGoB
|
|
|
55
58
|
goForward: () => void;
|
|
56
59
|
setActiveSurface: (surface: SurfaceId) => void;
|
|
57
60
|
setConversationMode: (mode: ConversationMode) => void;
|
|
61
|
+
setSelectedLocalRepoIndex: (index: number) => void;
|
|
62
|
+
setSelectedSessionId: (id: string | undefined) => void;
|
|
58
63
|
setShellMode: (mode: ShellMode) => void;
|
|
59
64
|
status: GuiStatusData;
|
|
60
65
|
}): ReactElement {
|
|
@@ -87,8 +92,13 @@ function WorkspaceHeader({ activeItem, activeSectionLabel, activeSurface, canGoB
|
|
|
87
92
|
setProfileOpen(false);
|
|
88
93
|
};
|
|
89
94
|
|
|
90
|
-
const openItem = ({ conversationMode, shellMode, surface }:
|
|
95
|
+
const openItem = ({ conversationMode, repoPathRef, sessionId, shellMode, surface }: CommandPaletteSelection) => {
|
|
96
|
+
const repoIndex = status.local_repos.repos.findIndex((repo) => repo.path_ref === repoPathRef);
|
|
91
97
|
setShellMode(shellMode ?? "devices");
|
|
98
|
+
if (repoIndex >= 0) {
|
|
99
|
+
setSelectedLocalRepoIndex(repoIndex);
|
|
100
|
+
}
|
|
101
|
+
setSelectedSessionId(sessionId);
|
|
92
102
|
if (conversationMode) {
|
|
93
103
|
setConversationMode(conversationMode);
|
|
94
104
|
}
|
|
@@ -176,15 +186,16 @@ function NotificationsMenu({ openSurface }: { openSurface: (surface: SurfaceId)
|
|
|
176
186
|
);
|
|
177
187
|
}
|
|
178
188
|
|
|
179
|
-
function ConversationWorkspace({ conversationMode, selectedLocalRepoIndex, status }: {
|
|
189
|
+
function ConversationWorkspace({ conversationMode, selectedLocalRepoIndex, selectedSessionId, status }: {
|
|
180
190
|
conversationMode: ConversationMode;
|
|
181
191
|
selectedLocalRepoIndex: number;
|
|
192
|
+
selectedSessionId: string | undefined;
|
|
182
193
|
status: GuiStatusData;
|
|
183
194
|
}) {
|
|
184
195
|
const selectedRepo = status.local_repos.repos[selectedLocalRepoIndex] ?? status.local_repos.repos[0];
|
|
185
196
|
const selectedSession = selectedRepo === undefined
|
|
186
197
|
? undefined
|
|
187
|
-
: status.opencode_sessions.sessions.find((session) => session.repo_path_ref === selectedRepo.path_ref);
|
|
198
|
+
: status.opencode_sessions.sessions.find((session) => session.id_ref === selectedSessionId && session.repo_path_ref === selectedRepo.path_ref) ?? status.opencode_sessions.sessions.find((session) => session.repo_path_ref === selectedRepo.path_ref);
|
|
188
199
|
const title = conversationMode === "ai" ? selectedRepo?.name ?? text.opencodeSessions : text.teams;
|
|
189
200
|
|
|
190
201
|
return (
|
|
@@ -295,7 +306,7 @@ function SurfaceContent({ activeItem, activeSurface, fileRoot, status }: {
|
|
|
295
306
|
maintenance: <PlannedSurface label={text.maintenance} detail={text.projectWorkIntro} />,
|
|
296
307
|
performance: <PlannedSurface label={text.performance} detail={text.projectWorkIntro} />,
|
|
297
308
|
reports: <PlannedSurface label={text.reports} detail={text.projectWorkIntro} />,
|
|
298
|
-
apps: <AppsSurface />,
|
|
309
|
+
apps: <AppsSurface status={status} />,
|
|
299
310
|
installation: <InstallationSurface />,
|
|
300
311
|
projects: <ProjectsSurface status={status} />,
|
|
301
312
|
security: <SecuritySurface status={status} />,
|
|
@@ -15,7 +15,9 @@ interface CommandPaletteItem {
|
|
|
15
15
|
iconGlyph?: string;
|
|
16
16
|
id: string;
|
|
17
17
|
label: string;
|
|
18
|
+
repoPathRef?: string;
|
|
18
19
|
searchText: string;
|
|
20
|
+
sessionId?: string;
|
|
19
21
|
shellMode?: ShellMode;
|
|
20
22
|
surface: SurfaceId;
|
|
21
23
|
tag: string;
|
|
@@ -61,6 +63,8 @@ const plannedChannelItems: CommandPaletteItem[] = [
|
|
|
61
63
|
|
|
62
64
|
export interface CommandPaletteSelection {
|
|
63
65
|
conversationMode?: ConversationMode;
|
|
66
|
+
repoPathRef?: string;
|
|
67
|
+
sessionId?: string;
|
|
64
68
|
shellMode?: ShellMode;
|
|
65
69
|
surface: SurfaceId;
|
|
66
70
|
}
|
|
@@ -80,7 +84,7 @@ export function CommandPalette({ activeSurface, close, initialQuery, openItem, s
|
|
|
80
84
|
if (item.action === "copy-current-link") {
|
|
81
85
|
void copyCurrentSurfaceLink(item.surface);
|
|
82
86
|
}
|
|
83
|
-
openItem({ conversationMode: item.conversationMode, shellMode: item.shellMode, surface: item.surface });
|
|
87
|
+
openItem({ conversationMode: item.conversationMode, repoPathRef: item.repoPathRef, sessionId: item.sessionId, shellMode: item.shellMode, surface: item.surface });
|
|
84
88
|
}
|
|
85
89
|
}, [openItem, recentIds]);
|
|
86
90
|
|
|
@@ -165,13 +169,14 @@ export function commandPaletteMatches(items: CommandPaletteItem[], query: string
|
|
|
165
169
|
const normalizedQuery = query.trim().toLowerCase();
|
|
166
170
|
const prefix = normalizedQuery.charAt(0);
|
|
167
171
|
const scopedItems = itemsForCommandPrefix(items, prefix);
|
|
172
|
+
const filterQuery = commandPaletteShortcutEntries.some(([shortcut]) => shortcut === prefix) ? normalizedQuery.slice(1).trim() : normalizedQuery;
|
|
168
173
|
|
|
169
174
|
if (normalizedQuery.length === 0 || commandPaletteShortcutEntries.some(([shortcut]) => shortcut === normalizedQuery)) {
|
|
170
175
|
return orderCommandItemsByRecency(scopedItems, recentIds).slice(0, 8);
|
|
171
176
|
}
|
|
172
177
|
|
|
173
178
|
return scopedItems
|
|
174
|
-
.filter((item) => item.searchText.toLowerCase().includes(
|
|
179
|
+
.filter((item) => item.searchText.toLowerCase().includes(filterQuery))
|
|
175
180
|
.slice(0, 8);
|
|
176
181
|
}
|
|
177
182
|
|
|
@@ -197,7 +202,9 @@ function commandPaletteItems(status: GuiStatusData, activeSurface: SurfaceId): C
|
|
|
197
202
|
iconGlyph: "_",
|
|
198
203
|
id: `session-${session.id_ref}`,
|
|
199
204
|
label: `_${session.title}`,
|
|
205
|
+
repoPathRef: session.repo_path_ref,
|
|
200
206
|
searchText: `_${session.title} ${session.title} ${session.repo_path_ref} ${session.agent} ${session.model}`,
|
|
207
|
+
sessionId: session.id_ref,
|
|
201
208
|
surface: "git",
|
|
202
209
|
shellMode: "sessions",
|
|
203
210
|
conversationMode: "ai",
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { GuiAppActionId, GuiAppActionJobSummary, GuiManagedAppSummary, GuiResponseEnvelope, GuiStatusData } from "@aidevops/gui-shared";
|
|
2
|
+
import { type Dispatch, type ReactElement, type SetStateAction, useEffect, useState } from "react";
|
|
3
|
+
import { FiDownload, FiExternalLink, FiRefreshCw, FiRepeat, FiTrash2 } from "react-icons/fi";
|
|
2
4
|
import type { InventoryColumn } from "./app-model";
|
|
3
|
-
import {
|
|
5
|
+
import { installationRows, text } from "./app-model";
|
|
4
6
|
|
|
5
7
|
interface DraftInventoryRow {
|
|
6
8
|
id: string;
|
|
@@ -9,7 +11,23 @@ interface DraftInventoryRow {
|
|
|
9
11
|
|
|
10
12
|
let draftRowCounter = 0;
|
|
11
13
|
|
|
12
|
-
export function AppsSurface(): ReactElement {
|
|
14
|
+
export function AppsSurface({ status }: { status: GuiStatusData }): ReactElement {
|
|
15
|
+
const [selectedJobId, setSelectedJobId] = useState<string | null>(null);
|
|
16
|
+
const [jobs, setJobs] = useState<Record<string, GuiAppActionJobSummary>>({});
|
|
17
|
+
const selectedJob = selectedJobId === null ? null : jobs[selectedJobId] ?? null;
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (selectedJob === null || selectedJob.status !== "running") {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const timer = window.setInterval(() => {
|
|
25
|
+
void refreshJob(selectedJob.id, setJobs);
|
|
26
|
+
}, 1_500);
|
|
27
|
+
|
|
28
|
+
return () => window.clearInterval(timer);
|
|
29
|
+
}, [selectedJob]);
|
|
30
|
+
|
|
13
31
|
return (
|
|
14
32
|
<section className="panel" aria-label={text.apps}>
|
|
15
33
|
<div className="section-heading">
|
|
@@ -17,16 +35,98 @@ export function AppsSurface(): ReactElement {
|
|
|
17
35
|
<h2>{text.apps}</h2>
|
|
18
36
|
<p>{text.appsIntro}</p>
|
|
19
37
|
</div>
|
|
20
|
-
<div className="
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
<div className="data-row" key={row.name}><span>{row.name}</span><span>{row.latest}</span><span>{row.channel}</span><span>{row.website}</span></div>
|
|
38
|
+
<div className="managed-app-list">
|
|
39
|
+
{status.managed_apps.map((app) => (
|
|
40
|
+
<ManagedAppRow app={app} key={app.id} onJob={(job) => { setJobs((current) => ({ ...current, [job.id]: job })); setSelectedJobId(job.id); }} />
|
|
24
41
|
))}
|
|
25
42
|
</div>
|
|
43
|
+
{selectedJob ? <AppActionTerminal job={selectedJob} /> : <p className="empty-state compact-notice">Install/update actions run as allowlisted background jobs and stream here. xterm.js with a node-pty bridge is the right next step for full TUI apps such as OpenCode; this view starts with non-interactive command logs.</p>}
|
|
44
|
+
</section>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function ManagedAppRow({ app, onJob }: { app: GuiManagedAppSummary; onJob: (job: GuiAppActionJobSummary) => void }): ReactElement {
|
|
49
|
+
return (
|
|
50
|
+
<article className="managed-app-row">
|
|
51
|
+
<div className="managed-app-main">
|
|
52
|
+
<div>
|
|
53
|
+
<p className="eyebrow">{app.category}</p>
|
|
54
|
+
<h3>{app.name}</h3>
|
|
55
|
+
<p>{app.description}</p>
|
|
56
|
+
</div>
|
|
57
|
+
<div className="managed-app-links">
|
|
58
|
+
<OriginLink href={app.origin_website_url} label={text.website} />
|
|
59
|
+
<OriginLink href={app.origin_repo_url} label="Repo" />
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
<div className="managed-app-details">
|
|
63
|
+
<ToggleSwitch checked={app.aidevops_install} label="setup installs" />
|
|
64
|
+
<ToggleSwitch checked={app.aidevops_update} label="update maintains" />
|
|
65
|
+
<AppMeta label="Installed" value={app.installed_version} />
|
|
66
|
+
<AppMeta label="Latest" value={app.latest_version} />
|
|
67
|
+
<AppMeta label={text.path} value={app.install_path_ref} />
|
|
68
|
+
<AppMeta label="Status" value={app.status} />
|
|
69
|
+
</div>
|
|
70
|
+
<div className="managed-app-actions">
|
|
71
|
+
{app.actions.map((action) => <AppActionButton action={action.id} app={app} disabled={!action.enabled} key={action.id} onJob={onJob} title={action.command_preview} />)}
|
|
72
|
+
</div>
|
|
73
|
+
</article>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function OriginLink({ href, label }: { href: string; label: string }): ReactElement {
|
|
78
|
+
if (href.length === 0) {
|
|
79
|
+
return <span className="origin-missing">{label}: source pending</span>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return <a href={href} rel="noreferrer" target="_blank">{label} <FiExternalLink aria-hidden="true" /></a>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function ToggleSwitch({ checked, label }: { checked: boolean; label: string }): ReactElement {
|
|
86
|
+
return <span className="managed-toggle"><span aria-hidden="true" className={checked ? "switch-track checked" : "switch-track"}><span /></span>{label}</span>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function AppMeta({ label, value }: { label: string; value: string }): ReactElement {
|
|
90
|
+
return <span className="app-meta"><small>{label}</small><strong>{value}</strong></span>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function AppActionButton({ action, app, disabled, onJob, title }: { action: GuiAppActionId; app: GuiManagedAppSummary; disabled: boolean; onJob: (job: GuiAppActionJobSummary) => void; title: string }): ReactElement {
|
|
94
|
+
const icon = action === "install" ? <FiDownload /> : action === "update" ? <FiRefreshCw /> : action === "reinstall" ? <FiRepeat /> : <FiTrash2 />;
|
|
95
|
+
|
|
96
|
+
async function runAction(): Promise<void> {
|
|
97
|
+
if (disabled) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if ((action === "remove" || action === "reinstall") && !window.confirm(`${action} ${app.name}?`)) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const response = await fetch(`/api/apps/${encodeURIComponent(app.id)}/actions/${action}`, { method: "POST" });
|
|
105
|
+
const envelope = await response.json() as GuiResponseEnvelope<GuiAppActionJobSummary>;
|
|
106
|
+
onJob(envelope.data);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return <button aria-label={`${action} ${app.name}`} className="app-action-button" disabled={disabled} onClick={() => void runAction()} title={title} type="button">{icon}</button>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function AppActionTerminal({ job }: { job: GuiAppActionJobSummary }): ReactElement {
|
|
113
|
+
return (
|
|
114
|
+
<section className="app-terminal" aria-label="App action terminal output">
|
|
115
|
+
<header><strong>{job.app_id} {job.action}</strong><span>{job.status}{job.exit_code === null ? "" : ` (${job.exit_code})`}</span></header>
|
|
116
|
+
<pre>{job.output.join("\n")}</pre>
|
|
26
117
|
</section>
|
|
27
118
|
);
|
|
28
119
|
}
|
|
29
120
|
|
|
121
|
+
async function refreshJob(jobId: string, setJobs: Dispatch<SetStateAction<Record<string, GuiAppActionJobSummary>>>): Promise<void> {
|
|
122
|
+
const response = await fetch(`/api/apps/jobs/${encodeURIComponent(jobId)}`);
|
|
123
|
+
if (!response.ok) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const envelope = await response.json() as GuiResponseEnvelope<GuiAppActionJobSummary>;
|
|
127
|
+
setJobs((current) => ({ ...current, [envelope.data.id]: envelope.data }));
|
|
128
|
+
}
|
|
129
|
+
|
|
30
130
|
export function InstallationSurface(): ReactElement {
|
|
31
131
|
return (
|
|
32
132
|
<section className="panel" aria-label={text.installation}>
|
|
@@ -141,7 +141,7 @@ export const text = {
|
|
|
141
141
|
aidevops: "aidevops",
|
|
142
142
|
appShell: "App shell",
|
|
143
143
|
apps: "Apps",
|
|
144
|
-
appsIntro: "App and CLI inventory for tools installed or updated by aidevops
|
|
144
|
+
appsIntro: "App and CLI inventory for tools installed or updated by aidevops, with origin links, install/update policy, versions, paths, and allowlisted background actions.",
|
|
145
145
|
appStatusPending: "adapter pending",
|
|
146
146
|
brands: "Brands",
|
|
147
147
|
brandsIntro: "Draft brand inventory for names and website references.",
|
|
@@ -420,15 +420,6 @@ export const fileRootBySurface: Partial<Record<SurfaceId, GuiFileRootId>> = {
|
|
|
420
420
|
git: "git",
|
|
421
421
|
};
|
|
422
422
|
|
|
423
|
-
export const appRows = [
|
|
424
|
-
{ name: "aidevops", latest: text.appStatusPending, channel: "setup/update", website: "https://aidevops.sh" },
|
|
425
|
-
{ name: "OpenCode", latest: text.appStatusPending, channel: "runtime", website: text.appStatusPending },
|
|
426
|
-
{ name: "Bun", latest: text.appStatusPending, channel: "toolchain", website: text.appStatusPending },
|
|
427
|
-
{ name: "GitHub CLI", latest: text.appStatusPending, channel: "git", website: text.appStatusPending },
|
|
428
|
-
{ name: "ShellCheck", latest: text.appStatusPending, channel: "quality", website: text.appStatusPending },
|
|
429
|
-
{ name: "Biome", latest: text.appStatusPending, channel: "quality", website: text.appStatusPending },
|
|
430
|
-
];
|
|
431
|
-
|
|
432
423
|
export const installationRows = [
|
|
433
424
|
{ name: "OpenCode runtime", install: true, update: true, scope: "core" },
|
|
434
425
|
{ name: "GUI desktop launcher", install: true, update: true, scope: "local" },
|
|
@@ -582,7 +573,7 @@ export function surfaceRecordCounts(status: GuiStatusData): SurfaceRecordCounts
|
|
|
582
573
|
return {
|
|
583
574
|
agents: status.capabilities.length,
|
|
584
575
|
aiProviders: oauthAccounts,
|
|
585
|
-
apps:
|
|
576
|
+
apps: status.managed_apps.length || status.ai_apps.length,
|
|
586
577
|
config: status.settings.key_count || status.settings.keys.length,
|
|
587
578
|
git: status.local_repos.total || status.local_repos.repos.length,
|
|
588
579
|
hosts: populatedInventoryRowCount("hosts"),
|