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.
@@ -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",
@@ -162,6 +162,7 @@ export const statusFixture: GuiStatusData = {
162
162
  needs_update: false,
163
163
  },
164
164
  ],
165
+ managed_apps: [],
165
166
  vault: {
166
167
  status: "uninitialized",
167
168
  setup_state: "uninitialized",
@@ -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, index) => <li key={session.id_ref}><button className={index === 0 ? "surface-link active" : "surface-link"} 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>)}
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 }: { conversationMode?: ConversationMode; shellMode?: ShellMode; surface: SurfaceId }) => {
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(normalizedQuery))
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 { type ReactElement, useState } from "react";
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 { appRows, installationRows, text } from "./app-model";
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="data-table">
21
- <div className="data-row header-row"><span>{text.name}</span><span>{text.latest}</span><span>{text.channel}</span><span>{text.website}</span></div>
22
- {appRows.map((row) => (
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. Version and homepage adapters are planned.",
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: appRows.length,
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"),