aidevops 3.29.9 → 3.29.12

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 CHANGED
@@ -58,7 +58,7 @@ The result: an AI operations platform that manages projects across every busines
58
58
  [![Copyright](https://img.shields.io/badge/Copyright-Marcus%20Quinn%202025--2026-blue.svg)](https://github.com/marcusquinn)
59
59
 
60
60
  <!-- Release & Version Info -->
61
- [![Version](https://img.shields.io/badge/Version-3.29.9-blue.svg)](https://github.com/marcusquinn/aidevops/releases)
61
+ [![Version](https://img.shields.io/badge/Version-3.29.12-blue.svg)](https://github.com/marcusquinn/aidevops/releases)
62
62
  [![npm version](https://img.shields.io/npm/v/aidevops)](https://www.npmjs.com/package/aidevops)
63
63
  [![Homebrew](https://img.shields.io/badge/homebrew-marcusquinn%2Ftap-orange)](https://github.com/marcusquinn/homebrew-tap)
64
64
  [![GitHub repository](https://img.shields.io/badge/github-repository-181717.svg?logo=github)](https://github.com/marcusquinn/aidevops)
package/VERSION CHANGED
@@ -1 +1 @@
1
- 3.29.9
1
+ 3.29.12
package/aidevops.sh CHANGED
@@ -5,7 +5,7 @@
5
5
  # AI DevOps Framework CLI
6
6
  # Usage: aidevops <command> [options]
7
7
  #
8
- # Version: 3.29.9
8
+ # Version: 3.29.12
9
9
 
10
10
  set -euo pipefail
11
11
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aidevops",
3
- "version": "3.29.9",
3
+ "version": "3.29.12",
4
4
  "description": "AI DevOps Framework - AI-assisted development workflows, code quality, and deployment automation",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -1,4 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
+ import * as readline from "node:readline";
2
3
  import { Hono } from "hono";
3
4
  import { APP_ACTION_ROUTE_MANIFEST, APP_ACTION_STATUS_ROUTE_MANIFEST, BANNED_ROUTE_PATTERNS, createEnvelope, FILE_EXPLORER_ROUTE_MANIFEST, type GuiAppActionId, type GuiAppActionJobSummary, STATUS_ROUTE_MANIFEST, VAULT_STATUS_ROUTE_MANIFEST } from "../../gui-shared/src";
4
5
  import { readFileExplorer } from "./file-adapter";
@@ -10,6 +11,7 @@ const appActionCommands: Record<string, Partial<Record<GuiAppActionId, string[]>
10
11
  "gui-desktop": { install: ["aidevops", "setup", "--scope", "gui-desktop"], update: ["aidevops", "setup", "--scope", "gui-desktop"], reinstall: ["aidevops", "setup", "--scope", "gui-desktop"] },
11
12
  hooks: { install: ["aidevops", "setup", "--scope", "hooks"], update: ["aidevops", "setup", "--scope", "hooks"], reinstall: ["aidevops", "setup", "--scope", "hooks"] },
12
13
  opencode: { install: ["aidevops", "setup", "--scope", "opencode"], update: ["aidevops", "setup", "--scope", "opencode"], reinstall: ["aidevops", "setup", "--scope", "opencode"] },
14
+ "opencode-cli": { install: ["aidevops", "setup", "--scope", "opencode"], update: ["aidevops", "setup", "--scope", "opencode"], reinstall: ["aidevops", "setup", "--scope", "opencode"] },
13
15
  pulse: { install: ["aidevops", "setup", "--scope", "pulse"], update: ["aidevops", "setup", "--scope", "pulse"], reinstall: ["aidevops", "setup", "--scope", "pulse"] },
14
16
  tabby: { install: ["aidevops", "setup", "--scope", "tabby"], update: ["aidevops", "setup", "--scope", "tabby"], reinstall: ["aidevops", "setup", "--scope", "tabby"] },
15
17
  bun: { install: ["./setup.sh", "--non-interactive"], update: ["aidevops", "update-tools", "--update"] },
@@ -149,10 +151,16 @@ function startAppActionJob(appId: string, action: GuiAppActionId, command: strin
149
151
  env: { ...process.env, AIDEVOPS_NON_INTERACTIVE: "true" },
150
152
  stdio: ["ignore", "pipe", "pipe"],
151
153
  });
152
- child.stdout.on("data", (chunk) => appendJobOutput(job, chunk.toString()));
153
- child.stderr.on("data", (chunk) => appendJobOutput(job, chunk.toString()));
154
+ if (child.stdout !== null) {
155
+ const stdoutLines = readline.createInterface({ input: child.stdout, terminal: false });
156
+ stdoutLines.on("line", (line) => appendJobLine(job, line));
157
+ }
158
+ if (child.stderr !== null) {
159
+ const stderrLines = readline.createInterface({ input: child.stderr, terminal: false });
160
+ stderrLines.on("line", (line) => appendJobLine(job, line));
161
+ }
154
162
  child.on("error", (error) => {
155
- appendJobOutput(job, error.message);
163
+ appendJobLine(job, error.message);
156
164
  job.status = "failed";
157
165
  job.finished_at = new Date().toISOString();
158
166
  job.exit_code = 127;
@@ -166,8 +174,11 @@ function startAppActionJob(appId: string, action: GuiAppActionId, command: strin
166
174
  return job;
167
175
  }
168
176
 
169
- function appendJobOutput(job: GuiAppActionJobSummary, output: string): void {
170
- job.output.push(...output.split(/\r?\n/).filter((line) => line.length > 0).map(redactOutputLine));
177
+ function appendJobLine(job: GuiAppActionJobSummary, line: string): void {
178
+ if (line.length === 0) {
179
+ return;
180
+ }
181
+ job.output.push(redactOutputLine(line));
171
182
  if (job.output.length > 400) {
172
183
  job.output.splice(1, job.output.length - 400);
173
184
  }
@@ -83,12 +83,12 @@ const MANAGED_APP_DEFINITIONS: ManagedAppDefinition[] = [
83
83
  },
84
84
  {
85
85
  id: "opencode",
86
- name: "OpenCode",
87
- description: "AI terminal runtime configured by aidevops for local sessions.",
88
- category: "ai runtime",
89
- binary: "opencode",
90
- version_args: ["--version"],
91
- install_path_refs: ["~/Applications/OpenCode AIDevOps.app", "/Applications/OpenCode.app", "~/.config/opencode/opencode.json"],
86
+ name: "OpenCode Beta",
87
+ description: "Native OpenCode beta desktop app configured by aidevops.",
88
+ category: "desktop",
89
+ binary: null,
90
+ version_args: [],
91
+ install_path_refs: ["~/Applications/OpenCode AIDevOps.app", "~/Applications/OpenCode.app", "/Applications/OpenCode.app", "~/.config/opencode/opencode.json"],
92
92
  origin_website_url: "https://opencode.ai",
93
93
  origin_repo_url: "",
94
94
  aidevops_install: true,
@@ -96,6 +96,7 @@ const MANAGED_APP_DEFINITIONS: ManagedAppDefinition[] = [
96
96
  latest_version_source: "binary",
97
97
  action_commands: setupScopeActions("opencode"),
98
98
  },
99
+ binaryApp({ id: "opencode-cli", name: "OpenCode CLI", description: "Command-line runtime installed with OpenCode and configured by aidevops.", category: "cli", binary: "opencode", version_args: ["--version"], install_path_refs: ["/opt/homebrew/bin/opencode", "/usr/local/bin/opencode", "~/.bun/bin/opencode", "~/.config/opencode/opencode.json"], origin_website_url: "https://opencode.ai", origin_repo_url: "", aidevops_install: true, aidevops_update: true }),
99
100
  {
100
101
  id: "hooks",
101
102
  name: "Safety hooks",
@@ -139,7 +140,7 @@ const MANAGED_APP_DEFINITIONS: ManagedAppDefinition[] = [
139
140
  binaryApp({ id: "homebrew", name: "Homebrew", description: "macOS package manager used by setup/update when available.", category: "package manager", binary: "brew", version_args: ["--version"], install_path_refs: ["/opt/homebrew/bin/brew", "/usr/local/bin/brew"], origin_website_url: "https://brew.sh", origin_repo_url: "https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh" }),
140
141
  binaryApp({ id: "qlty", name: "Qlty CLI", description: "Optional code quality aggregator installed by setup when selected.", category: "quality", binary: "qlty", version_args: ["--version"], install_path_refs: ["/opt/homebrew/bin/qlty", "/usr/local/bin/qlty", "~/.qlty/bin/qlty"], origin_website_url: "https://qlty.sh", origin_repo_url: "" }),
141
142
  binaryApp({ id: "rtk", name: "rtk", description: "Token-optimized GitHub/API helper used by interactive discovery.", category: "developer tooling", binary: "rtk", version_args: ["--version"], install_path_refs: ["~/.local/bin/rtk", "/opt/homebrew/bin/rtk", "/usr/local/bin/rtk"], origin_website_url: "https://github.com/rtk-ai/rtk#installation", origin_repo_url: "https://github.com/rtk-ai/rtk" }),
142
- binaryApp({ id: "cursor", name: "Cursor CLI", description: "Cursor command-line integration and config targets.", category: "ai runtime", binary: "cursor", version_args: ["--version"], install_path_refs: ["~/.cursor/bin/cursor", "/Applications/Cursor.app"], origin_website_url: "https://cursor.com/install", origin_repo_url: "" }),
143
+ binaryApp({ id: "cursor", name: "Cursor CLI", description: "Cursor command-line integration and config targets.", category: "cli", binary: "cursor", version_args: ["--version"], install_path_refs: ["~/.cursor/bin/cursor", "/Applications/Cursor.app"], origin_website_url: "https://cursor.com/install", origin_repo_url: "" }),
143
144
  binaryApp({ id: "zed", name: "Zed", description: "Optional editor installed by setup when selected.", category: "editor", binary: "zed", version_args: ["--version"], install_path_refs: ["/Applications/Zed.app", "/opt/homebrew/bin/zed", "/usr/local/bin/zed"], origin_website_url: "https://zed.dev/download", origin_repo_url: "", aidevops_install: false, aidevops_update: false }),
144
145
  binaryApp({ id: "orbstack", name: "OrbStack", description: "Optional local container/VM runtime for macOS development.", category: "runtime", binary: "orb", version_args: ["version"], install_path_refs: ["/Applications/OrbStack.app", "/opt/homebrew/bin/orb"], origin_website_url: "https://orbstack.dev/", origin_repo_url: "", aidevops_install: false, aidevops_update: false }),
145
146
  binaryApp({ id: "ollama", name: "Ollama", description: "Optional local model runtime for local AI workflows.", category: "ai runtime", binary: "ollama", version_args: ["--version"], install_path_refs: ["/Applications/Ollama.app", "/opt/homebrew/bin/ollama", "/usr/local/bin/ollama"], origin_website_url: "https://ollama.com", origin_repo_url: "" }),
@@ -221,8 +222,22 @@ function readManagedAppVersion(definition: ManagedAppDefinition, binaryPath: str
221
222
  }
222
223
 
223
224
  if (binaryPath === null) {
224
- return "not installed";
225
+ const existingPathRef = firstExistingPathRef(definition.install_path_refs);
226
+
227
+ if (existingPathRef === null) {
228
+ return "not installed";
229
+ }
230
+
231
+ return readAppBundleVersion(existingPathRef) ?? "installed";
225
232
  }
226
233
 
227
234
  return readBinaryVersion(binaryPath, definition.version_args);
228
235
  }
236
+
237
+ function readAppBundleVersion(pathRef: string): string | null {
238
+ if (!pathRef.endsWith(".app")) {
239
+ return null;
240
+ }
241
+
242
+ return readBinaryVersion("/usr/libexec/PlistBuddy", ["-c", "Print :CFBundleShortVersionString", `${expandHome(pathRef)}/Contents/Info.plist`]);
243
+ }
@@ -17,7 +17,7 @@ const toastSeverityRank: Record<GuiNotificationSeverity, number> = {
17
17
 
18
18
  const warningLineMatchers: Array<(line: string) => boolean> = [
19
19
  (line) => line.startsWith("Pulse stalled"),
20
- (line) => /contribution\(s\) need/i.test(line),
20
+ (line) => /contribution(\(s\)|s)?\s+needs?/i.test(line),
21
21
  (line) => line.startsWith("[OPENCODE MAINTENANCE]"),
22
22
  (line) => line.startsWith("[WARNING]"),
23
23
  (line) => line.startsWith("[WARN]"),
@@ -28,7 +28,7 @@ const toastTitleRules: Array<{ matches: (line: string) => boolean; title: string
28
28
  { matches: (line) => line.startsWith("[ERROR]"), title: "Startup error" },
29
29
  { matches: (line) => line.startsWith("[OPENCODE MAINTENANCE]"), title: "OpenCode maintenance" },
30
30
  { matches: (line) => line.startsWith("Pulse stalled"), title: "Pulse stalled" },
31
- { matches: (line) => /contribution\(s\) need/i.test(line), title: "External contribution review" },
31
+ { matches: (line) => /contribution(\(s\)|s)?\s+needs?/i.test(line), title: "External contribution review" },
32
32
  { matches: (line) => line.startsWith("[WARNING]") || line.startsWith("[WARN]"), title: "Startup warning" },
33
33
  { matches: (line) => line.startsWith("Security: all protections active"), title: "Security protections active" },
34
34
  ];
@@ -93,10 +93,10 @@ function buildToastNotifications(greetingOutput: string): GuiNotificationSummary
93
93
  }
94
94
 
95
95
  function buildGuiStateNotifications(input: BuildNotificationInput): GuiNotificationSummary[] {
96
- const setupNeedsUpdate = input.setupTargets.filter((target) => target.needs_update);
97
- const appNeedsUpdate = input.aiApps.filter((app) => app.needs_update);
98
- const authErrors = input.oauthPool.providers.filter((provider) => provider.auth_errors > 0);
99
- const rateLimited = input.oauthPool.providers.filter((provider) => provider.rate_limited > 0);
96
+ const setupNeedsUpdate = (input.setupTargets ?? []).filter((target) => target.needs_update);
97
+ const appNeedsUpdate = (input.aiApps ?? []).filter((app) => app.needs_update);
98
+ const authErrors = (input.oauthPool?.providers ?? []).filter((provider) => provider.auth_errors > 0);
99
+ const rateLimited = (input.oauthPool?.providers ?? []).filter((provider) => provider.rate_limited > 0);
100
100
  const notifications: GuiNotificationSummary[] = [];
101
101
 
102
102
  if (input.restartRequired) {
@@ -222,6 +222,11 @@ function ConversationSidebar({ conversationMode, selectedLocalRepoIndex, selecte
222
222
  ? []
223
223
  : status.opencode_sessions.sessions.filter((session) => session.repo_path_ref === selectedRepo.path_ref);
224
224
  const activeSessionId = sessions.some((session) => session.id_ref === selectedSessionId) ? selectedSessionId : sessions[0]?.id_ref;
225
+ useEffect(() => {
226
+ if (activeSessionId !== selectedSessionId) {
227
+ setSelectedSessionId(activeSessionId);
228
+ }
229
+ }, [activeSessionId, selectedSessionId, setSelectedSessionId]);
225
230
  const canSelectPreviousRepo = selectedLocalRepoIndex > 0;
226
231
  const canSelectNextRepo = selectedLocalRepoIndex < repos.length - 1;
227
232
 
@@ -189,21 +189,30 @@ function NotificationsMenu({ notifications, openSurface }: { notifications: GuiN
189
189
  <span>{active.length} active</span>
190
190
  </div>
191
191
  {preview.length === 0 ? <p>No current aidevops notifications.</p> : null}
192
- {preview.map((notification) => <NotificationPreview key={notification.id} notification={notification} />)}
192
+ {preview.map((notification) => (
193
+ <NotificationPreview
194
+ key={notification.id}
195
+ notification={notification}
196
+ onClick={() => {
197
+ const action = notification.actions.find((candidate) => candidate.enabled && candidate.kind === "surface" && isSurfaceId(candidate.surface_id));
198
+ openSurface(action?.kind === "surface" && isSurfaceId(action.surface_id) ? action.surface_id : "notifications");
199
+ }}
200
+ />
201
+ ))}
193
202
  <button onClick={() => openSurface("notifications")} role="menuitem" type="button">Open notifications</button>
194
203
  </div>
195
204
  );
196
205
  }
197
206
 
198
- function NotificationPreview({ notification }: { notification: GuiNotificationSummary }): ReactElement {
207
+ function NotificationPreview({ notification, onClick }: { notification: GuiNotificationSummary; onClick: () => void }): ReactElement {
199
208
  return (
200
- <article className={`notification-preview ${notification.severity}`}>
209
+ <button className={`notification-preview ${notification.severity}`} onClick={onClick} role="menuitem" type="button">
201
210
  <span aria-hidden="true"><NotificationIcon notification={notification} /></span>
202
211
  <div>
203
212
  <strong>{notification.title}</strong>
204
213
  <small>{notification.category} · {notification.status}</small>
205
214
  </div>
206
- </article>
215
+ </button>
207
216
  );
208
217
  }
209
218
 
@@ -528,7 +537,7 @@ function NotificationCard({ notification, openSurface }: { notification: GuiNoti
528
537
  return <button className="secondary-action" disabled={!action.enabled} key={action.id} onClick={() => openSurface(surfaceId)} type="button">{action.label}</button>;
529
538
  }
530
539
 
531
- return <button className="secondary-action" disabled key={action.id} title={action.command_preview} type="button">{action.label}</button>;
540
+ return <button className="secondary-action" disabled={!action.enabled} key={action.id} title={action.command_preview} type="button">{action.label}</button>;
532
541
  })}
533
542
  </div>
534
543
  </div>
@@ -1,6 +1,10 @@
1
1
  import type { GuiAppActionId, GuiAppActionJobSummary, GuiManagedAppSummary, GuiResponseEnvelope, GuiStatusData } from "@aidevops/gui-shared";
2
- import { type Dispatch, type ReactElement, type SetStateAction, useEffect, useRef, useState } from "react";
3
- import { FiChevronDown, FiDownload, FiExternalLink, FiRefreshCw, FiRepeat, FiTrash2 } from "react-icons/fi";
2
+ import { type Dispatch, type MouseEvent as ReactMouseEvent, type ReactElement, type SetStateAction, useEffect, useRef, useState } from "react";
3
+ import type { IconType } from "react-icons";
4
+ import { FaApple, FaLinux, FaWindows } from "react-icons/fa";
5
+ import { FiChevronDown, FiCode, FiDownload, FiExternalLink, FiGlobe, FiMonitor, FiRefreshCw, FiRepeat, FiTerminal, FiTrash2 } from "react-icons/fi";
6
+ import { IoLogoAndroid } from "react-icons/io";
7
+ import { SiIos } from "react-icons/si";
4
8
  import type { InventoryColumn } from "./app-model";
5
9
  import { installationRows, text } from "./app-model";
6
10
 
@@ -14,13 +18,17 @@ let draftRowCounter = 0;
14
18
  export function AppsSurface({ status }: { status: GuiStatusData }): ReactElement {
15
19
  const [appCollection, setAppCollection] = useState<AppCollectionId>("aidevops");
16
20
  const [managedCategory, setManagedCategory] = useState<ManagedCategoryId>("core");
21
+ const [recommendedPlatform, setRecommendedPlatform] = useState<RecommendedPlatformFilterId>("all");
17
22
  const [recommendedOs, setRecommendedOs] = useState<RecommendedOsId>("all");
18
23
  const [selectedJobId, setSelectedJobId] = useState<string | null>(null);
19
24
  const [expandedAppId, setExpandedAppId] = useState<string | null>(() => sortedManagedApps(status.managed_apps).find((app) => managedCategoryForApp(app) === "core")?.id ?? null);
20
25
  const [jobs, setJobs] = useState<Record<string, GuiAppActionJobSummary>>({});
26
+ const [policyJobs, setPolicyJobs] = useState<Record<string, GuiAppActionJobSummary[]>>({});
27
+ const [policyToggles, setPolicyToggles] = useState<ManagedPolicyToggleState>(() => readManagedPolicyToggles());
21
28
  const selectedJob = selectedJobId === null ? null : jobs[selectedJobId] ?? null;
22
- const visibleManagedApps = sortedManagedApps(status.managed_apps).filter((app) => managedCategoryForApp(app) === managedCategory);
23
- const visibleRecommendedApps = recommendedApps.filter((app) => recommendedOs === "all" || (app.os as readonly RecommendedOsId[]).includes(recommendedOs));
29
+ const managedApps = sortedManagedApps(status.managed_apps).map((app) => applyManagedPolicyToggles(app, policyToggles[app.id]));
30
+ const visibleManagedApps = managedApps.filter((app) => managedCategoryForApp(app) === managedCategory);
31
+ const visibleRecommendedApps = recommendedApps.filter((app) => recommendedAppMatchesFilters(app, recommendedPlatform, recommendedOs));
24
32
 
25
33
  useEffect(() => {
26
34
  setExpandedAppId(visibleManagedApps[0]?.id ?? null);
@@ -55,22 +63,36 @@ export function AppsSurface({ status }: { status: GuiStatusData }): ReactElement
55
63
  expanded={expandedAppId === app.id}
56
64
  job={jobForApp(app.id, jobs, selectedJob)}
57
65
  key={app.id}
66
+ policyJobs={policyJobs[app.id] ?? []}
58
67
  onJob={(job) => {
59
68
  setJobs((current) => ({ ...current, [job.id]: job }));
60
69
  setSelectedJobId(job.id);
61
70
  setExpandedAppId(app.id);
62
71
  }}
72
+ onPolicyToggle={(policy, value) => {
73
+ setPolicyToggles((current) => {
74
+ const next = { ...current, [app.id]: { ...current[app.id], [policy]: value } };
75
+ writeManagedPolicyToggles(next);
76
+ return next;
77
+ });
78
+ const policyJob = createPolicyJob(app.id, policy, value);
79
+ setPolicyJobs((current) => ({ ...current, [app.id]: [...(current[app.id] ?? []), policyJob] }));
80
+ setSelectedJobId(policyJob.id);
81
+ setExpandedAppId(app.id);
82
+ }}
63
83
  onToggle={() => setExpandedAppId((current) => current === app.id ? null : app.id)}
64
84
  />
65
85
  ))}
66
86
  </div>
67
87
  <p className="empty-state compact-notice">Install/update actions run as allowlisted background jobs and stream inside each app panel. 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>
68
- </> : <RecommendedAppsSurface apps={visibleRecommendedApps} os={recommendedOs} setOs={setRecommendedOs} />}
88
+ </> : <RecommendedAppsSurface apps={visibleRecommendedApps} os={recommendedOs} platform={recommendedPlatform} setOs={setRecommendedOs} setPlatform={setRecommendedPlatform} />}
69
89
  </section>
70
90
  );
71
91
  }
72
92
 
73
- function ManagedAppPanel({ app, expanded, job, onJob, onToggle }: { app: GuiManagedAppSummary; expanded: boolean; job: GuiAppActionJobSummary | null; onJob: (job: GuiAppActionJobSummary) => void; onToggle: () => void }): ReactElement {
93
+ function ManagedAppPanel({ app, expanded, job, onJob, onPolicyToggle, onToggle, policyJobs }: { app: GuiManagedAppSummary; expanded: boolean; job: GuiAppActionJobSummary | null; onJob: (job: GuiAppActionJobSummary) => void; onPolicyToggle: (policy: ManagedPolicyId, value: boolean) => void; onToggle: () => void; policyJobs: GuiAppActionJobSummary[] }): ReactElement {
94
+ const lockedPolicy = isEssentialManagedApp(app);
95
+
74
96
  return (
75
97
  <article className={expanded ? "managed-app-card expanded" : "managed-app-card"}>
76
98
  <button aria-expanded={expanded} className="managed-app-summary" data-tooltip={`${expanded ? "Collapse" : "Expand"} ${app.name} controls`} onClick={onToggle} type="button">
@@ -94,16 +116,18 @@ function ManagedAppPanel({ app, expanded, job, onJob, onToggle }: { app: GuiMana
94
116
  <div className="managed-app-actions">
95
117
  {app.actions.map((action) => <AppActionButton action={action.id} app={app} disabled={!action.enabled} key={action.id} onJob={onJob} commandPreview={action.command_preview} />)}
96
118
  </div>
97
- </div>
119
+ </div>
98
120
  <div className="managed-app-details">
99
- <ToggleSwitch checked={app.aidevops_install} label="setup installs" />
100
- <ToggleSwitch checked={app.aidevops_update} label="update maintains" />
121
+ <ToggleSwitch checked={app.aidevops_install} disabled={lockedPolicy} label="setup installs" onChange={(value) => onPolicyToggle("setup", value)} />
122
+ <ToggleSwitch checked={app.aidevops_update} disabled={lockedPolicy} label="update maintains" onChange={(value) => onPolicyToggle("update", value)} />
101
123
  <AppMeta label="Installed" value={app.installed_version} />
102
124
  <AppMeta label="Latest" value={app.latest_version} />
103
125
  <AppMeta label={text.path} value={app.install_path_ref} />
104
126
  {job ? <AppLogLink job={job} /> : null}
105
127
  </div>
106
- {job ? <AppActionTerminal job={job} /> : <p className="empty-state compact-notice">No recent command output for this app. Run an action to open this app's terminal log.</p>}
128
+ {job ? <AppActionTerminal job={job} /> : null}
129
+ {policyJobs.map((policyJob) => <AppActionTerminal job={policyJob} key={policyJob.id} />)}
130
+ {job === null && policyJobs.length === 0 ? <p className="empty-state compact-notice">No recent command output for this app. Run an action or change a policy toggle to open this app's terminal log.</p> : null}
107
131
  </div> : null}
108
132
  </article>
109
133
  );
@@ -118,13 +142,11 @@ function OriginLink({ href, label }: { href: string; label: string }): ReactElem
118
142
  return <span className="origin-missing">{label}: source pending</span>;
119
143
  }
120
144
 
121
- return <a data-tooltip={href} href={href} rel="noreferrer" target="_blank">{label} <FiExternalLink aria-hidden="true" /></a>;
145
+ return <a href={href} onClick={(event) => openExternalLink(event, href)} rel="noreferrer" target="_blank" title={href}>{label} <FiExternalLink aria-hidden="true" /></a>;
122
146
  }
123
147
 
124
- function ToggleSwitch({ checked, label }: { checked: boolean; label: string }): ReactElement {
125
- const [isChecked, setIsChecked] = useState(checked);
126
-
127
- return <button aria-pressed={isChecked} className={isChecked ? "managed-toggle checked" : "managed-toggle"} onClick={() => setIsChecked((current) => !current)} type="button"><span aria-hidden="true" className={isChecked ? "switch-track checked" : "switch-track"}><span /></span>{label}</button>;
148
+ function ToggleSwitch({ checked, disabled = false, label, onChange }: { checked: boolean; disabled?: boolean; label: string; onChange: (checked: boolean) => void }): ReactElement {
149
+ return <button aria-pressed={checked} className={checked ? "managed-toggle checked" : "managed-toggle"} data-tooltip={disabled ? "Essential aidevops component; policy is locked on" : undefined} disabled={disabled} onClick={() => onChange(!checked)} title={disabled ? "Essential aidevops component; policy is locked on" : undefined} type="button"><span aria-hidden="true" className={checked ? "switch-track checked" : "switch-track"}><span /></span>{label}</button>;
128
150
  }
129
151
 
130
152
  function AppMeta({ label, value }: { label: string; value: string }): ReactElement {
@@ -203,7 +225,14 @@ function jobForApp(appId: string, jobs: Record<string, GuiAppActionJobSummary>,
203
225
  return selectedJob;
204
226
  }
205
227
 
206
- return Object.values(jobs).reverse().find((job) => job.app_id === appId) ?? null;
228
+ let latestJob: GuiAppActionJobSummary | null = null;
229
+ for (const job of Object.values(jobs)) {
230
+ if (job.app_id === appId && (latestJob === null || job.started_at > latestJob.started_at)) {
231
+ latestJob = job;
232
+ }
233
+ }
234
+
235
+ return latestJob;
207
236
  }
208
237
 
209
238
  function sortedManagedApps(apps: GuiManagedAppSummary[]): GuiManagedAppSummary[] {
@@ -214,6 +243,9 @@ function managedCategoryForApp(app: GuiManagedAppSummary): ManagedCategoryId {
214
243
  if (["core", "safety", "automation"].includes(app.category)) {
215
244
  return "core";
216
245
  }
246
+ if (app.category.includes("cli")) {
247
+ return "cli";
248
+ }
217
249
  if (app.category.includes("ai")) {
218
250
  return "ai";
219
251
  }
@@ -227,9 +259,17 @@ function managedCategoryForApp(app: GuiManagedAppSummary): ManagedCategoryId {
227
259
  return "cli";
228
260
  }
229
261
 
262
+ function isEssentialManagedApp(app: GuiManagedAppSummary): boolean {
263
+ return ["core", "safety", "automation"].includes(app.category);
264
+ }
265
+
230
266
  type AppCollectionId = "aidevops" | "recommended";
231
267
  type ManagedCategoryId = "core" | "ai" | "cli" | "desktop" | "terminal";
232
268
  type RecommendedOsId = "all" | "macos" | "linux" | "windows" | "ios" | "android";
269
+ type RecommendedPlatformId = "webapp" | "saas" | "cli" | "api";
270
+ type RecommendedPlatformFilterId = "all" | RecommendedPlatformId;
271
+ type ManagedPolicyId = "setup" | "update";
272
+ type ManagedPolicyToggleState = Record<string, Partial<Record<ManagedPolicyId, boolean>>>;
233
273
 
234
274
  interface TabOption<T extends string> {
235
275
  id: T;
@@ -242,7 +282,12 @@ interface RecommendedApp {
242
282
  websiteUrl: string;
243
283
  alternativeToUrl?: string;
244
284
  repoUrl?: string;
285
+ iosUrl?: string;
286
+ androidUrl?: string;
287
+ fdroidUrl?: string;
288
+ obtainiumUrl?: string;
245
289
  os: RecommendedOsId[];
290
+ platforms: RecommendedPlatformId[];
246
291
  }
247
292
 
248
293
  const appCollectionTabs: TabOption<AppCollectionId>[] = [
@@ -258,8 +303,16 @@ const managedCategoryTabs: TabOption<ManagedCategoryId>[] = [
258
303
  { id: "terminal", label: "Terminal" },
259
304
  ];
260
305
 
261
- const recommendedOsTabs: TabOption<RecommendedOsId>[] = [
306
+ const recommendedPlatformTabs: TabOption<RecommendedPlatformFilterId>[] = [
262
307
  { id: "all", label: "All" },
308
+ { id: "webapp", label: "WebApp" },
309
+ { id: "saas", label: "SaaS" },
310
+ { id: "cli", label: "CLI" },
311
+ { id: "api", label: "API" },
312
+ ];
313
+
314
+ const recommendedOsTabs: TabOption<RecommendedOsId>[] = [
315
+ { id: "all", label: "All OS" },
263
316
  { id: "macos", label: "macOS" },
264
317
  { id: "linux", label: "Linux" },
265
318
  { id: "windows", label: "Windows" },
@@ -268,89 +321,210 @@ const recommendedOsTabs: TabOption<RecommendedOsId>[] = [
268
321
  ];
269
322
 
270
323
  const recommendedApps = ([
271
- { name: "Affinity Studio", description: "Creative design suite.", websiteUrl: "https://www.affinity.studio/", alternativeToUrl: "https://alternativeto.net/software/affinity-1/", os: ["macos", "windows", "ios"] },
272
- { name: "Bitwarden", description: "Open source password manager.", websiteUrl: "https://bitwarden.com", alternativeToUrl: "https://alternativeto.net/software/bitwarden--free-password-manager/", repoUrl: "https://github.com/bitwarden", os: ["macos", "linux", "windows", "ios", "android"] },
273
- { name: "Brave Browser", description: "Privacy-focused web browser.", websiteUrl: "https://brave.com/", alternativeToUrl: "https://alternativeto.net/software/brave/", os: ["macos", "linux", "windows", "ios", "android"] },
274
- { name: "Cloudron", description: "Self-hosted app platform.", websiteUrl: "https://www.cloudron.io/", alternativeToUrl: "https://alternativeto.net/software/cloudron/", os: ["linux"] },
275
- { name: "Collabora Online", description: "Online office collaboration.", websiteUrl: "https://www.collaboraonline.com/collabora-online/", alternativeToUrl: "https://alternativeto.net/software/collabora-online/", os: ["linux"] },
276
- { name: "Cometly", description: "Marketing attribution analytics.", websiteUrl: "https://www.cometly.com/", os: [] },
277
- { name: "DaVinci Resolve", description: "Professional video editing, color, VFX, and audio post-production.", websiteUrl: "https://www.blackmagicdesign.com/products/davinciresolve/", alternativeToUrl: "https://alternativeto.net/software/davinci-resolve/", os: ["macos", "linux", "windows"] },
278
- { name: "DocuSeal", description: "Open source document signing.", websiteUrl: "https://www.docuseal.com/", alternativeToUrl: "https://alternativeto.net/software/docuseal/", os: ["linux"] },
279
- { name: "Element", description: "Matrix messaging client.", websiteUrl: "https://element.io/", alternativeToUrl: "https://alternativeto.net/software/element-app/", os: ["macos", "linux", "windows", "ios", "android"] },
280
- { name: "Enpass", description: "Offline-first password manager.", websiteUrl: "https://www.enpass.io/", alternativeToUrl: "https://alternativeto.net/software/enpass/", os: ["macos", "linux", "windows", "ios", "android"] },
281
- { name: "EspoCRM", description: "Open source CRM.", websiteUrl: "https://www.espocrm.com/", alternativeToUrl: "https://alternativeto.net/software/espocrm/", os: ["linux"] },
282
- { name: "Fathom Analytics", description: "Privacy-first analytics.", websiteUrl: "https://usefathom.com/", alternativeToUrl: "https://alternativeto.net/software/fathom-analytics/", os: [] },
283
- { name: "FonrBase", description: "Font management and reference tool.", websiteUrl: "https://fontba.se/", alternativeToUrl: "https://alternativeto.net/software/fontbase/", os: ["macos"] },
284
- { name: "Forgejo", description: "Self-hosted Git forge.", websiteUrl: "https://forgejo.org/", alternativeToUrl: "https://alternativeto.net/software/forgejo/", os: ["linux"] },
285
- { name: "Ghost", description: "Publishing platform.", websiteUrl: "https://ghost.org/", alternativeToUrl: "https://alternativeto.net/software/ghost/", os: ["linux"] },
286
- { name: "Gitea", description: "Self-hosted Git service.", websiteUrl: "https://about.gitea.com/", alternativeToUrl: "https://alternativeto.net/software/gitea/", os: ["linux", "windows"] },
287
- { name: "GitLab", description: "DevSecOps and Git hosting platform.", websiteUrl: "https://gitlab.com/", alternativeToUrl: "https://alternativeto.net/software/gitlab/", os: ["linux"] },
288
- { name: "LibreOffice", description: "Open source office suite.", websiteUrl: "https://www.libreoffice.org/", alternativeToUrl: "https://alternativeto.net/software/libreoffice/", os: ["macos", "linux", "windows"] },
289
- { name: "LocalWP", description: "Local WordPress development.", websiteUrl: "https://localwp.com/", alternativeToUrl: "https://alternativeto.net/software/local-by-flywheel/", os: ["macos", "linux", "windows"] },
290
- { name: "Matomo", description: "Open analytics platform.", websiteUrl: "https://matomo.org/", alternativeToUrl: "https://alternativeto.net/software/piwik/", os: ["linux"] },
291
- { name: "Nextcloud", description: "Open source content collaboration platform.", websiteUrl: "https://nextcloud.com/", alternativeToUrl: "https://alternativeto.net/software/nextcloud/", repoUrl: "https://github.com/nextcloud", os: ["macos", "linux", "windows", "ios", "android"] },
292
- { name: "OBS Studio", description: "Open source video recording and live streaming.", websiteUrl: "https://obsproject.com/", alternativeToUrl: "https://alternativeto.net/software/open-broadcaster-software/", repoUrl: "https://github.com/obsproject/obs-studio", os: ["macos", "linux", "windows"] },
293
- { name: "ONLYOFFICE", description: "Office and document collaboration.", websiteUrl: "https://www.onlyoffice.com/", alternativeToUrl: "https://alternativeto.net/software/onlyoffice/", os: ["macos", "linux", "windows", "ios", "android"] },
294
- { name: "OpenScreen", description: "Open screen-sharing project.", websiteUrl: "https://github.com/getopenscreen/openscreen", alternativeToUrl: "https://alternativeto.net/software/openscreen/", repoUrl: "https://github.com/getopenscreen/openscreen", os: ["linux"] },
295
- { name: "Osaurus", description: "AI app workspace.", websiteUrl: "https://osaurus.ai/", alternativeToUrl: "https://alternativeto.net/software/osaurus/", os: ["macos"] },
296
- { name: "Parallels Desktop", description: "Run Windows, Linux, and virtual machines on macOS.", websiteUrl: "https://www.parallels.com/products/desktop/", alternativeToUrl: "https://alternativeto.net/software/parallels-desktop/about/", os: ["macos"] },
297
- { name: "PDF Studio", description: "PDF editor.", websiteUrl: "https://www.qoppa.com/pdfstudio/", alternativeToUrl: "https://alternativeto.net/software/qoppa-pdf-studio/", os: ["macos", "linux", "windows"] },
298
- { name: "PostHog", description: "Product analytics platform.", websiteUrl: "https://posthog.com/", alternativeToUrl: "https://alternativeto.net/software/posthog/", os: ["linux"] },
299
- { name: "Postiz", description: "Social media scheduling.", websiteUrl: "https://postiz.com/", alternativeToUrl: "https://alternativeto.net/software/postiz/", os: ["linux"] },
300
- { name: "PrivateBin", description: "Zero-knowledge pastebin.", websiteUrl: "https://privatebin.info/", alternativeToUrl: "https://alternativeto.net/software/privatebin/", repoUrl: "https://github.com/PrivateBin/PrivateBin", os: ["linux"] },
301
- { name: "Proxmox", description: "Virtualization platform.", websiteUrl: "https://www.proxmox.com/", alternativeToUrl: "https://alternativeto.net/software/proxmox-virtual-environment/", os: ["linux"] },
302
- { name: "QuickFile", description: "Accounting platform.", websiteUrl: "https://www.quickfile.co.uk/", os: [] },
303
- { name: "Reframed", description: "Creative framing utility.", websiteUrl: "https://www.reframed.dev/", alternativeToUrl: "https://alternativeto.net/software/reframed/", os: ["macos"] },
304
- { name: "Rybbit", description: "Web analytics.", websiteUrl: "https://rybbit.com/", alternativeToUrl: "https://alternativeto.net/software/rybbit/", os: ["linux"] },
305
- { name: "SchildiChat", description: "Matrix messaging client.", websiteUrl: "https://schildi.chat/", alternativeToUrl: "https://alternativeto.net/software/schildichat/", os: ["macos", "linux", "windows", "android"] },
306
- { name: "ScreenFlow", description: "Screen recording and editing.", websiteUrl: "https://www.telestream.net/screenflow/", alternativeToUrl: "https://alternativeto.net/software/screenflow/", os: ["macos", "ios"] },
307
- { name: "SEO Utils", description: "Desktop SEO tools.", websiteUrl: "https://seoutils.app/", os: ["macos", "linux", "windows"] },
308
- { name: "Shottr", description: "macOS screenshot utility.", websiteUrl: "https://shottr.cc/", alternativeToUrl: "https://alternativeto.net/software/shottr/", os: ["macos"] },
309
- { name: "Signal", description: "Private messenger.", websiteUrl: "https://signal.org/", alternativeToUrl: "https://alternativeto.net/software/signal-private-messenger/", os: ["macos", "linux", "windows", "ios", "android"] },
310
- { name: "SimpleX Chat", description: "Private messenger with no user IDs.", websiteUrl: "https://simplex.chat/", alternativeToUrl: "https://alternativeto.net/software/simplex-chat/about/", repoUrl: "https://github.com/simplex-chat", os: ["macos", "linux", "windows", "ios", "android"] },
311
- { name: "Thunderbird", description: "Email and calendar app.", websiteUrl: "https://www.thunderbird.net/", alternativeToUrl: "https://alternativeto.net/software/mozilla-thunderbird/about/", os: ["macos", "linux", "windows"] },
312
- { name: "Ubicloud", description: "Open cloud platform.", websiteUrl: "https://www.ubicloud.com/", alternativeToUrl: "https://alternativeto.net/software/ubicloud/about/", os: ["linux"] },
313
- { name: "Vaultwarden", description: "Alternative Bitwarden server implementation.", websiteUrl: "https://github.com/dani-garcia/vaultwarden", alternativeToUrl: "https://alternativeto.net/software/vaultwarden/", repoUrl: "https://github.com/dani-garcia/vaultwarden", os: ["linux"] },
314
- { name: "VideoProc", description: "Video processing toolkit.", websiteUrl: "https://www.videoproc.com/", alternativeToUrl: "https://alternativeto.net/software/videoproc/about/", os: ["macos", "windows"] },
315
- { name: "VirtualBox", description: "Virtualization app.", websiteUrl: "https://www.virtualbox.org/", alternativeToUrl: "https://alternativeto.net/software/virtualbox/about/", os: ["macos", "linux", "windows"] },
316
- { name: "WordPress", description: "Open source publishing platform.", websiteUrl: "https://wordpress.org/", alternativeToUrl: "https://alternativeto.net/software/wordpress/about/", os: ["linux"] },
324
+ { name: "Affinity Studio", description: "Creative design suite.", websiteUrl: "https://www.affinity.studio/", alternativeToUrl: "https://alternativeto.net/software/affinity-1/", os: ["macos", "windows", "ios"], platforms: [] },
325
+ { name: "Bitwarden", description: "Open source password manager.", websiteUrl: "https://bitwarden.com", alternativeToUrl: "https://alternativeto.net/software/bitwarden--free-password-manager/", repoUrl: "https://github.com/bitwarden", os: ["macos", "linux", "windows", "ios", "android"], platforms: ["webapp", "saas", "cli", "api"] },
326
+ { name: "Brave Browser", description: "Privacy-focused web browser.", websiteUrl: "https://brave.com/", alternativeToUrl: "https://alternativeto.net/software/brave/", os: ["macos", "linux", "windows", "ios", "android"], platforms: [] },
327
+ { name: "Cloudron", description: "Self-hosted app platform.", websiteUrl: "https://www.cloudron.io/", alternativeToUrl: "https://alternativeto.net/software/cloudron/", os: ["linux"], platforms: ["webapp", "cli", "api"] },
328
+ { name: "Collabora Online", description: "Online office collaboration.", websiteUrl: "https://www.collaboraonline.com/collabora-online/", alternativeToUrl: "https://alternativeto.net/software/collabora-online/", os: ["linux"], platforms: ["webapp", "api"] },
329
+ { name: "Cometly", description: "Marketing attribution analytics.", websiteUrl: "https://www.cometly.com/", os: [], platforms: ["saas", "api"] },
330
+ { name: "DaVinci Resolve", description: "Professional video editing, color, VFX, and audio post-production.", websiteUrl: "https://www.blackmagicdesign.com/products/davinciresolve/", alternativeToUrl: "https://alternativeto.net/software/davinci-resolve/", os: ["macos", "linux", "windows"], platforms: [] },
331
+ { name: "DocuSeal", description: "Open source document signing.", websiteUrl: "https://www.docuseal.com/", alternativeToUrl: "https://alternativeto.net/software/docuseal/", os: ["linux"], platforms: ["webapp", "saas", "api"] },
332
+ { name: "Element", description: "Matrix messaging client.", websiteUrl: "https://element.io/", alternativeToUrl: "https://alternativeto.net/software/element-app/", os: ["macos", "linux", "windows", "ios", "android"], platforms: ["webapp"] },
333
+ { name: "Enpass", description: "Offline-first password manager.", websiteUrl: "https://www.enpass.io/", alternativeToUrl: "https://alternativeto.net/software/enpass/", os: ["macos", "linux", "windows", "ios", "android"], platforms: [] },
334
+ { name: "EspoCRM", description: "Open source CRM.", websiteUrl: "https://www.espocrm.com/", alternativeToUrl: "https://alternativeto.net/software/espocrm/", os: ["linux"], platforms: ["webapp", "saas", "api"] },
335
+ { name: "Fathom Analytics", description: "Privacy-first analytics.", websiteUrl: "https://usefathom.com/", alternativeToUrl: "https://alternativeto.net/software/fathom-analytics/", os: [], platforms: ["saas", "api"] },
336
+ { name: "FontBase", description: "Font management and reference tool.", websiteUrl: "https://fontba.se/", alternativeToUrl: "https://alternativeto.net/software/fontbase/", os: ["macos"], platforms: [] },
337
+ { name: "Forgejo", description: "Self-hosted Git forge.", websiteUrl: "https://forgejo.org/", alternativeToUrl: "https://alternativeto.net/software/forgejo/", os: ["linux"], platforms: ["webapp", "cli", "api"] },
338
+ { name: "Ghost", description: "Publishing platform.", websiteUrl: "https://ghost.org/", alternativeToUrl: "https://alternativeto.net/software/ghost/", os: ["linux"], platforms: ["webapp", "saas", "cli", "api"] },
339
+ { name: "Gitea", description: "Self-hosted Git service.", websiteUrl: "https://about.gitea.com/", alternativeToUrl: "https://alternativeto.net/software/gitea/", os: ["linux", "windows"], platforms: ["webapp", "cli", "api"] },
340
+ { name: "GitLab", description: "DevSecOps and Git hosting platform.", websiteUrl: "https://gitlab.com/", alternativeToUrl: "https://alternativeto.net/software/gitlab/", os: ["linux"], platforms: ["webapp", "saas", "cli", "api"] },
341
+ { name: "LibreOffice", description: "Open source office suite.", websiteUrl: "https://www.libreoffice.org/", alternativeToUrl: "https://alternativeto.net/software/libreoffice/", os: ["macos", "linux", "windows"], platforms: [] },
342
+ { name: "LocalWP", description: "Local WordPress development.", websiteUrl: "https://localwp.com/", alternativeToUrl: "https://alternativeto.net/software/local-by-flywheel/", os: ["macos", "linux", "windows"], platforms: [] },
343
+ { name: "Matomo", description: "Open analytics platform.", websiteUrl: "https://matomo.org/", alternativeToUrl: "https://alternativeto.net/software/piwik/", os: ["linux"], platforms: ["webapp", "saas", "api"] },
344
+ { name: "Nextcloud", description: "Open source content collaboration platform.", websiteUrl: "https://nextcloud.com/", alternativeToUrl: "https://alternativeto.net/software/nextcloud/", repoUrl: "https://github.com/nextcloud", os: ["macos", "linux", "windows", "ios", "android"], platforms: ["webapp", "saas", "api"] },
345
+ { name: "Nextcloud Talk", description: "Open source video calls, chat, and collaboration for Nextcloud.", websiteUrl: "https://nextcloud.com/talk/", repoUrl: "https://github.com/nextcloud/spreed", iosUrl: "https://apps.apple.com/us/app/nextcloud-talk/id1296825574", androidUrl: "https://play.google.com/store/apps/details?id=com.nextcloud.talk2&hl=en", os: ["ios", "android"], platforms: ["webapp"] },
346
+ { name: "OBS Studio", description: "Open source video recording and live streaming.", websiteUrl: "https://obsproject.com/", alternativeToUrl: "https://alternativeto.net/software/open-broadcaster-software/", repoUrl: "https://github.com/obsproject/obs-studio", os: ["macos", "linux", "windows"], platforms: [] },
347
+ { name: "ONLYOFFICE", description: "Office and document collaboration.", websiteUrl: "https://www.onlyoffice.com/", alternativeToUrl: "https://alternativeto.net/software/onlyoffice/", repoUrl: "https://github.com/ONLYOFFICE/", os: ["macos", "linux", "windows", "ios", "android"], platforms: ["webapp", "saas", "api"] },
348
+ { name: "OpenScreen", description: "Open screen-sharing project.", websiteUrl: "https://github.com/getopenscreen/openscreen/releases", alternativeToUrl: "https://alternativeto.net/software/openscreen/", repoUrl: "https://github.com/getopenscreen/openscreen", os: ["macos", "linux", "windows"], platforms: [] },
349
+ { name: "Osaurus", description: "AI app workspace.", websiteUrl: "https://osaurus.ai/", alternativeToUrl: "https://alternativeto.net/software/osaurus/", os: ["macos"], platforms: [] },
350
+ { name: "Parallels Desktop", description: "Run Windows, Linux, and virtual machines on macOS.", websiteUrl: "https://www.parallels.com/products/desktop/", alternativeToUrl: "https://alternativeto.net/software/parallels-desktop/about/", os: ["macos"], platforms: [] },
351
+ { name: "PDF Studio", description: "PDF editor.", websiteUrl: "https://www.qoppa.com/pdfstudio/", alternativeToUrl: "https://alternativeto.net/software/qoppa-pdf-studio/", os: ["macos", "linux", "windows"], platforms: [] },
352
+ { name: "Pixelmator Pro", description: "Professional image editor for macOS.", websiteUrl: "https://www.apple.com/pixelmator-pro/", os: ["macos"], platforms: [] },
353
+ { name: "PostHog", description: "Product analytics platform.", websiteUrl: "https://posthog.com/", alternativeToUrl: "https://alternativeto.net/software/posthog/", os: ["linux"], platforms: ["webapp", "saas", "cli", "api"] },
354
+ { name: "Postiz", description: "Social media scheduling.", websiteUrl: "https://postiz.com/", alternativeToUrl: "https://alternativeto.net/software/postiz/", os: ["linux"], platforms: ["webapp", "saas", "api"] },
355
+ { name: "PrivateBin", description: "Zero-knowledge pastebin.", websiteUrl: "https://privatebin.info/", alternativeToUrl: "https://alternativeto.net/software/privatebin/", repoUrl: "https://github.com/PrivateBin/PrivateBin", os: ["linux"], platforms: ["webapp", "api"] },
356
+ { name: "Proxmox", description: "Virtualization platform.", websiteUrl: "https://www.proxmox.com/", alternativeToUrl: "https://alternativeto.net/software/proxmox-virtual-environment/", os: ["linux"], platforms: ["webapp", "cli", "api"] },
357
+ { name: "QuickFile", description: "Accounting platform.", websiteUrl: "https://www.quickfile.co.uk/", os: [], platforms: ["saas", "api"] },
358
+ { name: "Reframed", description: "Creative framing utility.", websiteUrl: "https://www.reframed.dev/", alternativeToUrl: "https://alternativeto.net/software/reframed/", os: ["macos"], platforms: [] },
359
+ { name: "Rybbit", description: "Web analytics.", websiteUrl: "https://rybbit.com/", alternativeToUrl: "https://alternativeto.net/software/rybbit/", os: ["linux"], platforms: ["webapp", "saas", "api"] },
360
+ { name: "SchildiChat", description: "Matrix messaging client.", websiteUrl: "https://schildi.chat/", alternativeToUrl: "https://alternativeto.net/software/schildichat/", os: ["macos", "linux", "windows", "android"], platforms: ["webapp"] },
361
+ { name: "ScreenFlow", description: "Screen recording and editing.", websiteUrl: "https://www.telestream.net/screenflow/", alternativeToUrl: "https://alternativeto.net/software/screenflow/", os: ["macos", "ios"], platforms: [] },
362
+ { name: "SEO Utils", description: "Desktop SEO tools.", websiteUrl: "https://seoutils.app/", os: ["macos", "linux", "windows"], platforms: [] },
363
+ { name: "Shottr", description: "macOS screenshot utility.", websiteUrl: "https://shottr.cc/", alternativeToUrl: "https://alternativeto.net/software/shottr/", os: ["macos"], platforms: [] },
364
+ { name: "Signal", description: "Private messenger.", websiteUrl: "https://signal.org/", alternativeToUrl: "https://alternativeto.net/software/signal-private-messenger/", os: ["macos", "linux", "windows", "ios", "android"], platforms: [] },
365
+ { name: "SimpleX Chat", description: "Private messenger with no user IDs.", websiteUrl: "https://simplex.chat/", alternativeToUrl: "https://alternativeto.net/software/simplex-chat/about/", repoUrl: "https://github.com/simplex-chat", os: ["macos", "linux", "windows", "ios", "android"], platforms: ["cli"] },
366
+ { name: "Telegram", description: "Cloud-based mobile and desktop messaging app.", websiteUrl: "https://telegram.org/", iosUrl: "https://telegram.org/dl/ios", androidUrl: "https://telegram.org/android", os: ["macos", "linux", "windows", "ios", "android"], platforms: ["webapp", "api"] },
367
+ { name: "Thunderbird", description: "Email and calendar app.", websiteUrl: "https://www.thunderbird.net/", alternativeToUrl: "https://alternativeto.net/software/mozilla-thunderbird/about/", os: ["macos", "linux", "windows"], platforms: [] },
368
+ { name: "Ubicloud", description: "Open cloud platform.", websiteUrl: "https://www.ubicloud.com/", alternativeToUrl: "https://alternativeto.net/software/ubicloud/about/", repoUrl: "https://github.com/ubicloud/ubicloud", os: ["linux"], platforms: ["webapp", "saas", "cli", "api"] },
369
+ { name: "Vaultwarden", description: "Alternative Bitwarden server implementation.", websiteUrl: "https://github.com/dani-garcia/vaultwarden", alternativeToUrl: "https://alternativeto.net/software/vaultwarden/", repoUrl: "https://github.com/dani-garcia/vaultwarden", os: ["linux"], platforms: ["webapp", "api"] },
370
+ { name: "VideoProc", description: "Video processing toolkit.", websiteUrl: "https://www.videoproc.com/", alternativeToUrl: "https://alternativeto.net/software/videoproc/about/", os: ["macos", "windows"], platforms: [] },
371
+ { name: "VirtualBox", description: "Virtualization app.", websiteUrl: "https://www.virtualbox.org/", alternativeToUrl: "https://alternativeto.net/software/virtualbox/about/", os: ["macos", "linux", "windows"], platforms: ["cli"] },
372
+ { name: "WordPress", description: "Open source publishing platform.", websiteUrl: "https://wordpress.org/", alternativeToUrl: "https://alternativeto.net/software/wordpress/about/", os: ["linux"], platforms: ["webapp", "saas", "cli", "api"] },
317
373
  ] satisfies RecommendedApp[]).sort((left, right) => left.name.localeCompare(right.name, undefined, { sensitivity: "base" }));
318
374
 
319
375
  function TabNav<T extends string>({ label, onChange, tabs, value }: { label: string; onChange: (value: T) => void; tabs: TabOption<T>[]; value: T }): ReactElement {
320
376
  return <div aria-label={label} className="pill-tabs app-subnav" role="tablist">{tabs.map((tab) => <button aria-selected={value === tab.id} className={value === tab.id ? "active" : ""} key={tab.id} onClick={() => onChange(tab.id)} role="tab" type="button">{tab.label}</button>)}</div>;
321
377
  }
322
378
 
323
- function RecommendedAppsSurface({ apps, os, setOs }: { apps: RecommendedApp[]; os: RecommendedOsId; setOs: (os: RecommendedOsId) => void }): ReactElement {
379
+ function RecommendedAppsSurface({ apps, os, platform, setOs, setPlatform }: { apps: RecommendedApp[]; os: RecommendedOsId; platform: RecommendedPlatformFilterId; setOs: (os: RecommendedOsId) => void; setPlatform: (platform: RecommendedPlatformFilterId) => void }): ReactElement {
324
380
  return <>
325
- <TabNav label="Recommended app OS filters" tabs={recommendedOsTabs} value={os} onChange={setOs} />
381
+ <div className="recommended-filter-tabs">
382
+ <TabNav label="Recommended app platform filters" tabs={recommendedPlatformTabs} value={platform} onChange={setPlatform} />
383
+ <TabNav label="Recommended app operating system filters" tabs={recommendedOsTabs} value={os} onChange={setOs} />
384
+ </div>
326
385
  <div className="recommended-app-grid">
327
- {apps.map((app) => <RecommendedAppCard app={app} key={app.name} />)}
386
+ {apps.map((app) => <RecommendedAppCard app={app} key={app.name} setOs={setOs} setPlatform={setPlatform} />)}
328
387
  </div>
329
388
  </>;
330
389
  }
331
390
 
332
- function RecommendedAppCard({ app }: { app: RecommendedApp }): ReactElement {
391
+ function RecommendedAppCard({ app, setOs, setPlatform }: { app: RecommendedApp; setOs: (os: RecommendedOsId) => void; setPlatform: (platform: RecommendedPlatformFilterId) => void }): ReactElement {
333
392
  return <article className="recommended-app-card">
334
393
  <div>
335
394
  <strong>{app.name}</strong>
336
395
  <p>{app.description}</p>
337
396
  </div>
338
- <OsIconList os={app.os} />
397
+ <div className="app-icon-groups">
398
+ <PlatformIconList platforms={app.platforms} setPlatform={setPlatform} />
399
+ <OsIconList os={app.os} setOs={setOs} />
400
+ </div>
339
401
  <div className="managed-app-links plain-links">
340
402
  <OriginLink href={app.websiteUrl} label={text.website} />
341
403
  {app.alternativeToUrl ? <OriginLink href={app.alternativeToUrl} label="AlternativeTo" /> : null}
342
404
  {app.repoUrl ? <OriginLink href={app.repoUrl} label="Repo" /> : null}
405
+ {app.iosUrl ? <OriginLink href={app.iosUrl} label="iOS" /> : null}
406
+ {app.androidUrl ? <OriginLink href={app.androidUrl} label="Android" /> : null}
407
+ {app.fdroidUrl ? <OriginLink href={app.fdroidUrl} label="F-Droid" /> : null}
408
+ {app.obtainiumUrl ? <OriginLink href={app.obtainiumUrl} label="Obtainium" /> : null}
343
409
  </div>
344
410
  </article>;
345
411
  }
346
412
 
347
- function OsIconList({ os }: { os: RecommendedOsId[] }): ReactElement {
348
- const icons: Record<RecommendedOsId, string> = { all: "*", macos: "⌘", linux: "🐧", windows: "⊞", ios: "iOS", android: "🤖" };
413
+ function PlatformIconList({ platforms, setPlatform }: { platforms: RecommendedPlatformId[]; setPlatform: (platform: RecommendedPlatformFilterId) => void }): ReactElement | null {
414
+ if (platforms.length === 0) {
415
+ return null;
416
+ }
417
+
418
+ return <span className="os-icon-list platform-icon-list">{platforms.map((platform) => <PlatformIcon id={platform} key={platform} setPlatform={setPlatform} />)}</span>;
419
+ }
420
+
421
+ function PlatformIcon({ id, setPlatform }: { id: RecommendedPlatformId; setPlatform: (platform: RecommendedPlatformFilterId) => void }): ReactElement {
422
+ const iconMap: Record<RecommendedPlatformId, { Icon: IconType; label: string }> = {
423
+ webapp: { Icon: FiMonitor, label: "WebApp" },
424
+ saas: { Icon: FiGlobe, label: "SaaS" },
425
+ cli: { Icon: FiTerminal, label: "CLI" },
426
+ api: { Icon: FiCode, label: "API" },
427
+ };
428
+ const { Icon, label } = iconMap[id];
429
+
430
+ return <button aria-label={`Filter by ${label}`} data-tooltip={`Filter by ${label}`} onClick={() => setPlatform(id)} title={`Filter by ${label}`} type="button"><Icon aria-hidden="true" focusable="false" /></button>;
431
+ }
432
+
433
+ function OsIconList({ os, setOs }: { os: RecommendedOsId[]; setOs: (os: RecommendedOsId) => void }): ReactElement | null {
349
434
  if (os.length === 0) {
350
- return <span className="os-icon-list"><span title="Web app">Web</span></span>;
435
+ return null;
351
436
  }
352
437
 
353
- return <span className="os-icon-list">{os.map((item) => <span key={item} title={recommendedOsTabs.find((tab) => tab.id === item)?.label}>{icons[item]}</span>)}</span>;
438
+ return <span className="os-icon-list">{os.map((item) => <OsIcon id={item} key={item} setOs={setOs} />)}</span>;
439
+ }
440
+
441
+ function OsIcon({ id, setOs }: { id: RecommendedOsId; setOs: (os: RecommendedOsId) => void }): ReactElement {
442
+ const iconMap: Record<RecommendedOsId, { Icon: IconType; label: string }> = {
443
+ all: { Icon: FiGlobe, label: "Web app" },
444
+ macos: { Icon: FaApple, label: "macOS" },
445
+ linux: { Icon: FaLinux, label: "Linux" },
446
+ windows: { Icon: FaWindows, label: "Windows" },
447
+ ios: { Icon: SiIos, label: "iOS" },
448
+ android: { Icon: IoLogoAndroid, label: "Android" },
449
+ };
450
+ const { Icon, label } = iconMap[id];
451
+
452
+ return <button aria-label={`Filter by ${label}`} data-tooltip={`Filter by ${label}`} onClick={() => setOs(id)} title={`Filter by ${label}`} type="button"><Icon aria-hidden="true" focusable="false" /></button>;
453
+ }
454
+
455
+ function recommendedAppMatchesFilters(app: RecommendedApp, platform: RecommendedPlatformFilterId, os: RecommendedOsId): boolean {
456
+ const platformMatches = platform === "all" || app.platforms.includes(platform);
457
+ const osMatches = os === "all" || app.os.includes(os);
458
+
459
+ return platformMatches && osMatches;
460
+ }
461
+
462
+ function applyManagedPolicyToggles(app: GuiManagedAppSummary, policy: Partial<Record<ManagedPolicyId, boolean>> | undefined): GuiManagedAppSummary {
463
+ if (policy === undefined) {
464
+ return app;
465
+ }
466
+
467
+ return {
468
+ ...app,
469
+ aidevops_install: policy.setup ?? app.aidevops_install,
470
+ aidevops_update: policy.update ?? app.aidevops_update,
471
+ };
472
+ }
473
+
474
+ function createPolicyJob(appId: string, policy: ManagedPolicyId, value: boolean): GuiAppActionJobSummary {
475
+ const mode = value ? "on" : "off";
476
+ const command = `aidevops app policy set ${appId} --${policy === "setup" ? "setup-installs" : "update-maintains"} ${mode}`;
477
+ const now = new Date().toISOString();
478
+
479
+ return {
480
+ id: `policy-${appId}-${policy}-${Date.now()}`,
481
+ app_id: appId,
482
+ action: "update",
483
+ status: "completed",
484
+ command_preview: command,
485
+ started_at: now,
486
+ finished_at: now,
487
+ exit_code: 0,
488
+ output: [
489
+ `$ ${command}`,
490
+ `Saved local aidevops.app policy override for ${appId}: ${policy}=${mode}.`,
491
+ "Persistence: browser localStorage key aidevops-gui-managed-app-policy.",
492
+ ],
493
+ };
494
+ }
495
+
496
+ function readManagedPolicyToggles(): ManagedPolicyToggleState {
497
+ if (typeof window === "undefined") {
498
+ return {};
499
+ }
500
+
501
+ try {
502
+ const raw = window.localStorage.getItem("aidevops-gui-managed-app-policy");
503
+ return raw === null ? {} : JSON.parse(raw) as ManagedPolicyToggleState;
504
+ } catch {
505
+ return {};
506
+ }
507
+ }
508
+
509
+ function writeManagedPolicyToggles(policy: ManagedPolicyToggleState): void {
510
+ if (typeof window === "undefined") {
511
+ return;
512
+ }
513
+
514
+ window.localStorage.setItem("aidevops-gui-managed-app-policy", JSON.stringify(policy));
515
+ }
516
+
517
+ function openExternalLink(event: ReactMouseEvent<HTMLAnchorElement>, href: string): void {
518
+ event.stopPropagation();
519
+ if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
520
+ return;
521
+ }
522
+
523
+ const opened = window.open(href, "_blank", "noopener,noreferrer");
524
+ if (opened !== null) {
525
+ event.preventDefault();
526
+ opened.opener = null;
527
+ }
354
528
  }
355
529
 
356
530
  export function InstallationSurface(): ReactElement {
@@ -335,6 +335,19 @@ function plannedCommsGroup(label: string, items: SurfaceNavItem[]): SurfaceNavGr
335
335
  }
336
336
 
337
337
  export const navGroups: SurfaceNavGroup[] = [
338
+ {
339
+ label: text.infrastructure,
340
+ mode: "devops",
341
+ items: [
342
+ { id: "devices", label: text.devices, description: "Installed aidevops machines", icon: "device", badge: text.planned },
343
+ { id: "apps", label: text.apps, description: "Installed tools and apps", icon: "package" },
344
+ { id: "vpnsProxies", label: text.vpnsProxies, description: "VPN and proxy inventory", icon: "shield", badge: text.planned },
345
+ { id: "installation", label: text.installation, description: "Optional setup toggles", icon: "download" },
346
+ { id: "registrars", label: text.registrars, description: "Domain registrars", icon: "chain" },
347
+ { id: "hosts", label: text.hosts, description: "Hosting providers", icon: "server" },
348
+ { id: "servers", label: text.servers, description: "Servers and orchestrators", icon: "hardDrive" },
349
+ ],
350
+ },
338
351
  {
339
352
  label: text.development,
340
353
  mode: "devops",
@@ -360,19 +373,6 @@ export const navGroups: SurfaceNavGroup[] = [
360
373
  { id: "routines", label: text.routines, description: "Scheduled workflows", icon: "clock", badge: text.planned },
361
374
  ],
362
375
  },
363
- {
364
- label: text.infrastructure,
365
- mode: "devops",
366
- items: [
367
- { id: "devices", label: text.devices, description: "Installed aidevops machines", icon: "device", badge: text.planned },
368
- { id: "vpnsProxies", label: text.vpnsProxies, description: "VPN and proxy inventory", icon: "shield", badge: text.planned },
369
- { id: "apps", label: text.apps, description: "Installed tools and apps", icon: "package" },
370
- { id: "installation", label: text.installation, description: "Optional setup toggles", icon: "download" },
371
- { id: "registrars", label: text.registrars, description: "Domain registrars", icon: "chain" },
372
- { id: "hosts", label: text.hosts, description: "Hosting providers", icon: "server" },
373
- { id: "servers", label: text.servers, description: "Servers and orchestrators", icon: "hardDrive" },
374
- ],
375
- },
376
376
  {
377
377
  label: text.identities,
378
378
  mode: "comms",
@@ -1,6 +1,7 @@
1
1
  :root {
2
2
  --accent-hue: 123;
3
3
  --accent: hsl(var(--accent-hue) 50% 36%);
4
+ --accent-aaa-bg: hsl(var(--accent-hue) 58% 24%);
4
5
  --accent-strong: hsl(var(--accent-hue) 56% 28%);
5
6
  --bg-primary: #ffffff;
6
7
  --bg-secondary: #f8f8f8;
@@ -12,6 +13,14 @@
12
13
  --font-family-app: Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
13
14
  --font-size-app: 16px;
14
15
  --glass-panel-shadow: inset 0 1px 0 rgb(255 255 255 / 58%), inset 0 -1px 0 rgb(0 0 0 / 3%), var(--shadow-soft);
16
+ --notification-error-bg: rgb(207 34 46 / 10%);
17
+ --notification-error-fg: #cf222e;
18
+ --notification-info-bg: rgb(9 105 218 / 10%);
19
+ --notification-info-fg: #0969da;
20
+ --notification-success-bg: rgb(26 127 55 / 10%);
21
+ --notification-success-fg: #1a7f37;
22
+ --notification-warning-bg: rgb(191 135 0 / 12%);
23
+ --notification-warning-fg: #9a6700;
15
24
  --shell-gutter: 8px;
16
25
  --shadow-soft: 0 18px 56px rgb(15 23 42 / 9%);
17
26
  --surface-elevated: #ffffff;
@@ -33,6 +42,7 @@
33
42
 
34
43
  :root[data-theme="dark"] {
35
44
  --accent: hsl(var(--accent-hue) 74% 66%);
45
+ --accent-aaa-bg: hsl(var(--accent-hue) 64% 24%);
36
46
  --accent-strong: hsl(var(--accent-hue) 75% 72%);
37
47
  --bg-primary: #0a0a0a;
38
48
  --bg-secondary: #111111;
@@ -41,6 +51,14 @@
41
51
  --border-hover: color-mix(in srgb, rgb(255 255 255 / 22%) 74%, rgb(255 255 255 / 40%));
42
52
  --code-bg: hsl(var(--accent-hue) 18% 6%);
43
53
  --glass-panel-shadow: inset 0 1px 0 rgb(255 255 255 / 10%), inset 0 -1px 0 rgb(0 0 0 / 24%), var(--shadow-soft);
54
+ --notification-error-bg: rgb(255 123 114 / 13%);
55
+ --notification-error-fg: #ff7b72;
56
+ --notification-info-bg: rgb(88 166 255 / 13%);
57
+ --notification-info-fg: #58a6ff;
58
+ --notification-success-bg: rgb(86 211 100 / 13%);
59
+ --notification-success-fg: #56d364;
60
+ --notification-warning-bg: rgb(210 153 34 / 15%);
61
+ --notification-warning-fg: #d29922;
44
62
  --shadow-soft: 0 24px 80px rgb(0 0 0 / 40%);
45
63
  --surface-elevated: #151515;
46
64
  --surface-muted: #202020;
@@ -921,6 +939,15 @@ code {
921
939
  min-width: 0;
922
940
  }
923
941
 
942
+ .header-actions > .header-icon-button,
943
+ .header-actions > .header-action-menu {
944
+ align-items: center;
945
+ display: inline-flex;
946
+ flex: 0 0 38px;
947
+ justify-content: center;
948
+ width: 38px;
949
+ }
950
+
924
951
  .workspace-search {
925
952
  background: var(--surface-muted);
926
953
  border: 1px solid var(--border);
@@ -1071,11 +1098,22 @@ code {
1071
1098
  }
1072
1099
 
1073
1100
  .notification-preview {
1101
+ background: var(--notification-bg, transparent);
1074
1102
  border: 1px solid var(--border);
1075
1103
  border-left: 4px solid var(--notification-color, var(--accent));
1076
1104
  border-radius: 12px;
1105
+ color: var(--text-secondary);
1106
+ cursor: pointer;
1077
1107
  gap: 10px;
1078
1108
  padding: 10px;
1109
+ text-align: left;
1110
+ width: 100%;
1111
+ }
1112
+
1113
+ .notification-preview:hover,
1114
+ .notification-preview:focus-visible {
1115
+ border-color: var(--notification-color, var(--accent));
1116
+ outline: none;
1079
1117
  }
1080
1118
 
1081
1119
  .notification-preview > span {
@@ -1095,29 +1133,29 @@ code {
1095
1133
 
1096
1134
  .notification-preview.error,
1097
1135
  .github-notification-card.error {
1098
- --notification-color: #cf222e;
1099
- --notification-bg: rgb(207 34 46 / 10%);
1136
+ --notification-color: var(--notification-error-fg);
1137
+ --notification-bg: var(--notification-error-bg);
1100
1138
  }
1101
1139
 
1102
1140
  .notification-preview.warning,
1103
1141
  .github-notification-card.warning {
1104
- --notification-color: #9a6700;
1105
- --notification-bg: rgb(191 135 0 / 12%);
1142
+ --notification-color: var(--notification-warning-fg);
1143
+ --notification-bg: var(--notification-warning-bg);
1106
1144
  }
1107
1145
 
1108
1146
  .notification-preview.info,
1109
1147
  .github-notification-card.info {
1110
- --notification-color: #0969da;
1111
- --notification-bg: rgb(9 105 218 / 10%);
1148
+ --notification-color: var(--notification-info-fg);
1149
+ --notification-bg: var(--notification-info-bg);
1112
1150
  }
1113
1151
 
1114
1152
  .notification-preview.success,
1115
1153
  .github-notification-card.success {
1116
- --notification-color: #1a7f37;
1117
- --notification-bg: rgb(26 127 55 / 10%);
1154
+ --notification-color: var(--notification-success-fg);
1155
+ --notification-bg: var(--notification-success-bg);
1118
1156
  }
1119
1157
 
1120
- .popover-menu button,
1158
+ .popover-menu button:not(.notification-preview),
1121
1159
  .popover-menu a {
1122
1160
  align-items: center;
1123
1161
  background: transparent;
@@ -1131,9 +1169,9 @@ code {
1131
1169
  text-decoration: none;
1132
1170
  }
1133
1171
 
1134
- .popover-menu button:hover:not(:disabled),
1172
+ .popover-menu button:not(.notification-preview):hover:not(:disabled),
1135
1173
  .popover-menu a:hover,
1136
- .popover-menu button:focus-visible,
1174
+ .popover-menu button:not(.notification-preview):focus-visible,
1137
1175
  .popover-menu a:focus-visible {
1138
1176
  background: var(--surface-muted);
1139
1177
  color: var(--accent);
@@ -1966,7 +2004,12 @@ code {
1966
2004
 
1967
2005
  .apps-surface {
1968
2006
  display: grid;
1969
- gap: 14px;
2007
+ align-content: start;
2008
+ gap: 10px;
2009
+ }
2010
+
2011
+ .apps-surface .section-heading {
2012
+ margin-bottom: 8px;
1970
2013
  }
1971
2014
 
1972
2015
  .data-table,
@@ -1990,13 +2033,21 @@ code {
1990
2033
  border: 1px solid var(--border);
1991
2034
  border-radius: 18px;
1992
2035
  box-shadow: inset 0 1px 0 rgb(255 255 255 / 7%);
1993
- overflow: visible;
2036
+ overflow: hidden;
2037
+ transition: background 140ms ease, border-color 140ms ease;
1994
2038
  }
1995
2039
 
1996
2040
  .managed-app-card.expanded {
1997
2041
  border-color: color-mix(in srgb, var(--accent) 32%, var(--border));
1998
2042
  }
1999
2043
 
2044
+ .managed-app-card:hover,
2045
+ .managed-app-card:focus-within {
2046
+ background:
2047
+ linear-gradient(135deg, color-mix(in srgb, var(--accent) 11%, transparent), transparent 40%),
2048
+ var(--surface-muted);
2049
+ }
2050
+
2000
2051
  .managed-app-summary {
2001
2052
  align-items: center;
2002
2053
  background: transparent;
@@ -2006,6 +2057,7 @@ code {
2006
2057
  gap: 16px;
2007
2058
  grid-template-columns: minmax(0, 1fr) auto;
2008
2059
  padding: 16px;
2060
+ position: relative;
2009
2061
  text-align: left;
2010
2062
  width: 100%;
2011
2063
  }
@@ -2089,6 +2141,7 @@ code {
2089
2141
  display: grid;
2090
2142
  gap: 14px;
2091
2143
  padding: 0 16px 16px;
2144
+ position: relative;
2092
2145
  }
2093
2146
 
2094
2147
  .managed-app-toolbar {
@@ -2116,7 +2169,7 @@ code {
2116
2169
  align-items: center;
2117
2170
  color: var(--text-secondary);
2118
2171
  display: inline-flex;
2119
- font-size: 0.78rem;
2172
+ font-size: 0.95em;
2120
2173
  font-weight: 800;
2121
2174
  gap: 4px;
2122
2175
  max-width: 100%;
@@ -2167,6 +2220,11 @@ button.managed-toggle {
2167
2220
  text-align: left;
2168
2221
  }
2169
2222
 
2223
+ button.managed-toggle:disabled {
2224
+ cursor: not-allowed;
2225
+ opacity: 0.82;
2226
+ }
2227
+
2170
2228
  button.managed-toggle:hover,
2171
2229
  button.managed-toggle:focus-visible {
2172
2230
  border-color: color-mix(in srgb, var(--accent) 42%, var(--border));
@@ -2363,7 +2421,7 @@ button.managed-toggle:focus-visible {
2363
2421
  content: attr(data-tooltip);
2364
2422
  font-size: 0.72rem;
2365
2423
  font-weight: 700;
2366
- left: 50%;
2424
+ left: clamp(10px, 50%, calc(100vw - 330px));
2367
2425
  line-height: 1.35;
2368
2426
  max-width: min(320px, 72vw);
2369
2427
  opacity: 0;
@@ -2374,7 +2432,7 @@ button.managed-toggle:focus-visible {
2374
2432
  transform: translate(-50%, 4px);
2375
2433
  transition: opacity 140ms ease, transform 140ms ease;
2376
2434
  width: max-content;
2377
- z-index: 40;
2435
+ z-index: 400;
2378
2436
  }
2379
2437
 
2380
2438
  [data-tooltip]:hover::after,
@@ -2453,14 +2511,22 @@ button.managed-toggle:focus-visible {
2453
2511
  background: var(--surface-muted);
2454
2512
  border: 1px solid var(--border);
2455
2513
  border-radius: 999px;
2456
- display: flex;
2514
+ align-items: center;
2515
+ display: inline-flex;
2457
2516
  gap: 4px;
2458
2517
  padding: 5px;
2459
- width: min(100%, 520px);
2518
+ width: max-content;
2519
+ max-width: 100%;
2460
2520
  }
2461
2521
 
2462
2522
  .app-subnav {
2463
- width: fit-content;
2523
+ justify-self: start;
2524
+ }
2525
+
2526
+ .recommended-filter-tabs {
2527
+ display: flex;
2528
+ flex-wrap: wrap;
2529
+ gap: 10px;
2464
2530
  }
2465
2531
 
2466
2532
  .recommended-app-grid {
@@ -2495,19 +2561,38 @@ button.managed-toggle:focus-visible {
2495
2561
  gap: 6px;
2496
2562
  }
2497
2563
 
2498
- .os-icon-list span {
2564
+ .app-icon-groups {
2565
+ display: flex;
2566
+ flex-wrap: wrap;
2567
+ gap: 10px;
2568
+ }
2569
+
2570
+ .os-icon-list span,
2571
+ .os-icon-list button {
2499
2572
  align-items: center;
2500
- background: var(--surface-elevated);
2501
- border: 1px solid var(--border);
2502
- border-radius: 999px;
2573
+ background: transparent;
2574
+ border: 0;
2575
+ border-radius: 0;
2503
2576
  color: var(--text-secondary);
2504
2577
  display: inline-flex;
2505
- font-size: 0.72rem;
2578
+ font-size: 1.12rem;
2506
2579
  font-weight: 900;
2507
2580
  justify-content: center;
2508
- min-height: 28px;
2509
- min-width: 28px;
2510
- padding: 4px 7px;
2581
+ min-height: 24px;
2582
+ min-width: 24px;
2583
+ padding: 0;
2584
+ }
2585
+
2586
+ .platform-icon-list button {
2587
+ cursor: pointer;
2588
+ }
2589
+
2590
+ .os-icon-list span:hover,
2591
+ .os-icon-list span:focus-visible,
2592
+ .os-icon-list button:hover,
2593
+ .os-icon-list button:focus-visible {
2594
+ color: var(--accent);
2595
+ outline: none;
2511
2596
  }
2512
2597
 
2513
2598
  .pill-tabs button {
@@ -2515,20 +2600,30 @@ button.managed-toggle:focus-visible {
2515
2600
  border: 0;
2516
2601
  border-radius: 999px;
2517
2602
  color: var(--text-secondary);
2518
- flex: 1 1 0;
2603
+ flex: 0 0 auto;
2519
2604
  font-weight: 800;
2520
2605
  min-height: 38px;
2521
2606
  padding: 9px 14px;
2522
2607
  }
2523
2608
 
2524
- .pill-tabs button.active,
2609
+ .pill-tabs button.active {
2610
+ background: var(--accent-aaa-bg);
2611
+ color: #ffffff;
2612
+ }
2613
+
2525
2614
  .pill-tabs button:hover,
2526
2615
  .pill-tabs button:focus-visible {
2527
- background: var(--surface-elevated);
2528
- color: var(--text-primary);
2616
+ background: color-mix(in srgb, var(--accent) 16%, var(--surface-elevated));
2617
+ color: var(--accent);
2529
2618
  outline: none;
2530
2619
  }
2531
2620
 
2621
+ .pill-tabs button.active:hover,
2622
+ .pill-tabs button.active:focus-visible {
2623
+ background: var(--accent-aaa-bg);
2624
+ color: #ffffff;
2625
+ }
2626
+
2532
2627
  .count-pill,
2533
2628
  .repo-summary-strip span {
2534
2629
  background: color-mix(in srgb, var(--accent) 14%, transparent);
package/setup.sh CHANGED
@@ -12,7 +12,7 @@ shopt -s inherit_errexit 2>/dev/null || true
12
12
  # AI Assistant Server Access Framework Setup Script
13
13
  # Helps developers set up the framework for their infrastructure
14
14
  #
15
- # Version: 3.29.9
15
+ # Version: 3.29.12
16
16
  #
17
17
  # Quick Install:
18
18
  # npm install -g aidevops && aidevops update (recommended)