aidevops 3.29.7 → 3.29.9
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-web/src/AppWorkspace.tsx +93 -21
- package/packages/gui-web/src/CommsConversationSurface.tsx +93 -0
- package/packages/gui-web/src/InventorySurfaces.tsx +215 -42
- package/packages/gui-web/src/conversation-fixtures.ts +38 -0
- package/packages/gui-web/src/styles.css +235 -9
- package/setup.sh +1 -1
package/README.md
CHANGED
|
@@ -58,7 +58,7 @@ The result: an AI operations platform that manages projects across every busines
|
|
|
58
58
|
[](https://github.com/marcusquinn)
|
|
59
59
|
|
|
60
60
|
<!-- Release & Version Info -->
|
|
61
|
-
[](https://github.com/marcusquinn/aidevops/releases)
|
|
62
62
|
[](https://www.npmjs.com/package/aidevops)
|
|
63
63
|
[](https://github.com/marcusquinn/homebrew-tap)
|
|
64
64
|
[](https://github.com/marcusquinn/aidevops)
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
3.29.
|
|
1
|
+
3.29.9
|
package/aidevops.sh
CHANGED
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/* jshint esversion: 11 */
|
|
2
2
|
import { type ReactElement, type ReactNode, useEffect, useState } from "react";
|
|
3
|
-
import { FiAlertTriangle, FiBell, FiCheckCircle, FiChevronLeft, FiChevronRight, FiCommand, FiGlobe, FiHash, FiHelpCircle, FiInfo, FiLogOut, FiMessageSquare, FiSearch, FiSettings, FiShield, FiTerminal, FiTool, FiUser } from "react-icons/fi";
|
|
3
|
+
import { FiAlertTriangle, FiBell, FiCheckCircle, FiChevronLeft, FiChevronRight, FiCommand, FiFileText, FiGlobe, FiHash, FiHelpCircle, FiInfo, FiLogOut, FiMessageSquare, FiPaperclip, FiSearch, FiSettings, FiShield, FiTerminal, FiTool, FiUser } from "react-icons/fi";
|
|
4
4
|
import type { GuiFileRootId, GuiNotificationSummary, GuiStatusData } from "../../gui-shared/src";
|
|
5
5
|
import { SurfaceGlyph } from "./AppNavigation";
|
|
6
6
|
import type { ConversationMode, ShellMode, SurfaceId, SurfaceNavItem } from "./app-model";
|
|
7
7
|
import { inventorySurfaceConfigs, surfaceIds, text } from "./app-model";
|
|
8
8
|
import { CommandPalette, type CommandPaletteSelection, commandPaletteShortcutQuery } from "./CommandPalette";
|
|
9
|
+
import { CommsConversationSurface } from "./CommsConversationSurface";
|
|
9
10
|
import { FileExplorerSurface } from "./FileExplorerSurface";
|
|
10
11
|
import { AppsSurface, EditableInventorySurface, InstallationSurface } from "./InventorySurfaces";
|
|
11
12
|
import { AiProvidersSurface, LocalReposSurface, LockedVaultGate, OverviewSurface, PlannedSurface, ProjectsSurface, SecuritySurface, VaultSurface } from "./StatusSurfaces";
|
|
@@ -213,39 +214,110 @@ function ConversationWorkspace({ conversationMode, selectedLocalRepoIndex, selec
|
|
|
213
214
|
status: GuiStatusData;
|
|
214
215
|
}) {
|
|
215
216
|
const selectedRepo = status.local_repos.repos[selectedLocalRepoIndex] ?? status.local_repos.repos[0];
|
|
216
|
-
const selectedSession = selectedRepo === undefined
|
|
217
|
-
? undefined
|
|
218
|
-
: status.opencode_sessions.sessions.find((session) => session.id_ref === selectedSessionId && session.repo_path_ref === selectedRepo.path_ref) ?? status.opencode_sessions.sessions.find((session) => session.repo_path_ref === selectedRepo.path_ref);
|
|
217
|
+
const selectedSession = selectedRepo === undefined ? undefined : sessionForRepo(status, selectedRepo.path_ref, selectedSessionId);
|
|
219
218
|
const title = conversationMode === "ai" ? selectedRepo?.name ?? text.opencodeSessions : text.teams;
|
|
220
219
|
|
|
220
|
+
if (conversationMode === "ai") {
|
|
221
|
+
return <AiSessionsSurface selectedRepoIndex={selectedLocalRepoIndex} selectedSessionId={selectedSession?.id_ref} status={status} />;
|
|
222
|
+
}
|
|
223
|
+
|
|
221
224
|
return (
|
|
222
|
-
<
|
|
223
|
-
|
|
225
|
+
<CommsConversationSurface mode={title === text.teams ? "people" : "channels"} />
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function AiSessionsSurface({ selectedRepoIndex, selectedSessionId, status }: { selectedRepoIndex: number; selectedSessionId?: string; status: GuiStatusData }): ReactElement {
|
|
230
|
+
const selectedRepo = status.local_repos?.repos?.[selectedRepoIndex] ?? status.local_repos?.repos?.[0];
|
|
231
|
+
const selectedSession = selectedRepo === undefined ? undefined : sessionForRepo(status, selectedRepo.path_ref, selectedSessionId);
|
|
232
|
+
const providerOptions = status.oauth_pool?.providers?.filter((provider) => provider.configured || provider.total > 0) ?? [];
|
|
233
|
+
const sessionTitle = selectedSession?.title ?? "AI session bridge";
|
|
234
|
+
const sessionMeta = selectedSession ? `${selectedSession.model} via ${selectedSession.agent}` : "No OpenCode session metadata selected";
|
|
235
|
+
|
|
236
|
+
return (
|
|
237
|
+
<section className="chat-surface ai-sessions-surface" aria-label={text.aiSessions} data-tour="ai-sessions-surface">
|
|
238
|
+
<aside className="chat-thread-panel" aria-label="AI session controls" data-tour="ai-session-list">
|
|
224
239
|
<header className="chat-thread-header">
|
|
225
240
|
<div>
|
|
226
|
-
<p className="eyebrow">{
|
|
227
|
-
<h2><
|
|
241
|
+
<p className="eyebrow">{text.aiSessions}</p>
|
|
242
|
+
<h2><FiTerminal aria-hidden="true" /> {selectedRepo?.name ?? "Local workspace"}</h2>
|
|
228
243
|
</div>
|
|
229
244
|
<span className="count-pill">{text.readOnly}</span>
|
|
230
245
|
</header>
|
|
231
|
-
<div className="
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
246
|
+
<div className="notice compact-notice">New, rename, pin, archive, delete, share, and export are visible but disabled until audited AI session write routes land.</div>
|
|
247
|
+
<div className="sidebar-history-controls">
|
|
248
|
+
{sessionActions.map((action) => <button disabled key={action} title={`${action} needs the AI session write adapter`} type="button">{action}</button>)}
|
|
249
|
+
</div>
|
|
250
|
+
<label className="settings-form">
|
|
251
|
+
<span>Model/provider</span>
|
|
252
|
+
<select
|
|
253
|
+
disabled
|
|
254
|
+
defaultValue={providerOptions[0]?.provider ?? "local"}
|
|
255
|
+
title="Model switching needs the Turbostarter AI transport adapter."
|
|
256
|
+
>
|
|
257
|
+
{providerOptions.length === 0 ? (
|
|
258
|
+
<option value="local">Provider setup required</option>
|
|
259
|
+
) : (
|
|
260
|
+
providerOptions.map((provider) => (
|
|
261
|
+
<option key={provider.provider} value={provider.provider}>
|
|
262
|
+
{provider.provider} ({provider.available}/{provider.total})
|
|
263
|
+
</option>
|
|
264
|
+
))
|
|
265
|
+
)}
|
|
266
|
+
</select>
|
|
267
|
+
</label>
|
|
268
|
+
</aside>
|
|
269
|
+
<div className="chat-thread-panel" data-tour="ai-session-transcript">
|
|
270
|
+
<header className="chat-thread-header">
|
|
271
|
+
<div>
|
|
272
|
+
<p className="eyebrow">{sessionMeta}</p>
|
|
273
|
+
<h2><FiHash aria-hidden="true" /> {sessionTitle}</h2>
|
|
274
|
+
</div>
|
|
275
|
+
<button disabled title="Convert to worker task needs dispatch backend support" type="button">Create worker task</button>
|
|
276
|
+
</header>
|
|
277
|
+
<div className="chat-message-list" data-tour="message-scroller">
|
|
278
|
+
<MessageMarker label="Session status" detail="MessageScroller-compatible transcript; auto-scroll must pause when the reader scrolls away from the latest message." />
|
|
279
|
+
<ChatBubble
|
|
280
|
+
speaker="assistant"
|
|
281
|
+
title={sessionTitle}
|
|
282
|
+
body={
|
|
283
|
+
selectedSession
|
|
284
|
+
? `Most recent OpenCode session metadata: ${sessionMeta}, updated ${selectedSession.updated_at}. Message payloads stay out of the status API until the Turbostarter AI chat bridge lands.`
|
|
285
|
+
: "OpenCode session creation and continuation need an audited write route. This surface is ready for the Turbostarter AI adapter once connected."
|
|
286
|
+
}
|
|
287
|
+
/>
|
|
288
|
+
<AttachmentCard label="Context attachment" detail={selectedRepo ? `Selected repo context: ${selectedRepo.path_ref}` : "No local repos were discovered yet."} />
|
|
289
|
+
<MessageMarker label="Reasoning" detail="Reasoning disclosure, tool status, sources, retries, errors, and token usage render here when the AI transport supplies those parts." />
|
|
290
|
+
<ToolStatusCard />
|
|
239
291
|
</div>
|
|
240
|
-
<form className="chat-composer" aria-label="
|
|
292
|
+
<form className="chat-composer ai-composer" aria-label="AI prompt composer" data-tour="ai-composer">
|
|
293
|
+
<button disabled title="Attachments need the audited upload/context adapter" type="button"><FiPaperclip aria-hidden="true" /> Attach</button>
|
|
241
294
|
<textarea disabled placeholder={text.chatInputPlaceholder} />
|
|
242
|
-
<button disabled type="button">Send</button>
|
|
295
|
+
<button disabled title="Sending needs the Turbostarter AI transport adapter" type="button">Send</button>
|
|
243
296
|
</form>
|
|
244
297
|
</div>
|
|
245
298
|
</section>
|
|
246
299
|
);
|
|
247
300
|
}
|
|
248
301
|
|
|
302
|
+
const sessionActions = ["New", "Rename", "Pin", "Archive", "Delete", "Share", "Export"] as const;
|
|
303
|
+
|
|
304
|
+
function sessionForRepo(status: GuiStatusData, repoPathRef: string, selectedSessionId: string | undefined) {
|
|
305
|
+
const sessions = status.opencode_sessions?.sessions ?? [];
|
|
306
|
+
return sessions.find((session) => session.id_ref === selectedSessionId && session.repo_path_ref === repoPathRef) ?? sessions.find((session) => session.repo_path_ref === repoPathRef);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function MessageMarker({ detail, label }: { detail: string; label: string }) {
|
|
310
|
+
return <article className="chat-bubble assistant"><strong>{label}</strong><p>{detail}</p></article>;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function AttachmentCard({ detail, label }: { detail: string; label: string }) {
|
|
314
|
+
return <article className="chat-bubble user"><strong><FiFileText aria-hidden="true" /> {label}</strong><p>{detail}</p></article>;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function ToolStatusCard() {
|
|
318
|
+
return <article className="chat-bubble assistant"><strong>Tool status</strong><p>Tool calls, source citations, retries, errors, and usage metadata are reserved for the shared conversation part model.</p></article>;
|
|
319
|
+
}
|
|
320
|
+
|
|
249
321
|
function ChatBubble({ body, speaker, title }: { body: string; speaker: "assistant" | "user"; title: string }) {
|
|
250
322
|
return (
|
|
251
323
|
<article className={`chat-bubble ${speaker}`}>
|
|
@@ -304,9 +376,9 @@ function SurfaceContent({ activeItem, activeSurface, fileRoot, openSurface, stat
|
|
|
304
376
|
notifications: <NotificationsSurface openSurface={openSurface} status={status} />,
|
|
305
377
|
admin: <AdminSurface />,
|
|
306
378
|
vault: <VaultSurface status={status} />,
|
|
307
|
-
aiSessions: <
|
|
308
|
-
channels: <
|
|
309
|
-
directMessages: <
|
|
379
|
+
aiSessions: <AiSessionsSurface selectedRepoIndex={0} status={status} />,
|
|
380
|
+
channels: <CommsConversationSurface mode="channels" />,
|
|
381
|
+
directMessages: <CommsConversationSurface mode="directMessages" />,
|
|
310
382
|
workers: <PlannedSurface label={text.workers} detail={text.workersIntro} />,
|
|
311
383
|
repos: <PlannedSurface label={text.repos} detail={text.reposIntro} />,
|
|
312
384
|
deployments: <PlannedSurface label={text.deployments} detail={text.deploymentsIntro} />,
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { FiAtSign, FiHash, FiMessageSquare, FiSearch } from "react-icons/fi";
|
|
2
|
+
import { sortConversationMessageParts, sortConversationMessages, type GuiConversationMessage, type GuiConversationMessagePart, type GuiConversationThread } from "../../gui-shared/src";
|
|
3
|
+
import { text } from "./app-model";
|
|
4
|
+
import { conversationThreads } from "./conversation-fixtures";
|
|
5
|
+
|
|
6
|
+
export function CommsConversationSurface({ mode }: { mode: "channels" | "directMessages" | "people" }) {
|
|
7
|
+
const conversations = mode === "directMessages" ? conversationThreads.filter((thread) => thread.conversation.type === "dm" || thread.conversation.type === "group_dm") : conversationThreads.filter((thread) => thread.conversation.type === "channel");
|
|
8
|
+
const selectedThread = conversations[0] ?? conversationThreads[0];
|
|
9
|
+
const partsByMessage = new Map<string, GuiConversationMessagePart[]>();
|
|
10
|
+
|
|
11
|
+
for (const part of sortConversationMessageParts(selectedThread.parts)) {
|
|
12
|
+
partsByMessage.set(part.message_id, [...(partsByMessage.get(part.message_id) ?? []), part]);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<section className="chat-surface comms-surface" aria-label={mode === "directMessages" ? text.directMessages : text.channels} data-tour="comms-conversations">
|
|
17
|
+
<aside className="chat-thread-panel conversation-list-panel" aria-label="Conversation list">
|
|
18
|
+
<header className="chat-thread-header">
|
|
19
|
+
<div>
|
|
20
|
+
<p className="eyebrow">Unified conversation model</p>
|
|
21
|
+
<h2>{mode === "directMessages" ? <FiAtSign aria-hidden="true" /> : <FiHash aria-hidden="true" />} {mode === "directMessages" ? text.directMessages : text.channels}</h2>
|
|
22
|
+
</div>
|
|
23
|
+
<button disabled title="Creating conversations needs encrypted transport and audited write routes" type="button">Create</button>
|
|
24
|
+
</header>
|
|
25
|
+
<label className="conversation-search"><FiSearch aria-hidden="true" /><span className="sr-only">Search conversations</span><input disabled placeholder="Search channels, DMs, mentions" /></label>
|
|
26
|
+
<div className="notice compact-notice">{text.simplexReady}</div>
|
|
27
|
+
<ul className="conversation-list">
|
|
28
|
+
{conversations.map((thread) => <ConversationListItem key={thread.conversation.id} thread={thread} />)}
|
|
29
|
+
</ul>
|
|
30
|
+
</aside>
|
|
31
|
+
<div className="chat-thread-panel conversation-detail-panel">
|
|
32
|
+
<header className="chat-thread-header">
|
|
33
|
+
<div>
|
|
34
|
+
<p className="eyebrow">{selectedThread.conversation.scope.workspace_ref} · {selectedThread.conversation.status}</p>
|
|
35
|
+
<h2>{selectedThread.conversation.type === "channel" ? <FiHash aria-hidden="true" /> : <FiMessageSquare aria-hidden="true" />} {selectedThread.conversation.title}</h2>
|
|
36
|
+
</div>
|
|
37
|
+
<span className="count-pill">{participantSummary(selectedThread)}</span>
|
|
38
|
+
</header>
|
|
39
|
+
<div className="participant-strip">
|
|
40
|
+
{selectedThread.participants.map((participant) => <span key={participant.id}>{participant.display_name}<small>{participant.kind}</small></span>)}
|
|
41
|
+
</div>
|
|
42
|
+
<div className="chat-message-list" data-tour="comms-message-timeline">
|
|
43
|
+
{sortConversationMessages(selectedThread.messages).map((message) => <ConversationMessageRow key={message.id} message={message} parts={partsByMessage.get(message.id) ?? []} thread={selectedThread} />)}
|
|
44
|
+
</div>
|
|
45
|
+
<form className="chat-composer" aria-label="Conversation composer">
|
|
46
|
+
<textarea disabled placeholder={text.chatInputPlaceholder} />
|
|
47
|
+
<button disabled title="Sending messages needs the encrypted transport adapter" type="button">Send</button>
|
|
48
|
+
</form>
|
|
49
|
+
</div>
|
|
50
|
+
</section>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function ConversationListItem({ thread }: { thread: GuiConversationThread }) {
|
|
55
|
+
const latestMessage = sortConversationMessages(thread.messages).at(-1);
|
|
56
|
+
const unread = unreadCount(thread);
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<li>
|
|
60
|
+
<button className="surface-link" type="button">
|
|
61
|
+
<span className="surface-icon" aria-hidden="true">{thread.conversation.type === "channel" ? <FiHash /> : <FiMessageSquare />}</span>
|
|
62
|
+
<span className="surface-copy"><strong>{thread.conversation.title}</strong><small>{latestMessage ? latestMessage.created_at : "No messages yet"}</small></span>
|
|
63
|
+
{unread > 0 ? <em>{unread}</em> : null}
|
|
64
|
+
</button>
|
|
65
|
+
</li>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function ConversationMessageRow({ message, parts, thread }: { message: GuiConversationMessage; parts: GuiConversationMessagePart[]; thread: GuiConversationThread }) {
|
|
70
|
+
const sender = thread.participants.find((participant) => participant.id === message.sender_participant_id);
|
|
71
|
+
const reactions = thread.reactions.filter((reaction) => reaction.message_id === message.id);
|
|
72
|
+
const speaker = message.sender_kind === "human" ? "user" : "assistant";
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<article className={`chat-bubble ${speaker}`} data-sender-kind={message.sender_kind}>
|
|
76
|
+
<strong>{sender?.display_name ?? message.sender_kind}</strong>
|
|
77
|
+
{parts.map((part) => <p key={part.id}>{part.text ?? part.kind}</p>)}
|
|
78
|
+
<small>{message.status} · {message.created_at}</small>
|
|
79
|
+
{reactions.length > 0 ? <div className="reaction-row">{reactions.map((reaction) => <span key={reaction.id}>{reaction.reaction}</span>)}</div> : null}
|
|
80
|
+
</article>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function participantSummary(thread: GuiConversationThread): string {
|
|
85
|
+
const active = thread.participants.filter((participant) => participant.membership_state === "active").length;
|
|
86
|
+
return `${active} members`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function unreadCount(thread: GuiConversationThread): number {
|
|
90
|
+
const lastSequence = Math.max(0, ...thread.messages.map((message) => message.sequence));
|
|
91
|
+
const localRead = thread.read_states[0]?.last_read_sequence ?? 0;
|
|
92
|
+
return Math.max(0, lastSequence - localRead);
|
|
93
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { GuiAppActionId, GuiAppActionJobSummary, GuiManagedAppSummary, GuiResponseEnvelope, GuiStatusData } from "@aidevops/gui-shared";
|
|
2
|
-
import { type Dispatch, type ReactElement, type SetStateAction, useEffect, useState } from "react";
|
|
2
|
+
import { type Dispatch, type ReactElement, type SetStateAction, useEffect, useRef, useState } from "react";
|
|
3
3
|
import { FiChevronDown, FiDownload, FiExternalLink, FiRefreshCw, FiRepeat, FiTrash2 } from "react-icons/fi";
|
|
4
4
|
import type { InventoryColumn } from "./app-model";
|
|
5
5
|
import { installationRows, text } from "./app-model";
|
|
@@ -12,17 +12,19 @@ interface DraftInventoryRow {
|
|
|
12
12
|
let draftRowCounter = 0;
|
|
13
13
|
|
|
14
14
|
export function AppsSurface({ status }: { status: GuiStatusData }): ReactElement {
|
|
15
|
+
const [appCollection, setAppCollection] = useState<AppCollectionId>("aidevops");
|
|
16
|
+
const [managedCategory, setManagedCategory] = useState<ManagedCategoryId>("core");
|
|
17
|
+
const [recommendedOs, setRecommendedOs] = useState<RecommendedOsId>("all");
|
|
15
18
|
const [selectedJobId, setSelectedJobId] = useState<string | null>(null);
|
|
16
|
-
const [
|
|
19
|
+
const [expandedAppId, setExpandedAppId] = useState<string | null>(() => sortedManagedApps(status.managed_apps).find((app) => managedCategoryForApp(app) === "core")?.id ?? null);
|
|
17
20
|
const [jobs, setJobs] = useState<Record<string, GuiAppActionJobSummary>>({});
|
|
18
21
|
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));
|
|
19
24
|
|
|
20
25
|
useEffect(() => {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
24
|
-
setExpandedAppIds((current) => current.size === 0 ? new Set([status.managed_apps[0].id]) : current);
|
|
25
|
-
}, [status.managed_apps]);
|
|
26
|
+
setExpandedAppId(visibleManagedApps[0]?.id ?? null);
|
|
27
|
+
}, [managedCategory, status.managed_apps]);
|
|
26
28
|
|
|
27
29
|
useEffect(() => {
|
|
28
30
|
if (selectedJob === null || selectedJob.status !== "running") {
|
|
@@ -37,29 +39,33 @@ export function AppsSurface({ status }: { status: GuiStatusData }): ReactElement
|
|
|
37
39
|
}, [selectedJob]);
|
|
38
40
|
|
|
39
41
|
return (
|
|
40
|
-
<section className="
|
|
42
|
+
<section className="apps-surface" aria-label={text.apps}>
|
|
41
43
|
<div className="section-heading">
|
|
42
44
|
<p className="eyebrow">{text.infrastructure}</p>
|
|
43
45
|
<h2>{text.apps}</h2>
|
|
44
46
|
<p>{text.appsIntro}</p>
|
|
45
47
|
</div>
|
|
46
|
-
<
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
48
|
+
<TabNav label="Apps collections" tabs={appCollectionTabs} value={appCollection} onChange={(value) => setAppCollection(value)} />
|
|
49
|
+
{appCollection === "aidevops" ? <>
|
|
50
|
+
<TabNav label="aidevops app groups" tabs={managedCategoryTabs} value={managedCategory} onChange={(value) => setManagedCategory(value)} />
|
|
51
|
+
<div className="managed-app-list">
|
|
52
|
+
{visibleManagedApps.map((app) => (
|
|
53
|
+
<ManagedAppPanel
|
|
54
|
+
app={app}
|
|
55
|
+
expanded={expandedAppId === app.id}
|
|
56
|
+
job={jobForApp(app.id, jobs, selectedJob)}
|
|
57
|
+
key={app.id}
|
|
58
|
+
onJob={(job) => {
|
|
59
|
+
setJobs((current) => ({ ...current, [job.id]: job }));
|
|
60
|
+
setSelectedJobId(job.id);
|
|
61
|
+
setExpandedAppId(app.id);
|
|
62
|
+
}}
|
|
63
|
+
onToggle={() => setExpandedAppId((current) => current === app.id ? null : app.id)}
|
|
64
|
+
/>
|
|
65
|
+
))}
|
|
66
|
+
</div>
|
|
67
|
+
<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} />}
|
|
63
69
|
</section>
|
|
64
70
|
);
|
|
65
71
|
}
|
|
@@ -74,7 +80,6 @@ function ManagedAppPanel({ app, expanded, job, onJob, onToggle }: { app: GuiMana
|
|
|
74
80
|
<span>{app.description}</span>
|
|
75
81
|
</span>
|
|
76
82
|
<span className="managed-app-summary-meta">
|
|
77
|
-
<SummaryChip label="Status" value={app.status} />
|
|
78
83
|
<SummaryChip label="Installed" value={app.installed_version} />
|
|
79
84
|
<SummaryChip label="Latest" value={app.latest_version} />
|
|
80
85
|
<FiChevronDown aria-hidden="true" className="managed-app-chevron" />
|
|
@@ -96,7 +101,7 @@ function ManagedAppPanel({ app, expanded, job, onJob, onToggle }: { app: GuiMana
|
|
|
96
101
|
<AppMeta label="Installed" value={app.installed_version} />
|
|
97
102
|
<AppMeta label="Latest" value={app.latest_version} />
|
|
98
103
|
<AppMeta label={text.path} value={app.install_path_ref} />
|
|
99
|
-
<
|
|
104
|
+
{job ? <AppLogLink job={job} /> : null}
|
|
100
105
|
</div>
|
|
101
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>}
|
|
102
107
|
</div> : null}
|
|
@@ -113,41 +118,73 @@ function OriginLink({ href, label }: { href: string; label: string }): ReactElem
|
|
|
113
118
|
return <span className="origin-missing">{label}: source pending</span>;
|
|
114
119
|
}
|
|
115
120
|
|
|
116
|
-
return <a data-tooltip={
|
|
121
|
+
return <a data-tooltip={href} href={href} rel="noreferrer" target="_blank">{label} <FiExternalLink aria-hidden="true" /></a>;
|
|
117
122
|
}
|
|
118
123
|
|
|
119
124
|
function ToggleSwitch({ checked, label }: { checked: boolean; label: string }): ReactElement {
|
|
120
|
-
|
|
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>;
|
|
121
128
|
}
|
|
122
129
|
|
|
123
130
|
function AppMeta({ label, value }: { label: string; value: string }): ReactElement {
|
|
124
131
|
return <span className="app-meta"><small>{label}</small><strong>{value}</strong></span>;
|
|
125
132
|
}
|
|
126
133
|
|
|
134
|
+
function AppLogLink({ job }: { job: GuiAppActionJobSummary }): ReactElement {
|
|
135
|
+
return <a className="app-meta app-log-link" href={`#app-job-${job.id}`}><small>Logs</small><strong>{job.action} · {job.status}</strong></a>;
|
|
136
|
+
}
|
|
137
|
+
|
|
127
138
|
function AppActionButton({ action, app, commandPreview, disabled, onJob }: { action: GuiAppActionId; app: GuiManagedAppSummary; commandPreview: string; disabled: boolean; onJob: (job: GuiAppActionJobSummary) => void }): ReactElement {
|
|
128
139
|
const icon = action === "install" ? <FiDownload /> : action === "update" ? <FiRefreshCw /> : action === "reinstall" ? <FiRepeat /> : <FiTrash2 />;
|
|
140
|
+
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
129
141
|
|
|
130
142
|
async function runAction(): Promise<void> {
|
|
131
143
|
if (disabled) {
|
|
132
144
|
return;
|
|
133
145
|
}
|
|
134
|
-
if ((action === "remove" || action === "reinstall") && !window.confirm(`${action} ${app.name}?`)) {
|
|
135
|
-
return;
|
|
136
|
-
}
|
|
137
146
|
|
|
138
147
|
const response = await fetch(`/api/apps/${encodeURIComponent(app.id)}/actions/${action}`, { method: "POST" });
|
|
139
148
|
const envelope = await response.json() as GuiResponseEnvelope<GuiAppActionJobSummary>;
|
|
140
149
|
onJob(envelope.data);
|
|
141
150
|
}
|
|
142
151
|
|
|
143
|
-
return
|
|
152
|
+
return <>
|
|
153
|
+
<button aria-label={`${action} ${app.name}`} className={action === "remove" ? "app-action-button remove" : "app-action-button"} data-tooltip={commandPreview} disabled={disabled} onClick={() => setConfirmOpen(true)} type="button">{icon}<span>{action}</span></button>
|
|
154
|
+
{confirmOpen ? <ConfirmActionModal action={action} app={app} commandPreview={commandPreview} close={() => setConfirmOpen(false)} confirm={() => { setConfirmOpen(false); void runAction(); }} /> : null}
|
|
155
|
+
</>;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function ConfirmActionModal({ action, app, close, commandPreview, confirm }: { action: GuiAppActionId; app: GuiManagedAppSummary; close: () => void; commandPreview: string; confirm: () => void }): ReactElement {
|
|
159
|
+
return (
|
|
160
|
+
<div className="modal-backdrop" role="presentation">
|
|
161
|
+
<section aria-modal="true" className="confirm-modal" role="dialog" aria-labelledby={`confirm-${app.id}-${action}`}>
|
|
162
|
+
<p className="eyebrow">Confirm action</p>
|
|
163
|
+
<h3 id={`confirm-${app.id}-${action}`}>{action} {app.name}?</h3>
|
|
164
|
+
<p>This will start the allowlisted background command for this app.</p>
|
|
165
|
+
<code>{commandPreview}</code>
|
|
166
|
+
<div className="confirm-modal-actions">
|
|
167
|
+
<button className="secondary-action" onClick={close} type="button">Cancel</button>
|
|
168
|
+
<button className={action === "remove" ? "app-action-button remove" : "app-action-button"} onClick={confirm} type="button">Confirm</button>
|
|
169
|
+
</div>
|
|
170
|
+
</section>
|
|
171
|
+
</div>
|
|
172
|
+
);
|
|
144
173
|
}
|
|
145
174
|
|
|
146
175
|
function AppActionTerminal({ job }: { job: GuiAppActionJobSummary }): ReactElement {
|
|
176
|
+
const outputRef = useRef<HTMLPreElement | null>(null);
|
|
177
|
+
|
|
178
|
+
useEffect(() => {
|
|
179
|
+
if (outputRef.current !== null) {
|
|
180
|
+
outputRef.current.scrollTop = outputRef.current.scrollHeight;
|
|
181
|
+
}
|
|
182
|
+
}, [job.output]);
|
|
183
|
+
|
|
147
184
|
return (
|
|
148
|
-
<section className="app-terminal" aria-label="App action terminal output">
|
|
185
|
+
<section className="app-terminal" id={`app-job-${job.id}`} aria-label="App action terminal output">
|
|
149
186
|
<header><strong>{job.app_id} {job.action}</strong><span>{job.status}{job.exit_code === null ? "" : ` (${job.exit_code})`}</span></header>
|
|
150
|
-
<pre>{job.output.join("\n")}</pre>
|
|
187
|
+
<pre ref={outputRef}>{job.output.join("\n")}</pre>
|
|
151
188
|
</section>
|
|
152
189
|
);
|
|
153
190
|
}
|
|
@@ -169,15 +206,151 @@ function jobForApp(appId: string, jobs: Record<string, GuiAppActionJobSummary>,
|
|
|
169
206
|
return Object.values(jobs).reverse().find((job) => job.app_id === appId) ?? null;
|
|
170
207
|
}
|
|
171
208
|
|
|
172
|
-
function
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
209
|
+
function sortedManagedApps(apps: GuiManagedAppSummary[]): GuiManagedAppSummary[] {
|
|
210
|
+
return [...apps].sort((left, right) => left.name.localeCompare(right.name, undefined, { sensitivity: "base" }));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function managedCategoryForApp(app: GuiManagedAppSummary): ManagedCategoryId {
|
|
214
|
+
if (["core", "safety", "automation"].includes(app.category)) {
|
|
215
|
+
return "core";
|
|
216
|
+
}
|
|
217
|
+
if (app.category.includes("ai")) {
|
|
218
|
+
return "ai";
|
|
219
|
+
}
|
|
220
|
+
if (["desktop", "editor"].includes(app.category)) {
|
|
221
|
+
return "desktop";
|
|
222
|
+
}
|
|
223
|
+
if (["terminal"].includes(app.category) || app.name.toLowerCase().includes("terminal")) {
|
|
224
|
+
return "terminal";
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return "cli";
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
type AppCollectionId = "aidevops" | "recommended";
|
|
231
|
+
type ManagedCategoryId = "core" | "ai" | "cli" | "desktop" | "terminal";
|
|
232
|
+
type RecommendedOsId = "all" | "macos" | "linux" | "windows" | "ios" | "android";
|
|
233
|
+
|
|
234
|
+
interface TabOption<T extends string> {
|
|
235
|
+
id: T;
|
|
236
|
+
label: string;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
interface RecommendedApp {
|
|
240
|
+
name: string;
|
|
241
|
+
description: string;
|
|
242
|
+
websiteUrl: string;
|
|
243
|
+
alternativeToUrl?: string;
|
|
244
|
+
repoUrl?: string;
|
|
245
|
+
os: RecommendedOsId[];
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const appCollectionTabs: TabOption<AppCollectionId>[] = [
|
|
249
|
+
{ id: "aidevops", label: "aidevops" },
|
|
250
|
+
{ id: "recommended", label: "Recommended" },
|
|
251
|
+
];
|
|
252
|
+
|
|
253
|
+
const managedCategoryTabs: TabOption<ManagedCategoryId>[] = [
|
|
254
|
+
{ id: "core", label: "Core" },
|
|
255
|
+
{ id: "ai", label: "AI" },
|
|
256
|
+
{ id: "cli", label: "CLI" },
|
|
257
|
+
{ id: "desktop", label: "Desktop" },
|
|
258
|
+
{ id: "terminal", label: "Terminal" },
|
|
259
|
+
];
|
|
260
|
+
|
|
261
|
+
const recommendedOsTabs: TabOption<RecommendedOsId>[] = [
|
|
262
|
+
{ id: "all", label: "All" },
|
|
263
|
+
{ id: "macos", label: "macOS" },
|
|
264
|
+
{ id: "linux", label: "Linux" },
|
|
265
|
+
{ id: "windows", label: "Windows" },
|
|
266
|
+
{ id: "ios", label: "iOS" },
|
|
267
|
+
{ id: "android", label: "Android" },
|
|
268
|
+
];
|
|
269
|
+
|
|
270
|
+
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"] },
|
|
317
|
+
] satisfies RecommendedApp[]).sort((left, right) => left.name.localeCompare(right.name, undefined, { sensitivity: "base" }));
|
|
318
|
+
|
|
319
|
+
function TabNav<T extends string>({ label, onChange, tabs, value }: { label: string; onChange: (value: T) => void; tabs: TabOption<T>[]; value: T }): ReactElement {
|
|
320
|
+
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
|
+
}
|
|
322
|
+
|
|
323
|
+
function RecommendedAppsSurface({ apps, os, setOs }: { apps: RecommendedApp[]; os: RecommendedOsId; setOs: (os: RecommendedOsId) => void }): ReactElement {
|
|
324
|
+
return <>
|
|
325
|
+
<TabNav label="Recommended app OS filters" tabs={recommendedOsTabs} value={os} onChange={setOs} />
|
|
326
|
+
<div className="recommended-app-grid">
|
|
327
|
+
{apps.map((app) => <RecommendedAppCard app={app} key={app.name} />)}
|
|
328
|
+
</div>
|
|
329
|
+
</>;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function RecommendedAppCard({ app }: { app: RecommendedApp }): ReactElement {
|
|
333
|
+
return <article className="recommended-app-card">
|
|
334
|
+
<div>
|
|
335
|
+
<strong>{app.name}</strong>
|
|
336
|
+
<p>{app.description}</p>
|
|
337
|
+
</div>
|
|
338
|
+
<OsIconList os={app.os} />
|
|
339
|
+
<div className="managed-app-links plain-links">
|
|
340
|
+
<OriginLink href={app.websiteUrl} label={text.website} />
|
|
341
|
+
{app.alternativeToUrl ? <OriginLink href={app.alternativeToUrl} label="AlternativeTo" /> : null}
|
|
342
|
+
{app.repoUrl ? <OriginLink href={app.repoUrl} label="Repo" /> : null}
|
|
343
|
+
</div>
|
|
344
|
+
</article>;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function OsIconList({ os }: { os: RecommendedOsId[] }): ReactElement {
|
|
348
|
+
const icons: Record<RecommendedOsId, string> = { all: "*", macos: "⌘", linux: "🐧", windows: "⊞", ios: "iOS", android: "🤖" };
|
|
349
|
+
if (os.length === 0) {
|
|
350
|
+
return <span className="os-icon-list"><span title="Web app">Web</span></span>;
|
|
178
351
|
}
|
|
179
352
|
|
|
180
|
-
return
|
|
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>;
|
|
181
354
|
}
|
|
182
355
|
|
|
183
356
|
export function InstallationSurface(): ReactElement {
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { GuiConversationThread } from "../../gui-shared/src";
|
|
2
|
+
|
|
3
|
+
export const conversationThreads: GuiConversationThread[] = [
|
|
4
|
+
{
|
|
5
|
+
conversation: { id: "channel-general", type: "channel", title: "general", scope: { tenant_ref: "local", workspace_ref: "aidevops", repo_ref: null }, source_ref: "seed:channels/general", status: "read_only", created_at: "2026-06-27T00:00:00Z", updated_at: "2026-06-27T12:20:00Z" },
|
|
6
|
+
participants: [
|
|
7
|
+
{ id: "participant-local", conversation_id: "channel-general", kind: "human", display_name: "Local user", identity_ref: "local:user", agent_ref: null, worker_ref: null, membership_state: "active", joined_at: "2026-06-27T00:00:00Z" },
|
|
8
|
+
{ id: "participant-ai", conversation_id: "channel-general", kind: "ai_assistant", display_name: "AI DevOps", identity_ref: null, agent_ref: "aidevops", worker_ref: null, membership_state: "active", joined_at: "2026-06-27T00:00:00Z" },
|
|
9
|
+
{ id: "participant-system", conversation_id: "channel-general", kind: "system_bot", display_name: "System", identity_ref: null, agent_ref: null, worker_ref: null, membership_state: "active", joined_at: "2026-06-27T00:00:00Z" },
|
|
10
|
+
],
|
|
11
|
+
messages: [
|
|
12
|
+
{ id: "message-general-1", conversation_id: "channel-general", sender_participant_id: "participant-system", sender_kind: "system", sequence: 1, status: "sent", usage: null, created_at: "2026-06-27T12:00:00Z", edited_at: null },
|
|
13
|
+
{ id: "message-general-2", conversation_id: "channel-general", sender_participant_id: "participant-ai", sender_kind: "ai_assistant", sequence: 2, status: "delivered", usage: { provider_ref: "local", model_ref: "workflow-summary", input_tokens: 0, output_tokens: 0, total_tokens: 0, cost_ref: null }, created_at: "2026-06-27T12:15:00Z", edited_at: null },
|
|
14
|
+
],
|
|
15
|
+
parts: [
|
|
16
|
+
{ id: "part-general-1", message_id: "message-general-1", kind: "event_marker", ordinal: 1, text: "#general is ready for repo, deployment, review, and incident coordination.", payload_json: null, file_ref: null, source_ref: "seed" },
|
|
17
|
+
{ id: "part-general-2", message_id: "message-general-2", kind: "text", ordinal: 1, text: "Mention @AI DevOps or a worker to turn a thread into an audited task once write routes land.", payload_json: null, file_ref: null, source_ref: null },
|
|
18
|
+
],
|
|
19
|
+
reactions: [{ id: "reaction-general-1", message_id: "message-general-2", participant_id: "participant-local", reaction: "ack", created_at: "2026-06-27T12:16:00Z" }],
|
|
20
|
+
read_states: [{ conversation_id: "channel-general", participant_id: "participant-local", last_read_message_id: "message-general-1", last_read_sequence: 1, updated_at: "2026-06-27T12:10:00Z" }],
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
conversation: { id: "channel-workers", type: "channel", title: "worker-feed", scope: { tenant_ref: "local", workspace_ref: "aidevops", repo_ref: "current" }, source_ref: "seed:channels/worker-feed", status: "read_only", created_at: "2026-06-27T00:00:00Z", updated_at: "2026-06-27T12:30:00Z" },
|
|
24
|
+
participants: [{ id: "participant-worker", conversation_id: "channel-workers", kind: "worker", display_name: "Worker queue", identity_ref: null, agent_ref: null, worker_ref: "workers", membership_state: "active", joined_at: "2026-06-27T00:00:00Z" }],
|
|
25
|
+
messages: [{ id: "message-workers-1", conversation_id: "channel-workers", sender_participant_id: "participant-worker", sender_kind: "worker", sequence: 1, status: "delivered", usage: null, created_at: "2026-06-27T12:30:00Z", edited_at: null }],
|
|
26
|
+
parts: [{ id: "part-workers-1", message_id: "message-workers-1", kind: "event_marker", ordinal: 1, text: "Worker events for reviews, deployments, incidents, and releases collect here.", payload_json: null, file_ref: null, source_ref: "seed" }],
|
|
27
|
+
reactions: [],
|
|
28
|
+
read_states: [{ conversation_id: "channel-workers", participant_id: "participant-local", last_read_message_id: null, last_read_sequence: 0, updated_at: "2026-06-27T12:00:00Z" }],
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
conversation: { id: "dm-ai-devops", type: "dm", title: "AI DevOps", scope: { tenant_ref: "local", workspace_ref: "aidevops", repo_ref: null }, source_ref: "seed:dms/ai-devops", status: "read_only", created_at: "2026-06-27T00:00:00Z", updated_at: "2026-06-27T12:40:00Z" },
|
|
32
|
+
participants: [{ id: "participant-dm-local", conversation_id: "dm-ai-devops", kind: "human", display_name: "Local user", identity_ref: "local:user", agent_ref: null, worker_ref: null, membership_state: "active", joined_at: "2026-06-27T00:00:00Z" }, { id: "participant-dm-ai", conversation_id: "dm-ai-devops", kind: "ai_assistant", display_name: "AI DevOps", identity_ref: null, agent_ref: "aidevops", worker_ref: null, membership_state: "active", joined_at: "2026-06-27T00:00:00Z" }],
|
|
33
|
+
messages: [{ id: "message-dm-1", conversation_id: "dm-ai-devops", sender_participant_id: "participant-dm-ai", sender_kind: "ai_assistant", sequence: 1, status: "delivered", usage: null, created_at: "2026-06-27T12:40:00Z", edited_at: null }],
|
|
34
|
+
parts: [{ id: "part-dm-1", message_id: "message-dm-1", kind: "text", ordinal: 1, text: "Direct support threads share the same message parts, participants, reactions, and read state as channels.", payload_json: null, file_ref: null, source_ref: null }],
|
|
35
|
+
reactions: [],
|
|
36
|
+
read_states: [{ conversation_id: "dm-ai-devops", participant_id: "participant-dm-local", last_read_message_id: null, last_read_sequence: 0, updated_at: "2026-06-27T12:00:00Z" }],
|
|
37
|
+
},
|
|
38
|
+
];
|
|
@@ -69,6 +69,7 @@ body {
|
|
|
69
69
|
radial-gradient(ellipse at top left, color-mix(in srgb, var(--accent) 16%, transparent) 0%, transparent 36%),
|
|
70
70
|
var(--bg-secondary);
|
|
71
71
|
color: var(--text-primary);
|
|
72
|
+
font-family: var(--font-family-app);
|
|
72
73
|
line-height: 1.6;
|
|
73
74
|
margin: 0;
|
|
74
75
|
font-size: var(--font-size-app);
|
|
@@ -1286,11 +1287,28 @@ code {
|
|
|
1286
1287
|
gap: 16px;
|
|
1287
1288
|
}
|
|
1288
1289
|
|
|
1290
|
+
.sr-only {
|
|
1291
|
+
height: 1px;
|
|
1292
|
+
margin: -1px;
|
|
1293
|
+
overflow: hidden;
|
|
1294
|
+
position: absolute;
|
|
1295
|
+
width: 1px;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1289
1298
|
.chat-surface {
|
|
1290
1299
|
display: grid;
|
|
1300
|
+
gap: 16px;
|
|
1291
1301
|
min-height: 100%;
|
|
1292
1302
|
}
|
|
1293
1303
|
|
|
1304
|
+
.ai-sessions-surface {
|
|
1305
|
+
grid-template-columns: minmax(248px, 320px) minmax(0, 1fr);
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
.comms-surface {
|
|
1309
|
+
grid-template-columns: minmax(260px, 340px) minmax(0, 1fr);
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1294
1312
|
.chat-thread-panel {
|
|
1295
1313
|
background: var(--surface-elevated);
|
|
1296
1314
|
border: 1px solid var(--border);
|
|
@@ -1346,6 +1364,69 @@ code {
|
|
|
1346
1364
|
margin: 0;
|
|
1347
1365
|
}
|
|
1348
1366
|
|
|
1367
|
+
.chat-bubble small {
|
|
1368
|
+
color: var(--text-muted);
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
.chat-bubble[data-sender-kind="system"],
|
|
1372
|
+
.chat-bubble[data-sender-kind="worker"] {
|
|
1373
|
+
border-style: dashed;
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
.conversation-list-panel {
|
|
1377
|
+
grid-template-rows: auto auto auto minmax(0, 1fr);
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
.conversation-search {
|
|
1381
|
+
align-items: center;
|
|
1382
|
+
background: var(--surface-muted);
|
|
1383
|
+
border: 1px solid var(--border);
|
|
1384
|
+
border-radius: 14px;
|
|
1385
|
+
display: flex;
|
|
1386
|
+
gap: 8px;
|
|
1387
|
+
margin: 14px;
|
|
1388
|
+
padding: 10px 12px;
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
.conversation-search input {
|
|
1392
|
+
background: transparent;
|
|
1393
|
+
border: 0;
|
|
1394
|
+
color: var(--text-secondary);
|
|
1395
|
+
min-width: 0;
|
|
1396
|
+
width: 100%;
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
.conversation-list {
|
|
1400
|
+
display: grid;
|
|
1401
|
+
gap: 8px;
|
|
1402
|
+
list-style: none;
|
|
1403
|
+
margin: 0;
|
|
1404
|
+
padding: 0 14px 14px;
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
.participant-strip,
|
|
1408
|
+
.reaction-row {
|
|
1409
|
+
display: flex;
|
|
1410
|
+
flex-wrap: wrap;
|
|
1411
|
+
gap: 8px;
|
|
1412
|
+
padding: 12px 18px 0;
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
.participant-strip span,
|
|
1416
|
+
.reaction-row span {
|
|
1417
|
+
background: var(--surface-muted);
|
|
1418
|
+
border: 1px solid var(--border);
|
|
1419
|
+
border-radius: 999px;
|
|
1420
|
+
color: var(--text-secondary);
|
|
1421
|
+
display: inline-flex;
|
|
1422
|
+
gap: 6px;
|
|
1423
|
+
padding: 6px 9px;
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
.participant-strip small {
|
|
1427
|
+
color: var(--text-muted);
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1349
1430
|
.chat-composer {
|
|
1350
1431
|
border-top: 1px solid var(--border);
|
|
1351
1432
|
display: grid;
|
|
@@ -1354,6 +1435,10 @@ code {
|
|
|
1354
1435
|
padding: 14px;
|
|
1355
1436
|
}
|
|
1356
1437
|
|
|
1438
|
+
.chat-composer.ai-composer {
|
|
1439
|
+
grid-template-columns: auto minmax(0, 1fr) auto;
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1357
1442
|
.chat-composer textarea {
|
|
1358
1443
|
background: var(--surface-muted);
|
|
1359
1444
|
border: 1px solid var(--border);
|
|
@@ -1365,14 +1450,33 @@ code {
|
|
|
1365
1450
|
}
|
|
1366
1451
|
|
|
1367
1452
|
.chat-composer button {
|
|
1453
|
+
align-items: center;
|
|
1368
1454
|
background: color-mix(in srgb, var(--accent) 16%, transparent);
|
|
1369
1455
|
border: 1px solid color-mix(in srgb, var(--accent) 34%, transparent);
|
|
1370
1456
|
border-radius: 14px;
|
|
1371
1457
|
color: var(--accent);
|
|
1458
|
+
display: inline-flex;
|
|
1372
1459
|
font-weight: 900;
|
|
1460
|
+
gap: 8px;
|
|
1373
1461
|
padding: 0 18px;
|
|
1374
1462
|
}
|
|
1375
1463
|
|
|
1464
|
+
@media (max-width: 980px) {
|
|
1465
|
+
.ai-sessions-surface,
|
|
1466
|
+
.comms-surface {
|
|
1467
|
+
grid-template-columns: 1fr;
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
.chat-composer {
|
|
1471
|
+
grid-template-columns: 1fr;
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
.chat-composer button {
|
|
1475
|
+
justify-content: center;
|
|
1476
|
+
min-height: 44px;
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1376
1480
|
.hero-panel,
|
|
1377
1481
|
.panel,
|
|
1378
1482
|
.metric-card,
|
|
@@ -1860,6 +1964,11 @@ code {
|
|
|
1860
1964
|
margin-bottom: 12px;
|
|
1861
1965
|
}
|
|
1862
1966
|
|
|
1967
|
+
.apps-surface {
|
|
1968
|
+
display: grid;
|
|
1969
|
+
gap: 14px;
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1863
1972
|
.data-table,
|
|
1864
1973
|
.editable-table,
|
|
1865
1974
|
.installation-list,
|
|
@@ -1935,13 +2044,16 @@ code {
|
|
|
1935
2044
|
}
|
|
1936
2045
|
|
|
1937
2046
|
.managed-summary-chip {
|
|
2047
|
+
align-items: center;
|
|
1938
2048
|
background: color-mix(in srgb, var(--surface-elevated) 92%, transparent);
|
|
1939
2049
|
border: 1px solid var(--border);
|
|
1940
2050
|
border-radius: 999px;
|
|
1941
2051
|
display: inline-grid;
|
|
1942
2052
|
gap: 1px;
|
|
2053
|
+
justify-items: center;
|
|
1943
2054
|
min-width: 118px;
|
|
1944
2055
|
padding: 7px 11px;
|
|
2056
|
+
text-align: center;
|
|
1945
2057
|
}
|
|
1946
2058
|
|
|
1947
2059
|
.managed-summary-chip small {
|
|
@@ -2002,25 +2114,22 @@ code {
|
|
|
2002
2114
|
.managed-app-links a,
|
|
2003
2115
|
.origin-missing {
|
|
2004
2116
|
align-items: center;
|
|
2005
|
-
border: 1px solid var(--border);
|
|
2006
|
-
border-radius: 999px;
|
|
2007
2117
|
color: var(--text-secondary);
|
|
2008
2118
|
display: inline-flex;
|
|
2009
2119
|
font-size: 0.78rem;
|
|
2010
2120
|
font-weight: 800;
|
|
2011
2121
|
gap: 4px;
|
|
2012
2122
|
max-width: 100%;
|
|
2013
|
-
padding:
|
|
2123
|
+
padding: 4px 0;
|
|
2014
2124
|
position: relative;
|
|
2015
2125
|
text-decoration: none;
|
|
2016
2126
|
}
|
|
2017
2127
|
|
|
2018
2128
|
.managed-app-links a:hover,
|
|
2019
2129
|
.managed-app-links a:focus-visible {
|
|
2020
|
-
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
|
2021
|
-
border-color: color-mix(in srgb, var(--accent) 48%, var(--border));
|
|
2022
2130
|
color: var(--accent);
|
|
2023
2131
|
outline: none;
|
|
2132
|
+
text-decoration: underline;
|
|
2024
2133
|
}
|
|
2025
2134
|
|
|
2026
2135
|
.origin-missing {
|
|
@@ -2053,9 +2162,20 @@ code {
|
|
|
2053
2162
|
text-transform: uppercase;
|
|
2054
2163
|
}
|
|
2055
2164
|
|
|
2165
|
+
button.managed-toggle {
|
|
2166
|
+
cursor: pointer;
|
|
2167
|
+
text-align: left;
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
button.managed-toggle:hover,
|
|
2171
|
+
button.managed-toggle:focus-visible {
|
|
2172
|
+
border-color: color-mix(in srgb, var(--accent) 42%, var(--border));
|
|
2173
|
+
outline: none;
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2056
2176
|
.switch-track {
|
|
2057
|
-
background: var(--surface-muted);
|
|
2058
|
-
border: 1px solid var(--border);
|
|
2177
|
+
background: color-mix(in srgb, #ef4444 18%, var(--surface-muted));
|
|
2178
|
+
border: 1px solid color-mix(in srgb, #ef4444 44%, var(--border));
|
|
2059
2179
|
border-radius: 999px;
|
|
2060
2180
|
display: inline-flex;
|
|
2061
2181
|
padding: 2px;
|
|
@@ -2063,7 +2183,7 @@ code {
|
|
|
2063
2183
|
}
|
|
2064
2184
|
|
|
2065
2185
|
.switch-track span {
|
|
2066
|
-
background:
|
|
2186
|
+
background: #ef4444;
|
|
2067
2187
|
border-radius: 50%;
|
|
2068
2188
|
height: 14px;
|
|
2069
2189
|
transform: translateX(0);
|
|
@@ -2072,10 +2192,15 @@ code {
|
|
|
2072
2192
|
}
|
|
2073
2193
|
|
|
2074
2194
|
.switch-track.checked span {
|
|
2075
|
-
background:
|
|
2195
|
+
background: #22c55e;
|
|
2076
2196
|
transform: translateX(14px);
|
|
2077
2197
|
}
|
|
2078
2198
|
|
|
2199
|
+
.switch-track.checked {
|
|
2200
|
+
background: color-mix(in srgb, #22c55e 18%, var(--surface-muted));
|
|
2201
|
+
border-color: color-mix(in srgb, #22c55e 48%, var(--border));
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2079
2204
|
.app-meta small {
|
|
2080
2205
|
color: var(--text-muted);
|
|
2081
2206
|
font-size: 0.72rem;
|
|
@@ -2091,6 +2216,17 @@ code {
|
|
|
2091
2216
|
word-break: break-word;
|
|
2092
2217
|
}
|
|
2093
2218
|
|
|
2219
|
+
.app-log-link {
|
|
2220
|
+
color: var(--text-primary);
|
|
2221
|
+
text-decoration: none;
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
.app-log-link:hover,
|
|
2225
|
+
.app-log-link:focus-visible {
|
|
2226
|
+
border-color: color-mix(in srgb, var(--accent) 42%, var(--border));
|
|
2227
|
+
outline: none;
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2094
2230
|
.app-action-button {
|
|
2095
2231
|
align-items: center;
|
|
2096
2232
|
background: color-mix(in srgb, var(--accent) 16%, var(--surface-elevated));
|
|
@@ -2174,6 +2310,45 @@ code {
|
|
|
2174
2310
|
word-break: break-word;
|
|
2175
2311
|
}
|
|
2176
2312
|
|
|
2313
|
+
.modal-backdrop {
|
|
2314
|
+
align-items: center;
|
|
2315
|
+
background: rgb(0 0 0 / 62%);
|
|
2316
|
+
display: flex;
|
|
2317
|
+
inset: 0;
|
|
2318
|
+
justify-content: center;
|
|
2319
|
+
padding: 18px;
|
|
2320
|
+
position: fixed;
|
|
2321
|
+
z-index: 100;
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
.confirm-modal {
|
|
2325
|
+
background: var(--surface-elevated);
|
|
2326
|
+
border: 1px solid var(--border-hover);
|
|
2327
|
+
border-radius: 18px;
|
|
2328
|
+
box-shadow: 0 24px 90px rgb(0 0 0 / 48%);
|
|
2329
|
+
display: grid;
|
|
2330
|
+
gap: 12px;
|
|
2331
|
+
max-width: 520px;
|
|
2332
|
+
padding: 22px;
|
|
2333
|
+
width: min(100%, 520px);
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
.confirm-modal h3,
|
|
2337
|
+
.confirm-modal p {
|
|
2338
|
+
margin: 0;
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
.confirm-modal code {
|
|
2342
|
+
overflow-wrap: anywhere;
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
.confirm-modal-actions {
|
|
2346
|
+
display: flex;
|
|
2347
|
+
flex-wrap: wrap;
|
|
2348
|
+
gap: 10px;
|
|
2349
|
+
justify-content: flex-end;
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2177
2352
|
[data-tooltip] {
|
|
2178
2353
|
position: relative;
|
|
2179
2354
|
}
|
|
@@ -2284,6 +2459,57 @@ code {
|
|
|
2284
2459
|
width: min(100%, 520px);
|
|
2285
2460
|
}
|
|
2286
2461
|
|
|
2462
|
+
.app-subnav {
|
|
2463
|
+
width: fit-content;
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2466
|
+
.recommended-app-grid {
|
|
2467
|
+
display: grid;
|
|
2468
|
+
gap: 12px;
|
|
2469
|
+
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
.recommended-app-card {
|
|
2473
|
+
background:
|
|
2474
|
+
linear-gradient(135deg, color-mix(in srgb, var(--accent) 6%, transparent), transparent 40%),
|
|
2475
|
+
var(--surface-muted);
|
|
2476
|
+
border: 1px solid var(--border);
|
|
2477
|
+
border-radius: 18px;
|
|
2478
|
+
display: grid;
|
|
2479
|
+
gap: 12px;
|
|
2480
|
+
padding: 16px;
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2483
|
+
.recommended-app-card strong {
|
|
2484
|
+
font-size: 1.05rem;
|
|
2485
|
+
}
|
|
2486
|
+
|
|
2487
|
+
.recommended-app-card p {
|
|
2488
|
+
color: var(--text-secondary);
|
|
2489
|
+
margin: 4px 0 0;
|
|
2490
|
+
}
|
|
2491
|
+
|
|
2492
|
+
.os-icon-list {
|
|
2493
|
+
display: flex;
|
|
2494
|
+
flex-wrap: wrap;
|
|
2495
|
+
gap: 6px;
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2498
|
+
.os-icon-list span {
|
|
2499
|
+
align-items: center;
|
|
2500
|
+
background: var(--surface-elevated);
|
|
2501
|
+
border: 1px solid var(--border);
|
|
2502
|
+
border-radius: 999px;
|
|
2503
|
+
color: var(--text-secondary);
|
|
2504
|
+
display: inline-flex;
|
|
2505
|
+
font-size: 0.72rem;
|
|
2506
|
+
font-weight: 900;
|
|
2507
|
+
justify-content: center;
|
|
2508
|
+
min-height: 28px;
|
|
2509
|
+
min-width: 28px;
|
|
2510
|
+
padding: 4px 7px;
|
|
2511
|
+
}
|
|
2512
|
+
|
|
2287
2513
|
.pill-tabs button {
|
|
2288
2514
|
background: transparent;
|
|
2289
2515
|
border: 0;
|
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.
|
|
15
|
+
# Version: 3.29.9
|
|
16
16
|
#
|
|
17
17
|
# Quick Install:
|
|
18
18
|
# npm install -g aidevops && aidevops update (recommended)
|