aidevops 3.28.0 → 3.29.1

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.
@@ -135,6 +135,8 @@ set -euo pipefail
135
135
  REPO_ROOT="${root}"
136
136
  API_PORT="\${AIDEVOPS_GUI_API_PORT:-8787}"
137
137
  WEB_PORT="\${AIDEVOPS_GUI_WEB_PORT:-5173}"
138
+ API_HEALTH_URL="http://127.0.0.1:\${API_PORT}/api/health"
139
+ WEB_HEALTH_URL="http://127.0.0.1:\${WEB_PORT}/"
138
140
  LOG_DIR="\${HOME}/Library/Logs/aidevops-gui"
139
141
  LAUNCHER_LOG="\${LOG_DIR}/launcher.log"
140
142
  BUN_BIN=""
@@ -223,20 +225,18 @@ replace_stale_gui_on_port() {
223
225
  local pid=""
224
226
  local cwd=""
225
227
 
226
- if ! url_ready "\${health_url}"; then
227
- return 0
228
+ if url_ready "\${health_url}"; then
229
+ while IFS= read -r pid; do
230
+ if [[ -z "\${pid}" ]]; then
231
+ continue
232
+ fi
233
+ cwd="\$(pid_cwd "\${pid}" || true)"
234
+ if [[ "\${cwd}" == "\${REPO_ROOT}" ]]; then
235
+ return 0
236
+ fi
237
+ done < <(port_pids "\${port}")
228
238
  fi
229
239
 
230
- while IFS= read -r pid; do
231
- if [[ -z "\${pid}" ]]; then
232
- continue
233
- fi
234
- cwd="\$(pid_cwd "\${pid}" || true)"
235
- if [[ "\${cwd}" == "\${REPO_ROOT}" ]]; then
236
- return 0
237
- fi
238
- done < <(port_pids "\${port}")
239
-
240
240
  while IFS= read -r pid; do
241
241
  if [[ -z "\${pid}" ]]; then
242
242
  continue
@@ -274,16 +274,6 @@ stop_gui_services() {
274
274
  return 0
275
275
  }
276
276
 
277
- api_contract_current() {
278
- local payload=""
279
-
280
- payload="\$(curl --silent --fail --max-time 2 "http://127.0.0.1:\${API_PORT}/api/status" 2>/dev/null || true)"
281
- if [[ "\${payload}" == *'"local_repos"'* && "\${payload}" == *'"oauth_pool"'* ]]; then
282
- return 0
283
- fi
284
- return 1
285
- }
286
-
287
277
  if [[ "\${MODE}" == "stop" ]]; then
288
278
  stop_gui_services
289
279
  exit 0
@@ -297,36 +287,33 @@ if [[ -f "\${HOME}/.aidevops/agents/VERSION" && -f "\${REPO_ROOT}/VERSION" ]]; t
297
287
  fi
298
288
  fi
299
289
 
300
- replace_stale_gui_on_port "\${API_PORT}" "http://127.0.0.1:\${API_PORT}/api/status"
301
- replace_stale_gui_on_port "\${WEB_PORT}" "http://127.0.0.1:\${WEB_PORT}/"
302
- if url_ready "http://127.0.0.1:\${API_PORT}/api/status" && ! api_contract_current; then
303
- kill_gui_on_port_for_repo "\${API_PORT}"
304
- kill_gui_on_port_for_repo "\${WEB_PORT}"
305
- fi
290
+ replace_stale_gui_on_port "\${API_PORT}" "\${API_HEALTH_URL}"
291
+ replace_stale_gui_on_port "\${WEB_PORT}" "\${WEB_HEALTH_URL}"
306
292
  sleep 1
307
293
 
308
- if ! url_ready "http://127.0.0.1:\${API_PORT}/api/status" || ! url_ready "http://127.0.0.1:\${WEB_PORT}/"; then
294
+ if ! url_ready "\${API_HEALTH_URL}" || ! url_ready "\${WEB_HEALTH_URL}"; then
309
295
  require_bun
310
296
  fi
311
297
 
312
- if ! url_ready "http://127.0.0.1:\${API_PORT}/api/status"; then
298
+ if ! url_ready "\${API_HEALTH_URL}"; then
313
299
  nohup env AIDEVOPS_GUI_API_PORT="\${API_PORT}" "\${BUN_BIN}" run packages/gui-api/src/server.ts >"\${LOG_DIR}/api.log" 2>&1 &
314
300
  fi
315
- if ! url_ready "http://127.0.0.1:\${WEB_PORT}/"; then
301
+ if ! url_ready "\${WEB_HEALTH_URL}"; then
316
302
  nohup "\${BUN_BIN}" ./node_modules/vite/bin/vite.js --config packages/gui-web/vite.config.ts --host 127.0.0.1 --port "\${WEB_PORT}" >"\${LOG_DIR}/web.log" 2>&1 &
317
303
  fi
318
304
 
319
- if ! wait_for_url "http://127.0.0.1:\${API_PORT}/api/status" 20 1; then
305
+ if ! wait_for_url "\${API_HEALTH_URL}" 20 0.25; then
320
306
  printf 'aidevops GUI API did not become ready. Check %s/api.log.\n' "\${LOG_DIR}" >"\${LAUNCHER_LOG}"
321
307
  notify "aidevops GUI API did not become ready. Check \${LOG_DIR}/api.log."
322
308
  exit 1
323
309
  fi
324
- if ! wait_for_url "http://127.0.0.1:\${WEB_PORT}/" 30 1; then
310
+ if ! wait_for_url "\${WEB_HEALTH_URL}" 30 0.25; then
325
311
  printf 'aidevops GUI web did not become ready. Check %s/web.log.\n' "\${LOG_DIR}" >"\${LAUNCHER_LOG}"
326
312
  notify "aidevops GUI web did not become ready. Check \${LOG_DIR}/web.log."
327
313
  exit 1
328
314
  fi
329
315
 
316
+ printf 'aidevops GUI services ready: API %s, web %s\n' "\${API_HEALTH_URL}" "\${WEB_HEALTH_URL}" >"\${LAUNCHER_LOG}"
330
317
  exit 0
331
318
  LAUNCHER
332
319
  chmod 755 "$helper_path"
@@ -362,6 +349,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate,
362
349
  private let mainWindowFrameAutosaveName = "aidevops-main-window"
363
350
  private let titlebarHeight: CGFloat = 24
364
351
  private let serviceQueue = DispatchQueue(label: "sh.aidevops.gui.services")
352
+ private var hasLoadedDashboard = false
365
353
  private var servicesStopped = false
366
354
 
367
355
  func applicationDidFinishLaunching(_ notification: Notification) {
@@ -604,7 +592,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate,
604
592
  @objc private func openDashboard(_ sender: Any?) {
605
593
  window.makeKeyAndOrderFront(nil)
606
594
  NSApp.activate(ignoringOtherApps: true)
607
- loadAppURL()
595
+ if !hasLoadedDashboard {
596
+ loadAppURL()
597
+ }
608
598
  }
609
599
 
610
600
  @objc private func hideWindow(_ sender: Any?) {
@@ -895,12 +885,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate,
895
885
  loadStatusHTML(title: "Invalid aidevops URL", detail: "The configured web port is not valid: \(webPort)")
896
886
  return
897
887
  }
888
+ hasLoadedDashboard = true
898
889
  webView.load(URLRequest(url: url))
899
890
  }
900
891
 
901
892
  private func loadStatusHTML(title: String, detail: String) {
902
893
  let escapedTitle = escapeHTML(title)
903
894
  let escapedDetail = escapeHTML(detail).replacingOccurrences(of: "\n", with: "<br>")
895
+ let contentHTML: String
896
+ if title == "Starting aidevops" {
897
+ contentHTML = """
898
+ <main class="shell" aria-busy="true" aria-label="Loading aidevops interface">
899
+ <section class="brand" aria-label="Starting aidevops"><strong><span>aidevops</span><b>&gt;</b><em>_</em></strong><small>Preparing local GUI</small></section>
900
+ <aside class="rail panel"><i></i><i></i><i></i><i></i><i></i><i></i></aside>
901
+ <aside class="sidebar panel"><header><b></b><span></span></header><nav><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></nav><footer><i></i><i></i></footer></aside>
902
+ <section class="workspace panel"><header><b></b><span></span><i></i><i></i></header><article><h1>\(escapedTitle)</h1><p>\(escapedDetail)</p><div class="cards"><i></i><i></i><i></i><i></i><i></i><i></i></div><div class="block"></div></article></section>
903
+ <footer class="status"><i></i><span></span><span></span><span></span><span></span><span></span></footer>
904
+ </main>
905
+ """
906
+ } else {
907
+ contentHTML = "<main class=\"message\"><h1>\(escapedTitle)</h1><p>\(escapedDetail)</p></main>"
908
+ }
904
909
  let html = """
905
910
  <!doctype html>
906
911
  <html lang="en">
@@ -908,14 +913,48 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate,
908
913
  <meta charset="utf-8">
909
914
  <meta name="viewport" content="width=device-width, initial-scale=1">
910
915
  <style>
911
- body { align-items: center; background: #0a0a0a; color: #f6f7f2; display: flex; font: 15px -apple-system, BlinkMacSystemFont, sans-serif; height: 100vh; justify-content: center; margin: 0; }
912
- main { background: #11130f; border: 1px solid #2c3324; border-radius: 18px; max-width: 560px; padding: 28px; }
916
+ :root { --accent: #b2e969; --border: #2c3324; --panel: #11130f; --muted: #b9c3b0; }
917
+ * { box-sizing: border-box; }
918
+ body { background: radial-gradient(ellipse at top left, rgb(178 233 105 / 10%), transparent 34%), #0a0a0a; color: #f6f7f2; font: 15px -apple-system, BlinkMacSystemFont, sans-serif; height: 100vh; margin: 0; }
919
+ .message { background: var(--panel); border: 1px solid var(--border); border-radius: 18px; left: 50%; max-width: 560px; padding: 28px; position: fixed; top: 50%; transform: translate(-50%, -50%); }
913
920
  h1 { font-size: 22px; margin: 0 0 10px; }
914
921
  p { color: #b9c3b0; line-height: 1.5; margin: 0; }
915
922
  code { color: #b2e969; }
923
+ .shell { display: grid; gap: 8px; grid-template-columns: 72px 302px minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) 30px; height: 100vh; padding: 30px 8px 8px; }
924
+ .brand { align-items: center; backdrop-filter: blur(18px); background: rgb(17 19 15 / 78%); border: 1px solid rgb(178 233 105 / 34%); border-radius: 24px; box-shadow: 0 24px 90px rgb(178 233 105 / 14%), 0 20px 80px rgb(0 0 0 / 35%); display: grid; gap: 10px; justify-items: center; left: 50%; padding: 26px 30px; position: fixed; top: 50%; transform: translate(-50%, -50%); width: min(360px, calc(100vw - 48px)); z-index: 20; }
925
+ .brand strong { align-items: baseline; display: inline-flex; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: clamp(34px, 7vw, 64px); font-weight: 900; letter-spacing: -0.08em; line-height: .9; }
926
+ .brand span { color: #f6f7f2; font-size: .48em; letter-spacing: -0.04em; margin-right: .18em; }
927
+ .brand b, .brand em { color: var(--accent); font-style: normal; }
928
+ .brand em { animation: cursor 0.95s steps(1, end) infinite; }
929
+ .brand small { color: var(--muted); font-size: 11px; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; }
930
+ .panel, .status { background: rgb(17 19 15 / 92%); border: 1px solid var(--border); border-radius: 18px; box-shadow: inset 0 1px 0 rgb(255 255 255 / 8%); overflow: hidden; }
931
+ .rail { align-content: start; display: grid; gap: 10px; justify-items: center; padding: 12px 8px; }
932
+ .rail i, .sidebar b, .workspace b { border-radius: 12px; height: 44px; width: 44px; }
933
+ .sidebar { display: grid; grid-template-rows: auto 1fr auto; }
934
+ .sidebar header, .workspace header { align-items: center; display: flex; gap: 12px; padding: 16px; }
935
+ .sidebar header span { flex: 1; height: 18px; }
936
+ .sidebar nav, .sidebar footer, .workspace article { display: grid; gap: 12px; padding: 16px; }
937
+ .sidebar nav i { height: 18px; }
938
+ .sidebar footer { border-top: 1px solid var(--border); }
939
+ .workspace { display: grid; grid-template-rows: auto 1fr; }
940
+ .workspace header { border-bottom: 1px solid var(--border); }
941
+ .workspace header span { flex: 1; height: 38px; }
942
+ .workspace header i { border-radius: 999px; height: 34px; width: 34px; }
943
+ .workspace article { align-content: start; gap: 18px; }
944
+ .cards { display: grid; gap: 14px; grid-template-columns: repeat(3, minmax(0, 1fr)); }
945
+ .cards i { border-radius: 18px; height: 128px; }
946
+ .block { border-radius: 20px; height: 220px; }
947
+ .status { align-items: center; display: flex; gap: 10px; grid-column: 1 / -1; justify-content: center; padding: 0 12px; }
948
+ .status i { border-radius: 999px; height: 8px; width: 8px; }
949
+ .status span { height: 10px; width: 96px; }
950
+ .rail i, .sidebar b, .sidebar span, .sidebar i, .workspace b, .workspace span, .workspace i, .cards i, .block, .status i, .status span { animation: pulse 1.35s ease-in-out infinite; background: linear-gradient(90deg, #1a2116, rgb(178 233 105 / 18%), #1a2116); background-size: 220% 100%; border: 1px solid var(--border); border-radius: 999px; display: block; }
951
+ .block, .cards i { border-radius: 18px; }
952
+ @keyframes pulse { 0% { background-position: 120% 0; opacity: .52; } 50% { opacity: .95; } 100% { background-position: -120% 0; opacity: .52; } }
953
+ @keyframes cursor { 0%, 48% { opacity: 1; } 49%, 100% { opacity: 0; } }
954
+ @media (prefers-reduced-motion: reduce) { .rail i, .sidebar b, .sidebar span, .sidebar i, .workspace b, .workspace span, .workspace i, .cards i, .block, .status i, .status span, .brand em { animation: none; } }
916
955
  </style>
917
956
  </head>
918
- <body><main><h1>\(escapedTitle)</h1><p>\(escapedDetail)</p></main></body>
957
+ <body>\(contentHTML)</body>
919
958
  </html>
920
959
  """
921
960
  webView.loadHTMLString(html, baseURL: nil)
@@ -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,16 @@ 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
+ export const loadingSkeletonPanelLabels = ["machine rail", "sidebar", "workspace", "status bar"] as const;
43
+ export const loadingBrandGlyph = ">_";
44
+
38
45
  export interface StoredAppearancePreferences {
39
46
  accentHue: number;
40
47
  fontPreference: FontPreference;
@@ -65,6 +72,7 @@ export function readStoredAppearancePreferences(storage: ReadableAppearanceStora
65
72
  export function App(): ReactElement {
66
73
  const [storedAppearancePreferences] = useState<StoredAppearancePreferences>(() => readStoredAppearancePreferences());
67
74
  const [status, setStatus] = useState<GuiResponseEnvelope<GuiStatusData>>(mockedStatus());
75
+ const [statusLoading, setStatusLoading] = useState(true);
68
76
  const [navigation, setNavigation] = useState<NavigationHistory>({ entries: ["overview"], index: 0 });
69
77
  const [themePreference, setThemePreference] = useState<ThemePreference>(storedAppearancePreferences.themePreference);
70
78
  const [accentHue, setAccentHue] = useState(storedAppearancePreferences.accentHue);
@@ -76,6 +84,7 @@ export function App(): ReactElement {
76
84
  const [shellMode, setShellMode] = useState<ShellMode>("devices");
77
85
  const [conversationMode, setConversationMode] = useState<ConversationMode>("ai");
78
86
  const [selectedLocalRepoIndex, setSelectedLocalRepoIndex] = useState(0);
87
+ const [selectedSessionId, setSelectedSessionId] = useState<string | undefined>();
79
88
  const [systemTheme, setSystemTheme] = useState<"light" | "dark">("light");
80
89
  const resolvedTheme = themePreference === "system" ? systemTheme : themePreference;
81
90
  const activeSurface: SurfaceId = navigation.entries[navigation.index] ?? "overview";
@@ -153,7 +162,8 @@ export function App(): ReactElement {
153
162
  useEffect(() => {
154
163
  fetchStatus()
155
164
  .then(setStatus)
156
- .catch(() => setStatus(mockedStatus()));
165
+ .catch(() => setStatus(mockedStatus()))
166
+ .finally(() => setStatusLoading(false));
157
167
  }, []);
158
168
 
159
169
  useEffect(() => {
@@ -173,6 +183,10 @@ export function App(): ReactElement {
173
183
  setSelectedLocalRepoIndex((current) => Math.min(current, Math.max(0, status.data.local_repos.repos.length - 1)));
174
184
  }, [status.data.local_repos.repos.length]);
175
185
 
186
+ if (statusLoading) {
187
+ return <AppLoadingSkeleton machineRailVisible={machineRailVisible} />;
188
+ }
189
+
176
190
  return (
177
191
  <main className={machineRailVisible ? "app-shell" : "app-shell machine-rail-collapsed"}>
178
192
  <div className="desktop-titlebar-tagline" aria-hidden="true">Your data protected. Your systems managed. Your creations published.</div>
@@ -184,12 +198,14 @@ export function App(): ReactElement {
184
198
  fontSizePreference={fontSizePreference}
185
199
  fontPreference={fontPreference}
186
200
  selectedLocalRepoIndex={selectedLocalRepoIndex}
201
+ selectedSessionId={selectedSessionId}
187
202
  setAccentHue={setAccentHue}
188
203
  setActiveSurface={setActiveSurface}
189
204
  setConversationMode={setConversationMode}
190
205
  setFontSizePreference={setFontSizePreference}
191
206
  setFontPreference={setFontPreference}
192
207
  setSelectedLocalRepoIndex={setSelectedLocalRepoIndex}
208
+ setSelectedSessionId={setSelectedSessionId}
193
209
  setShellMode={setShellMode}
194
210
  setShowBorders={setShowBorders}
195
211
  setShowNavCounts={setShowNavCounts}
@@ -200,6 +216,7 @@ export function App(): ReactElement {
200
216
  status={status.data}
201
217
  themePreference={themePreference}
202
218
  />
219
+ <SidebarResizeHandle />
203
220
  <Workspace
204
221
  activeItem={activeItem}
205
222
  activeSectionLabel={activeSectionLabel}
@@ -211,8 +228,11 @@ export function App(): ReactElement {
211
228
  goBack={goBack}
212
229
  goForward={goForward}
213
230
  selectedLocalRepoIndex={selectedLocalRepoIndex}
231
+ selectedSessionId={selectedSessionId}
214
232
  setActiveSurface={setActiveSurface}
215
233
  setConversationMode={setConversationMode}
234
+ setSelectedLocalRepoIndex={setSelectedLocalRepoIndex}
235
+ setSelectedSessionId={setSelectedSessionId}
216
236
  setShellMode={setShellMode}
217
237
  shellMode={shellMode}
218
238
  status={status.data}
@@ -222,6 +242,115 @@ export function App(): ReactElement {
222
242
  );
223
243
  }
224
244
 
245
+ export function AppLoadingSkeleton({ machineRailVisible }: { machineRailVisible: boolean }): ReactElement {
246
+ return (
247
+ <main className={machineRailVisible ? "app-shell app-loading-shell" : "app-shell app-loading-shell machine-rail-collapsed"} aria-busy="true" aria-label="Loading aidevops interface">
248
+ <div className="desktop-titlebar-tagline loading-line loading-titlebar" aria-hidden="true" />
249
+ <LoadingBrandOverlay />
250
+ {machineRailVisible ? (
251
+ <section className="machine-rail loading-panel" aria-label={loadingSkeletonPanelLabels[0]}>
252
+ {Array.from({ length: 6 }, (_, index) => <span className="loading-orb" key={index} />)}
253
+ </section>
254
+ ) : null}
255
+ <section className="app-sidebar loading-panel" aria-label={loadingSkeletonPanelLabels[1]}>
256
+ <div className="loading-sidebar-header">
257
+ <span className="loading-logo" />
258
+ <span className="loading-line loading-line-wide" />
259
+ </div>
260
+ <span className="loading-pill" />
261
+ <div className="loading-list">
262
+ {Array.from({ length: 12 }, (_, index) => <span className={index % 3 === 0 ? "loading-line loading-line-short" : "loading-line"} key={index} />)}
263
+ </div>
264
+ <div className="loading-sidebar-footer">
265
+ <span className="loading-pill" />
266
+ <span className="loading-line" />
267
+ </div>
268
+ </section>
269
+ <SidebarResizeHandle />
270
+ <section className="app-inset loading-panel" aria-label={loadingSkeletonPanelLabels[2]}>
271
+ <div className="workspace-header loading-workspace-header">
272
+ <span className="loading-icon" />
273
+ <span className="loading-line loading-line-wide" />
274
+ <span className="loading-search" />
275
+ <span className="loading-circle" />
276
+ <span className="loading-circle" />
277
+ </div>
278
+ <div className="loading-workspace-body">
279
+ <span className="loading-line loading-line-hero" />
280
+ <div className="loading-card-grid">
281
+ {Array.from({ length: 6 }, (_, index) => <span className="loading-card" key={index} />)}
282
+ </div>
283
+ <span className="loading-panel-block" />
284
+ </div>
285
+ </section>
286
+ <div className="desktop-status-bar loading-status-bar" aria-label={loadingSkeletonPanelLabels[3]}>
287
+ {Array.from({ length: 8 }, (_, index) => <span className={index === 0 ? "loading-dot" : "loading-line"} key={index} />)}
288
+ </div>
289
+ </main>
290
+ );
291
+ }
292
+
293
+ function LoadingBrandOverlay(): ReactElement {
294
+ return (
295
+ <div className="loading-brand-overlay" aria-label="Starting aidevops">
296
+ <span className="loading-brand-mark" aria-hidden="true">
297
+ <span className="loading-brand-word">aidevops</span>
298
+ <span className="loading-brand-chevron">&gt;</span>
299
+ <span className="loading-brand-cursor">_</span>
300
+ </span>
301
+ <span className="loading-brand-status">Preparing local GUI</span>
302
+ </div>
303
+ );
304
+ }
305
+
306
+ export function clampSidebarWidth(width: number): number {
307
+ return Math.min(maxSidebarWidth, Math.max(minSidebarWidth, Math.round(width)));
308
+ }
309
+
310
+ function SidebarResizeHandle(): ReactElement {
311
+ const [sidebarWidth, setSidebarWidth] = useState(() => readStoredSidebarWidth());
312
+
313
+ useEffect(() => {
314
+ document.documentElement.style.setProperty("--sidebar-width", `${sidebarWidth}px`);
315
+ persistAppearancePreference(appearanceStorageKeys.sidebarWidth, String(sidebarWidth));
316
+ }, [sidebarWidth]);
317
+
318
+ const startSidebarResize = (event: ReactMouseEvent<HTMLElement>) => {
319
+ const startX = event.clientX;
320
+ const startWidth = sidebarWidth;
321
+ const resizeSidebar = (moveEvent: MouseEvent) => setSidebarWidth(clampSidebarWidth(startWidth + moveEvent.clientX - startX));
322
+ const stopResize = () => {
323
+ document.documentElement.classList.remove("resizing-sidebar");
324
+ window.removeEventListener("mousemove", resizeSidebar);
325
+ window.removeEventListener("mouseup", stopResize);
326
+ };
327
+
328
+ event.preventDefault();
329
+ document.documentElement.classList.add("resizing-sidebar");
330
+ window.addEventListener("mousemove", resizeSidebar);
331
+ window.addEventListener("mouseup", stopResize);
332
+ };
333
+
334
+ const resizeSidebarWithKeyboard = (event: ReactKeyboardEvent<HTMLElement>) => {
335
+ if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
336
+ event.preventDefault();
337
+ setSidebarWidth((current) => clampSidebarWidth(current + (event.key === "ArrowRight" ? 16 : -16)));
338
+ }
339
+
340
+ if (event.key === "Home" || event.key === "End") {
341
+ event.preventDefault();
342
+ setSidebarWidth(event.key === "Home" ? minSidebarWidth : maxSidebarWidth);
343
+ }
344
+ };
345
+
346
+ 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} />;
347
+ }
348
+
349
+ function readStoredSidebarWidth(storage: ReadableAppearanceStorage | undefined = browserLocalStorage()): number {
350
+ const savedWidth = Number.parseInt(storage?.getItem(appearanceStorageKeys.sidebarWidth) ?? "", 10);
351
+ return Number.isFinite(savedWidth) ? clampSidebarWidth(savedWidth) : defaultSidebarWidth;
352
+ }
353
+
225
354
  function sendNativeAccentHue(hue: number): void {
226
355
  const webkit = (window as WebKitBridgeWindow).webkit;
227
356
  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>