@tt-a1i/openpi 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/THIRD_PARTY_NOTICES.md +242 -0
  2. package/extensions/ai-providers/cursor/connect-frame-reader.ts +76 -0
  3. package/extensions/ai-providers/cursor/provider.ts +6 -19
  4. package/extensions/file-mutation-display/index.ts +13 -9
  5. package/extensions/shared/agent-transcript.ts +3 -2
  6. package/extensions/web/index.ts +69 -5
  7. package/extensions/workflows/artifacts.ts +362 -23
  8. package/extensions/workflows/dashboard.ts +2 -0
  9. package/package.json +28 -4
  10. package/web/dist/app.js +87 -0
  11. package/web/dist/favicon.svg +9 -0
  12. package/web/dist/index.html +15 -0
  13. package/web/dist/styles.css +3 -0
  14. package/web/host/web-host.ts +6 -9
  15. package/web/ui/index.html +3 -131
  16. package/web/ui/public/favicon.svg +9 -0
  17. package/web/ui/src/app/App.tsx +134 -0
  18. package/web/ui/src/app/providers.tsx +38 -0
  19. package/web/ui/src/components/Markdown.tsx +58 -0
  20. package/web/ui/src/components/OpenPiLogo.tsx +41 -0
  21. package/web/ui/src/features/activity/ActivityBar.tsx +120 -0
  22. package/web/ui/src/features/composer/Composer.tsx +237 -0
  23. package/web/ui/src/features/sessions/SessionSidebar.tsx +418 -0
  24. package/web/ui/src/features/transcript/Transcript.tsx +860 -0
  25. package/web/ui/src/i18n.ts +159 -0
  26. package/web/ui/src/lib/format.ts +57 -0
  27. package/web/ui/src/main.tsx +16 -0
  28. package/web/ui/src/protocol/client.ts +199 -0
  29. package/web/ui/src/protocol/event-stream.ts +88 -0
  30. package/web/ui/src/store/web-store.ts +926 -0
  31. package/web/ui/src/styles.css +420 -0
  32. package/web/ui/tsconfig.json +12 -0
  33. package/web/ui/vite-env.d.ts +1 -0
  34. package/web/vite.config.mjs +21 -1
  35. package/web/host/static-assets.ts +0 -4
  36. package/web/ui/app.js +0 -1700
  37. package/web/ui/styles.css +0 -680
package/web/ui/index.html CHANGED
@@ -5,138 +5,10 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <meta name="color-scheme" content="light" />
7
7
  <title>OpenPI</title>
8
- <link rel="stylesheet" href="/styles.css" />
9
- <script src="/marked.js"></script>
10
- <script src="/app.js" defer></script>
8
+ <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
11
9
  </head>
12
10
  <body>
13
- <div class="app-shell">
14
- <aside class="session-sidebar" aria-label="Session navigation">
15
- <div class="sidebar-brand">
16
- <strong><span class="brand-word">Open</span><span class="pixel-mark" aria-label="OpenPI"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></span></strong>
17
- <button id="collapse-sidebar" class="collapse-button" type="button" aria-label="Collapse sidebar" title="Collapse sidebar">
18
- <svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><rect width="18" height="18" x="3" y="3" rx="2"></rect><path d="M9 3v18"></path></svg>
19
- </button>
20
- </div>
21
-
22
- <button class="new-session-button" type="button" data-i18n-aria="newSession" data-i18n-title="newSession">
23
- <svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"></circle><path d="M8 12h8M12 8v8"></path></svg>
24
- <span data-i18n="newSession">New session</span>
25
- </button>
26
-
27
- <div class="workspace-heading">
28
- <span class="workspace-heading-label" data-i18n="workspaces">Workspaces</span>
29
- <div class="session-search">
30
- <span class="visually-hidden" data-i18n="searchConversations">Search conversations</span>
31
- <svg class="session-search-icon icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="10.8" cy="10.8" r="6.8"></circle><path d="m16 16 4.5 4.5"></path></svg>
32
- <input id="search" type="search" data-i18n-aria="searchConversations" placeholder="搜索会话..." autocomplete="off" />
33
- <button id="search-clear" class="session-search-clear" type="button" data-i18n-aria="closeSearch" data-i18n-title="closeSearch">
34
- <svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"></path></svg>
35
- </button>
36
- </div>
37
- <div class="workspace-actions">
38
- <button id="search-toggle" class="icon-button" type="button" data-i18n-aria="searchConversations" data-i18n-title="searchConversations">
39
- <svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="10.8" cy="10.8" r="6.8"></circle><path d="m16 16 4.5 4.5"></path></svg>
40
- </button>
41
- <button id="import-workspace" class="icon-button" type="button" aria-label="Add workspace" title="Add workspace" data-i18n-aria="addWorkspace" data-i18n-title="addWorkspace">
42
- <svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 10v6M9 13h6"></path><path d="M22 18a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4a2 2 0 0 1 1.6.8l1.8 2.4H20a2 2 0 0 1 2 2z"></path></svg>
43
- </button>
44
- </div>
45
- </div>
46
-
47
- <div id="workspaces" class="workspace-tree" role="tree" data-empty="No sessions">No sessions</div>
48
- <dialog id="workspace-menu" class="workspace-menu">
49
- <button type="button" data-action="rename">Rename workspace</button>
50
- <button type="button" data-action="delete" class="danger-action">Remove from sidebar</button>
51
- </dialog>
52
- <dialog id="workspace-rename-dialog" class="workspace-rename-dialog">
53
- <form method="dialog" id="workspace-rename-form">
54
- <strong>Rename workspace</strong>
55
- <input id="workspace-name-input" type="text" maxlength="80" autocomplete="off" aria-label="Workspace name" />
56
- <div class="dialog-actions">
57
- <button type="submit" value="cancel">Cancel</button>
58
- <button id="save-workspace-name" type="submit" value="save">Save</button>
59
- </div>
60
- </form>
61
- </dialog>
62
- <dialog id="session-menu" class="workspace-menu">
63
- <button type="button" data-action="rename" data-i18n="renameConversation">Rename conversation</button>
64
- <button type="button" data-action="archive" data-i18n="archiveConversation">Archive conversation</button>
65
- </dialog>
66
- <dialog id="session-rename-dialog" class="workspace-rename-dialog">
67
- <form method="dialog" id="session-rename-form">
68
- <strong data-i18n="renameConversation">Rename conversation</strong>
69
- <input id="session-name-input" type="text" maxlength="80" autocomplete="off" data-i18n-aria="conversationName" aria-label="Conversation name" />
70
- <div class="dialog-actions">
71
- <button type="submit" value="cancel">Cancel</button>
72
- <button id="save-session-name" type="submit" value="save">Save</button>
73
- </div>
74
- </form>
75
- </dialog>
76
-
77
- </aside>
78
-
79
- <main class="conversation-shell landing">
80
- <header class="workbench-header">
81
- <button id="mobile-sidebar" class="mobile-sidebar-button" type="button" aria-label="Open sidebar" title="Open sidebar">
82
- <svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h16"></path></svg>
83
- </button>
84
- <div id="session-header" class="session-header">
85
- <strong data-i18n="newSession">New session</strong>
86
- <small data-i18n="chooseWorkspaceHint">Choose a workspace and describe the work</small>
87
- </div>
88
- <span id="connection-state" class="connection-state" aria-live="polite">Connecting</span>
89
- </header>
90
-
91
- <section id="conversation" class="conversation" aria-label="Conversation">
92
- <div class="landing-welcome">
93
- <div class="landing-brand">
94
- <strong>Open<span class="pixel-mark" aria-label="OpenPI"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></span></strong>
95
- </div>
96
- </div>
97
- </section>
98
-
99
- <div class="composer-dock">
100
- <div class="workspace-picker-row">
101
- <button id="select-workspace" class="workspace-picker" type="button" data-i18n-aria="selectWorkspace">
102
- <svg class="workspace-symbol icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M3.5 7.5a2 2 0 0 1 2-2h5l2 2h6a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-13a2 2 0 0 1-2-2z"></path></svg>
103
- <span id="selected-workspace" data-i18n="selectWorkspace">Select workspace</span>
104
- <svg class="context-chevron icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m8 10 4 4 4-4"></path></svg>
105
- </button>
106
- <div id="workspace-picker-menu" class="workspace-picker-menu" hidden role="listbox"></div>
107
- </div>
108
- <form id="composer" class="composer dormant">
109
- <textarea id="prompt-input" rows="1" readonly data-i18n-placeholder="promptStart" data-i18n-aria="describeTask" placeholder="Describe a task to start with this workspace"></textarea>
110
- <div class="composer-toolbar">
111
- <div class="model-picker-wrap">
112
- <button id="model-picker" class="model-picker" type="button" aria-haspopup="listbox" aria-expanded="false" aria-label="Select model" disabled>
113
- <span id="model-picker-value" class="model-picker-value">Loading models...</span>
114
- <svg class="model-picker-chevron icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m7 10 5 5 5-5"></path></svg>
115
- </button>
116
- <div id="model-menu" class="model-menu" role="listbox" hidden></div>
117
- </div>
118
- <button id="send-prompt" class="send-button" type="submit" aria-label="Send" title="Send">
119
- <svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 19V5"></path><path d="m6 11 6-6 6 6"></path></svg>
120
- </button>
121
- <button id="stop-turn" class="stop-button" type="button" aria-label="Stop turn" title="Stop turn" data-i18n-aria="stopTurn" data-i18n-title="stopTurn" hidden>
122
- <svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><rect x="7" y="7" width="10" height="10" rx="1"></rect></svg>
123
- </button>
124
- </div>
125
- <div id="composer-hint" class="composer-hint" aria-live="polite"></div>
126
- </form>
127
- </div>
128
- </main>
129
- <dialog id="workspace-delete-dialog" class="workspace-delete-dialog">
130
- <form method="dialog" id="workspace-delete-form">
131
- <strong data-i18n="deleteWorkspace">删除工作区</strong>
132
- <p id="workspace-delete-message"></p>
133
- <div class="dialog-actions">
134
- <button type="submit" value="cancel" data-i18n="cancel">取消</button>
135
- <button type="submit" value="delete" class="danger-action" data-i18n="deleteWorkspace">删除工作区</button>
136
- </div>
137
- </form>
138
- </dialog>
139
- </div>
140
- <button class="sidebar-scrim" type="button" aria-label="Close sidebar"></button>
11
+ <div id="root"></div>
12
+ <script type="module" src="/src/main.tsx"></script>
141
13
  </body>
142
14
  </html>
@@ -0,0 +1,9 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
2
+ <rect width="64" height="64" rx="12" fill="#faf7ed"/>
3
+ <g fill="#1f252c">
4
+ <path d="M14 14h10v10H14zM24 14h10v10H24zM34 14h10v10H34z"/>
5
+ <path d="M14 24h10v10H14zM34 24h10v10H34z"/>
6
+ <path d="M14 34h10v10H14zM24 34h10v10H24zM44 34h10v10H44z"/>
7
+ <path d="M14 44h10v10H14zM44 44h10v10H44z"/>
8
+ </g>
9
+ </svg>
@@ -0,0 +1,134 @@
1
+ import { Menu, PanelLeftOpen, X } from "lucide-react";
2
+ import { useCallback, useEffect } from "react";
3
+ import { useTranslation } from "react-i18next";
4
+ import { useStore } from "zustand";
5
+ import { OpenPiLogo } from "../components/OpenPiLogo.tsx";
6
+ import { Composer } from "../features/composer/Composer.tsx";
7
+ import { SessionSidebar } from "../features/sessions/SessionSidebar.tsx";
8
+ import { Transcript } from "../features/transcript/Transcript.tsx";
9
+ import { webStore } from "../store/web-store.ts";
10
+
11
+ export function App() {
12
+ const state = useStore(webStore);
13
+ const { t } = useTranslation();
14
+ const { actions } = state;
15
+
16
+ useEffect(() => {
17
+ actions.start();
18
+ return actions.stop;
19
+ }, [actions]);
20
+
21
+ const selected = state.snapshot?.selectedSession;
22
+ const hasMessages =
23
+ selected?.entries.some(
24
+ (entry) => entry.type === "message" && entry.message,
25
+ ) || state.liveMessages.length > 0;
26
+ const landing = !selected || !hasMessages;
27
+ const resend = useCallback(
28
+ (content: string) => actions.sendPrompt(content),
29
+ [actions],
30
+ );
31
+
32
+ return (
33
+ <div
34
+ className={`app-shell ${state.sidebarCollapsed ? "sidebar-collapsed" : ""} ${state.mobileSidebarOpen ? "sidebar-open" : ""}`}
35
+ >
36
+ <SessionSidebar
37
+ snapshot={state.snapshot}
38
+ selectedPath={state.selectedPath}
39
+ selectedWorkspace={state.selectedWorkspace}
40
+ collapsed={state.collapsed}
41
+ query={state.query}
42
+ searchOpen={state.searchOpen}
43
+ mobileOpen={state.mobileSidebarOpen}
44
+ actions={actions}
45
+ />
46
+ {state.sidebarCollapsed && (
47
+ <button
48
+ className="sidebar-expand"
49
+ type="button"
50
+ aria-label={t("expandSidebar")}
51
+ title={t("expandSidebar")}
52
+ onClick={() => actions.toggleSidebar(false)}
53
+ >
54
+ <PanelLeftOpen />
55
+ </button>
56
+ )}
57
+ <main className={`conversation-shell ${landing ? "landing" : ""}`}>
58
+ <h1 className="sr-only">OpenPI</h1>
59
+ <header className="mobile-header">
60
+ <button
61
+ type="button"
62
+ aria-label={t("openSidebar")}
63
+ onClick={() => actions.toggleSidebar(true)}
64
+ >
65
+ <Menu />
66
+ </button>
67
+ <span className={`connection-state ${state.connection}`}>
68
+ {t(state.connection)}
69
+ </span>
70
+ </header>
71
+ {state.sessionSwitching ? (
72
+ <div className="conversation switching" role="status">
73
+ <div className="conversation-running">
74
+ <span className="conversation-running-dot" />
75
+ <span>{t("switchingSession")}</span>
76
+ </div>
77
+ </div>
78
+ ) : landing ? (
79
+ <section
80
+ className="conversation landing-conversation"
81
+ aria-label="Conversation"
82
+ >
83
+ <div className="landing-welcome">
84
+ <OpenPiLogo animated />
85
+ </div>
86
+ </section>
87
+ ) : state.snapshot ? (
88
+ <Transcript
89
+ snapshot={state.snapshot}
90
+ liveMessages={state.liveMessages}
91
+ liveRunning={state.liveRunning}
92
+ livePhase={state.livePhase}
93
+ liveRetry={state.liveRetry}
94
+ thinkingStarts={state.thinkingStarts}
95
+ thinkingDurations={state.thinkingDurations}
96
+ scrollToBottom={state.scrollToBottom}
97
+ onResend={resend}
98
+ />
99
+ ) : null}
100
+ <Composer
101
+ activeTurn={state.activeTurn}
102
+ turnCancellationPending={state.turnCancellationPending}
103
+ turnTerminalStatus={state.turnTerminalStatus}
104
+ pendingFollowUpsReceipt={state.pendingFollowUpsReceipt}
105
+ snapshot={state.snapshot}
106
+ selectedWorkspace={state.selectedWorkspace}
107
+ sessionSwitching={state.sessionSwitching}
108
+ promptAdmissionPending={state.promptAdmissionPending}
109
+ liveRunning={state.liveRunning}
110
+ landing={landing}
111
+ actions={actions}
112
+ />
113
+ {state.notice && (
114
+ <div className="notice" role="alert">
115
+ <span>{state.notice}</span>
116
+ <button
117
+ type="button"
118
+ aria-label={t("close")}
119
+ onClick={actions.clearNotice}
120
+ >
121
+ <X />
122
+ </button>
123
+ </div>
124
+ )}
125
+ </main>
126
+ <button
127
+ className="sidebar-scrim"
128
+ type="button"
129
+ aria-label={t("close")}
130
+ onClick={actions.closeMobileSidebar}
131
+ />
132
+ </div>
133
+ );
134
+ }
@@ -0,0 +1,38 @@
1
+ import { Theme } from "@astryxdesign/core/theme";
2
+ import { neutralTheme } from "@astryxdesign/theme-neutral/built";
3
+ import { type PropsWithChildren, useEffect, useState } from "react";
4
+ import { I18nextProvider } from "react-i18next";
5
+ import { useStore } from "zustand";
6
+ import { i18n } from "../i18n.ts";
7
+ import { webStore } from "../store/web-store.ts";
8
+
9
+ export function Providers({ children }: PropsWithChildren) {
10
+ const preference =
11
+ useStore(webStore, (state) => state.snapshot?.preferences?.theme) ??
12
+ "system";
13
+ const [systemDark, setSystemDark] = useState(
14
+ () => window.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false,
15
+ );
16
+ useEffect(() => {
17
+ const media = window.matchMedia?.("(prefers-color-scheme: dark)");
18
+ if (!media) return;
19
+ const update = () => setSystemDark(media.matches);
20
+ update();
21
+ media.addEventListener("change", update);
22
+ return () => media.removeEventListener("change", update);
23
+ }, []);
24
+ const mode =
25
+ preference === "dark" || (preference === "system" && systemDark)
26
+ ? "dark"
27
+ : "light";
28
+ useEffect(() => {
29
+ document.documentElement.dataset.theme = mode;
30
+ }, [mode]);
31
+ return (
32
+ <I18nextProvider i18n={i18n}>
33
+ <Theme theme={neutralTheme} mode={mode}>
34
+ {children}
35
+ </Theme>
36
+ </I18nextProvider>
37
+ );
38
+ }
@@ -0,0 +1,58 @@
1
+ import { memo } from "react";
2
+ import ReactMarkdown from "react-markdown";
3
+ import rehypeSanitize from "rehype-sanitize";
4
+ import remarkBreaks from "remark-breaks";
5
+ import remarkGfm from "remark-gfm";
6
+
7
+ function safeUrl(value: string) {
8
+ if (!value.trim()) return "";
9
+ try {
10
+ const url = new URL(value, document.baseURI);
11
+ return ["http:", "https:", "mailto:", "tel:"].includes(url.protocol)
12
+ ? url.href
13
+ : "";
14
+ } catch {
15
+ return "";
16
+ }
17
+ }
18
+
19
+ export const Markdown = memo(function Markdown({
20
+ children,
21
+ }: {
22
+ children: string;
23
+ }) {
24
+ return (
25
+ <div className="markdown">
26
+ <ReactMarkdown
27
+ remarkPlugins={[remarkGfm, remarkBreaks]}
28
+ rehypePlugins={[rehypeSanitize]}
29
+ urlTransform={safeUrl}
30
+ components={{
31
+ a({ href, children: label, ...props }) {
32
+ const safeHref = safeUrl(href ?? "");
33
+ return safeHref ? (
34
+ <a {...props} href={safeHref} target="_blank" rel="noreferrer">
35
+ {label}
36
+ </a>
37
+ ) : (
38
+ label
39
+ );
40
+ },
41
+ img({ src, alt, title }) {
42
+ const safeHref = safeUrl(src ?? "");
43
+ const label = alt || "image";
44
+ return safeHref ? (
45
+ <a href={safeHref} title={title} target="_blank" rel="noreferrer">
46
+ [image: {label}]
47
+ </a>
48
+ ) : (
49
+ <span>{label}</span>
50
+ );
51
+ },
52
+ }}
53
+ >
54
+ {children}
55
+ </ReactMarkdown>
56
+ </div>
57
+ );
58
+ });
@@ -0,0 +1,41 @@
1
+ import { useState } from "react";
2
+
3
+ interface OpenPiLogoProps {
4
+ animated?: boolean;
5
+ compact?: boolean;
6
+ }
7
+
8
+ const cellIds = Array.from({ length: 16 }, (_, index) => `cell-${index + 1}`);
9
+
10
+ export function OpenPiLogo({
11
+ animated = false,
12
+ compact = false,
13
+ }: OpenPiLogoProps) {
14
+ const [replay, setReplay] = useState(0);
15
+ const count = compact ? 10 : 16;
16
+ const logo = (
17
+ <strong
18
+ className={compact ? "brand-lockup compact" : "brand-lockup"}
19
+ key={replay}
20
+ >
21
+ <span className="brand-word">Open</span>
22
+ <span className="pixel-mark" role="img" aria-label="OpenPI">
23
+ {cellIds.slice(0, count).map((cellId) => (
24
+ <i key={cellId} />
25
+ ))}
26
+ </span>
27
+ </strong>
28
+ );
29
+
30
+ if (!animated) return logo;
31
+ return (
32
+ <button
33
+ className="landing-brand"
34
+ type="button"
35
+ aria-label="Replay OpenPI logo animation"
36
+ onClick={() => setReplay((value) => value + 1)}
37
+ >
38
+ {logo}
39
+ </button>
40
+ );
41
+ }
@@ -0,0 +1,120 @@
1
+ import { Check, X } from "lucide-react";
2
+ import { useEffect, useState } from "react";
3
+ import type { WebSnapshot } from "../../../../protocol/types.ts";
4
+ import { formatElapsedMs } from "../../lib/format.ts";
5
+
6
+ type Status = "running" | "done" | "error" | "warn" | "unknown";
7
+
8
+ function canonicalStatus(value: string): Status {
9
+ if (value === "running") return "running";
10
+ if (value === "done" || value === "completed") return "done";
11
+ if (["error", "failed", "aborted", "killed", "timed_out"].includes(value))
12
+ return "error";
13
+ if (value === "uncertain") return "warn";
14
+ return "unknown";
15
+ }
16
+
17
+ function Chip({
18
+ kind,
19
+ label,
20
+ status,
21
+ }: {
22
+ kind: string;
23
+ label: string;
24
+ status: Status;
25
+ }) {
26
+ return (
27
+ <span className={`activity-chip ${kind} ${status}`}>
28
+ {status === "running" ? (
29
+ <i className="activity-chip-dot" />
30
+ ) : status === "done" ? (
31
+ <Check />
32
+ ) : status === "error" ? (
33
+ <X />
34
+ ) : (
35
+ <span className="activity-chip-glyph">?</span>
36
+ )}
37
+ <span className="activity-chip-text">{label}</span>
38
+ </span>
39
+ );
40
+ }
41
+
42
+ export function ActivityBar({ snapshot }: { snapshot: WebSnapshot | null }) {
43
+ const [, tick] = useState(0);
44
+ const capabilities = snapshot?.runtime.capabilities;
45
+ const running = [
46
+ ...(capabilities?.subagents?.items ?? []),
47
+ ...(capabilities?.workflows?.items ?? []),
48
+ ...(capabilities?.["background-terminals"]?.items ?? []),
49
+ ].some((item) => item.status === "running");
50
+ useEffect(() => {
51
+ if (!running) return;
52
+ const interval = window.setInterval(
53
+ () => tick((value) => value + 1),
54
+ 1_000,
55
+ );
56
+ return () => window.clearInterval(interval);
57
+ }, [running]);
58
+
59
+ const chips: Array<{
60
+ key: string;
61
+ kind: string;
62
+ label: string;
63
+ status: Status;
64
+ }> = [];
65
+ for (const workflow of capabilities?.workflows?.items ?? []) {
66
+ const settled = workflow.agents.total - workflow.agents.running;
67
+ const progress = workflow.agents.total
68
+ ? ` · ${settled}/${workflow.agents.total} agents`
69
+ : "";
70
+ const phase =
71
+ workflow.status === "running" && workflow.currentPhase
72
+ ? ` · ${workflow.currentPhase}`
73
+ : "";
74
+ const elapsed = formatElapsedMs(workflow.startedAt, workflow.finishedAt);
75
+ chips.push({
76
+ key: `workflow-${workflow.runId}`,
77
+ kind: "workflow",
78
+ label: `${workflow.name || workflow.runId}${phase}${progress}${elapsed ? ` · ${elapsed}` : ""}`,
79
+ status: canonicalStatus(workflow.status),
80
+ });
81
+ }
82
+ for (const subagent of capabilities?.subagents?.items ?? []) {
83
+ const elapsed = formatElapsedMs(subagent.createdAt, subagent.settledAt);
84
+ chips.push({
85
+ key: `subagent-${subagent.id}`,
86
+ kind: "subagent",
87
+ label: `${subagent.title || subagent.id}${elapsed ? ` · ${elapsed}` : ""}`,
88
+ status: canonicalStatus(subagent.status),
89
+ });
90
+ }
91
+ for (const terminal of capabilities?.["background-terminals"]?.items ?? []) {
92
+ const elapsed = formatElapsedMs(terminal.createdAt, terminal.settledAt);
93
+ chips.push({
94
+ key: `terminal-${terminal.id}`,
95
+ kind: "terminal",
96
+ label: `${terminal.title || terminal.id}${elapsed ? ` · ${elapsed}` : ""}`,
97
+ status: canonicalStatus(terminal.status),
98
+ });
99
+ }
100
+ chips.sort(
101
+ (left, right) =>
102
+ Number(right.status === "running") - Number(left.status === "running"),
103
+ );
104
+ const visible = chips.slice(0, 5);
105
+ const omitted =
106
+ chips.length -
107
+ visible.length +
108
+ (capabilities?.subagents?.omitted ?? 0) +
109
+ (capabilities?.workflows?.omitted ?? 0) +
110
+ (capabilities?.["background-terminals"]?.omitted ?? 0);
111
+ if (!visible.length && !omitted) return null;
112
+ return (
113
+ <div className="activity-bar" role="status" aria-label="Runtime activity">
114
+ {visible.map(({ key, ...chip }) => (
115
+ <Chip key={key} {...chip} />
116
+ ))}
117
+ {omitted > 0 && <span className="activity-chip more">+{omitted}</span>}
118
+ </div>
119
+ );
120
+ }