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.
- 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 +133 -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-api/src/status-vault.ts +1 -1
- package/packages/gui-desktop/scripts/install-macos-app.sh +76 -37
- 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 +131 -2
- 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 +443 -5
- package/setup.sh +1 -1
|
@@ -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
|
|
227
|
-
|
|
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}" "
|
|
301
|
-
replace_stale_gui_on_port "\${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 "
|
|
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 "
|
|
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 "
|
|
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 "
|
|
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 "
|
|
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
|
-
|
|
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>></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
|
-
|
|
912
|
-
|
|
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
|
|
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",
|
|
@@ -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">></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
|
|
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>
|