paseo-bm-plugin 0.0.0-placeholder.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 (101) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +53 -0
  3. package/client/agent-tree.ts +308 -0
  4. package/client/answer-state.ts +62 -0
  5. package/client/bead-chips.tsx +147 -0
  6. package/client/beads-header-button.ts +108 -0
  7. package/client/beads-model.ts +581 -0
  8. package/client/beads-screen.tsx +516 -0
  9. package/client/beads-tab.tsx +58 -0
  10. package/client/chat-card.tsx +636 -0
  11. package/client/chat-cards.ts +1038 -0
  12. package/client/dashboard-actions.tsx +255 -0
  13. package/client/dashboard-model.ts +947 -0
  14. package/client/dashboard-view.ts +215 -0
  15. package/client/dashboard.tsx +318 -0
  16. package/client/launch-manager.ts +323 -0
  17. package/client/launcher.tsx +516 -0
  18. package/client/markdown-view.tsx +112 -0
  19. package/client/markdown.ts +145 -0
  20. package/client/settings.tsx +104 -0
  21. package/client/setup-model.ts +552 -0
  22. package/client/setup-screen.tsx +913 -0
  23. package/client/slot.ts +47 -0
  24. package/client/tree.tsx +204 -0
  25. package/client/ui.tsx +262 -0
  26. package/client/waiting-pills-model.ts +156 -0
  27. package/client/waiting-pills.tsx +201 -0
  28. package/index.client.tsx +232 -0
  29. package/index.server.ts +168 -0
  30. package/package.json +35 -0
  31. package/paseo-plugin.json +6 -0
  32. package/roles/manager.md +181 -0
  33. package/roles/reviewer.md +160 -0
  34. package/roles/worker.md +407 -0
  35. package/server/agent-labels.ts +194 -0
  36. package/server/agent-role.ts +102 -0
  37. package/server/answer-marks.ts +120 -0
  38. package/server/bead-actions.ts +88 -0
  39. package/server/bead-work.ts +80 -0
  40. package/server/beads-store.ts +342 -0
  41. package/server/bm-report.ts +433 -0
  42. package/server/chat-peers.ts +65 -0
  43. package/server/chat-rpc.ts +122 -0
  44. package/server/chat-waiting.ts +182 -0
  45. package/server/collector.ts +629 -0
  46. package/server/config-writer.ts +222 -0
  47. package/server/cost.ts +88 -0
  48. package/server/dashboard-rpc.ts +662 -0
  49. package/server/fallback-detect.ts +183 -0
  50. package/server/fallback-handover.ts +365 -0
  51. package/server/fallback-manager.ts +170 -0
  52. package/server/fallback-reviewer.ts +198 -0
  53. package/server/fallback-rpc.ts +306 -0
  54. package/server/fallback-settings.ts +322 -0
  55. package/server/fallback-state.ts +518 -0
  56. package/server/fallback-switch.ts +191 -0
  57. package/server/fallback-wait.ts +188 -0
  58. package/server/format-check.ts +352 -0
  59. package/server/install-home.ts +187 -0
  60. package/server/live-timeline.ts +129 -0
  61. package/server/manager-instructions.ts +9 -0
  62. package/server/manager.ts +647 -0
  63. package/server/model-costs.ts +238 -0
  64. package/server/notice-queue.ts +315 -0
  65. package/server/notices.ts +81 -0
  66. package/server/paseo-cli.ts +115 -0
  67. package/server/provider-id.ts +12 -0
  68. package/server/review-budget.ts +208 -0
  69. package/server/reviewer-instructions.ts +9 -0
  70. package/server/role-choices.ts +161 -0
  71. package/server/role-extras.ts +270 -0
  72. package/server/role-hook.ts +347 -0
  73. package/server/role-mode.ts +397 -0
  74. package/server/role-settings-rpc.ts +325 -0
  75. package/server/roles.ts +96 -0
  76. package/server/settings-notices.ts +112 -0
  77. package/server/setup-rpc.ts +70 -0
  78. package/server/setup-skills.ts +121 -0
  79. package/server/setup-tools.ts +162 -0
  80. package/server/shell.ts +68 -0
  81. package/server/stop-propagation.ts +365 -0
  82. package/server/tools-check.ts +118 -0
  83. package/server/trace-store.ts +1137 -0
  84. package/server/traces.ts +1356 -0
  85. package/server/worker-instructions.ts +9 -0
  86. package/server/workflow-steps.ts +422 -0
  87. package/shared/bead-ids.ts +25 -0
  88. package/shared/bm-fallback.ts +91 -0
  89. package/shared/bm-format.ts +424 -0
  90. package/shared/bm-questions.ts +213 -0
  91. package/shared/bm-report.ts +433 -0
  92. package/shared/contracts.ts +1371 -0
  93. package/shared/fallback-patterns.ts +201 -0
  94. package/shared/fallback.ts +46 -0
  95. package/shared/new-request.ts +20 -0
  96. package/shared/order.ts +22 -0
  97. package/shared/prices.ts +65 -0
  98. package/shared/settings.ts +57 -0
  99. package/shared/sole-worker.ts +20 -0
  100. package/shared/version.ts +6 -0
  101. package/tsconfig.json +16 -0
package/client/slot.ts ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * A single-value hand-off between something that cannot talk to a surface (a
3
+ * Command Center item, a slash command) and the surface that acts on it
4
+ * (delta 20260918f §4.1). The newest `put` wins.
5
+ *
6
+ * `take()` tells readers when it clears a value, so a surface that reads the
7
+ * slot through `useSyncExternalStore` redraws when a notice is dismissed. A
8
+ * reader that takes again while being told gets `null` and tells nobody, so
9
+ * there is no loop.
10
+ *
11
+ * Pure: no React, no React Native, no `server/` import.
12
+ */
13
+ export interface Slot<T> {
14
+ put(value: T): void;
15
+ /** Returns and clears the value. */
16
+ take(): T | null;
17
+ /** Reads the value without clearing it. */
18
+ peek(): T | null;
19
+ subscribe(listener: () => void): () => void;
20
+ }
21
+
22
+ export function createSlot<T>(): Slot<T> {
23
+ let pending: T | null = null;
24
+ const listeners = new Set<() => void>();
25
+ const tell = () => {
26
+ for (const listener of listeners) listener();
27
+ };
28
+ return {
29
+ put(value) {
30
+ pending = value;
31
+ tell();
32
+ },
33
+ take() {
34
+ const value = pending;
35
+ pending = null;
36
+ if (value !== null) tell();
37
+ return value;
38
+ },
39
+ peek: () => pending,
40
+ subscribe(listener) {
41
+ listeners.add(listener);
42
+ return () => {
43
+ listeners.delete(listener);
44
+ };
45
+ },
46
+ };
47
+ }
@@ -0,0 +1,204 @@
1
+ /**
2
+ * "Beads agents" workspace panel (WP-113): the Manager -> Worker -> Reviewer
3
+ * tree with per-agent status, polled through `agents.list`, and the role
4
+ * configuration in effect from `roles.describe`.
5
+ *
6
+ * `AgentTreePanel` owns the data (RPC + query); `AgentTreeView` only renders
7
+ * from props and uses no hooks, so it can be rendered in tests without a
8
+ * renderer. Tree building, texts and styles live in `agent-tree.ts`.
9
+ *
10
+ * Read-only: pressing a row opens that agent. There is no stop, archive or
11
+ * delete action; agent lifecycle belongs to the user (ADR-005).
12
+ *
13
+ * Client rules: React Native primitives only, every color from theme.colors,
14
+ * no Node builtin imports.
15
+ */
16
+ import { type PluginTheme } from "@getpaseo/plugin";
17
+ import { type PluginWorkspacePanelProps, useRpc } from "@getpaseo/plugin/client";
18
+ import { useQuery } from "@tanstack/react-query";
19
+ import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
20
+ import { agentsListRpc, rolesDescribeRpc, type AgentNode, type RoleDescriptor } from "../shared/contracts";
21
+ import {
22
+ AGENT_TREE_POLL_MS,
23
+ EMPTY_AGENTS_TEXT,
24
+ NO_ROLES_TEXT,
25
+ agentSections,
26
+ buildAgentTree,
27
+ indentFor,
28
+ loadErrorText,
29
+ roleConfigRows,
30
+ treeStyles,
31
+ treeToneColor,
32
+ type TreeStyles,
33
+ } from "./agent-tree";
34
+
35
+ /** Loading state of one RPC, as the view needs it. */
36
+ export type Loadable<T> =
37
+ | { status: "pending" }
38
+ | { status: "error"; error: unknown }
39
+ | { status: "success"; data: T };
40
+
41
+ export interface AgentTreeViewProps {
42
+ theme: PluginTheme;
43
+ compact: boolean;
44
+ agents: Loadable<readonly AgentNode[]>;
45
+ roles: Loadable<readonly RoleDescriptor[]>;
46
+ /** Undefined on hosts without `navigation.openAgent`: rows are then not pressable. */
47
+ openAgent?: (input: { agentId: string }) => void;
48
+ onRetryAgents?: () => void;
49
+ onRetryRoles?: () => void;
50
+ }
51
+
52
+ export function AgentTreePanel({ theme, layout, navigation, workspaceId }: PluginWorkspacePanelProps) {
53
+ const listAgents = useRpc(agentsListRpc);
54
+ const describeRoles = useRpc(rolesDescribeRpc);
55
+
56
+ const agents = useQuery({
57
+ queryKey: ["paseo-bm", "agent-tree", "agents", workspaceId],
58
+ queryFn: async () => (await listAgents({ workspaceId })).agents,
59
+ refetchInterval: AGENT_TREE_POLL_MS,
60
+ });
61
+ const roles = useQuery({
62
+ queryKey: ["paseo-bm", "agent-tree", "roles"],
63
+ queryFn: async () => (await describeRoles({})).roles,
64
+ });
65
+
66
+ return (
67
+ <AgentTreeView
68
+ theme={theme}
69
+ compact={layout.compact}
70
+ agents={toLoadable(agents)}
71
+ roles={toLoadable(roles)}
72
+ openAgent={navigation?.openAgent}
73
+ onRetryAgents={() => void agents.refetch()}
74
+ onRetryRoles={() => void roles.refetch()}
75
+ />
76
+ );
77
+ }
78
+
79
+ function toLoadable<T>(query: { status: "pending" | "error" | "success"; data: T | undefined; error: unknown }): Loadable<T> {
80
+ // Keep showing the last good data while a background poll fails.
81
+ if (query.data !== undefined) return { status: "success", data: query.data };
82
+ if (query.status === "error") return { status: "error", error: query.error };
83
+ return { status: "pending" };
84
+ }
85
+
86
+ export function AgentTreeView(props: AgentTreeViewProps) {
87
+ const { theme, compact } = props;
88
+ const styles = treeStyles(theme, compact);
89
+
90
+ return (
91
+ <ScrollView style={styles.screen} contentContainerStyle={styles.content}>
92
+ <Text style={styles.heading}>Beads agents</Text>
93
+ {!props.openAgent ? (
94
+ <Text style={[styles.notice, { color: treeToneColor(theme, "warning") }]}>
95
+ This Paseo version cannot open agents from plugins. Update Paseo to open them from here.
96
+ </Text>
97
+ ) : null}
98
+ <AgentsBlock {...props} styles={styles} />
99
+
100
+ <Text style={styles.heading}>Role configuration</Text>
101
+ <RolesBlock {...props} styles={styles} />
102
+ </ScrollView>
103
+ );
104
+ }
105
+
106
+ function RetryButton({ label, onPress, styles }: { label: string; onPress?: () => void; styles: TreeStyles }) {
107
+ if (!onPress) return null;
108
+ return (
109
+ <Pressable accessibilityRole="button" accessibilityLabel={label} onPress={onPress} style={styles.button}>
110
+ <Text style={styles.buttonText}>Retry</Text>
111
+ </Pressable>
112
+ );
113
+ }
114
+
115
+ function AgentsBlock({ theme, compact, agents, openAgent, onRetryAgents, styles }: AgentTreeViewProps & { styles: TreeStyles }) {
116
+ if (agents.status === "pending") return <ActivityIndicator color={styles.spinner.color} />;
117
+ if (agents.status === "error") {
118
+ return (
119
+ <View style={{ gap: 6 }}>
120
+ <Text accessibilityLiveRegion="polite" style={[styles.notice, { color: treeToneColor(theme, "danger") }]}>
121
+ {loadErrorText("agents", agents.error)}
122
+ </Text>
123
+ <RetryButton label="Retry loading agents" onPress={onRetryAgents} styles={styles} />
124
+ </View>
125
+ );
126
+ }
127
+
128
+ const sections = agentSections(buildAgentTree(agents.data));
129
+ if (sections.length === 0) return <Text style={styles.body}>{EMPTY_AGENTS_TEXT}</Text>;
130
+
131
+ return (
132
+ <View style={{ gap: compact ? 8 : 12 }}>
133
+ {sections.map((section) => (
134
+ <View key={section.key} style={{ gap: 6 }}>
135
+ <Text style={styles.sectionTitle}>{section.title}</Text>
136
+ {section.rows.map((row) => {
137
+ const content = (
138
+ <>
139
+ <View style={{ flexShrink: 1 }}>
140
+ <Text style={styles.rowTitle}>{row.label}</Text>
141
+ <Text style={styles.rowRole}>{row.roleLabel}</Text>
142
+ </View>
143
+ <Text style={[styles.rowStatus, { color: treeToneColor(theme, row.statusTone) }]}>{row.status}</Text>
144
+ </>
145
+ );
146
+ const indent = { marginLeft: indentFor(row.depth, compact) };
147
+ return openAgent ? (
148
+ <Pressable
149
+ key={row.agentId}
150
+ accessibilityRole="button"
151
+ accessibilityLabel={row.accessibilityLabel}
152
+ onPress={() => openAgent({ agentId: row.agentId })}
153
+ style={({ pressed }) => [styles.row, indent, pressed ? styles.rowPressed : null]}
154
+ >
155
+ {content}
156
+ </Pressable>
157
+ ) : (
158
+ <View key={row.agentId} style={[styles.row, indent]}>
159
+ {content}
160
+ </View>
161
+ );
162
+ })}
163
+ </View>
164
+ ))}
165
+ </View>
166
+ );
167
+ }
168
+
169
+ function RolesBlock({ theme, roles, onRetryRoles, styles }: AgentTreeViewProps & { styles: TreeStyles }) {
170
+ if (roles.status === "pending") return <ActivityIndicator color={styles.spinner.color} />;
171
+ if (roles.status === "error") {
172
+ return (
173
+ <View style={{ gap: 6 }}>
174
+ <Text accessibilityLiveRegion="polite" style={[styles.notice, { color: treeToneColor(theme, "danger") }]}>
175
+ {loadErrorText("role configuration", roles.error)}
176
+ </Text>
177
+ <RetryButton label="Retry loading role configuration" onPress={onRetryRoles} styles={styles} />
178
+ </View>
179
+ );
180
+ }
181
+
182
+ const rows = roleConfigRows(roles.data);
183
+ if (rows.length === 0) {
184
+ return <Text style={[styles.notice, { color: treeToneColor(theme, "warning") }]}>{NO_ROLES_TEXT}</Text>;
185
+ }
186
+
187
+ return (
188
+ <View style={{ gap: 8 }}>
189
+ {rows.map((row) => (
190
+ <View key={row.role} style={styles.card}>
191
+ <Text style={styles.rowTitle}>{row.role}</Text>
192
+ {row.fields.map((field) => (
193
+ <View key={field.label} style={styles.field}>
194
+ <Text style={styles.fieldLabel}>{field.label}</Text>
195
+ <Text selectable style={styles.fieldValue}>
196
+ {field.value}
197
+ </Text>
198
+ </View>
199
+ ))}
200
+ </View>
201
+ ))}
202
+ </View>
203
+ );
204
+ }
package/client/ui.tsx ADDED
@@ -0,0 +1,262 @@
1
+ /**
2
+ * The pieces the Metric and Beads screens share: stat cards, a bar chart, a
3
+ * chip, the agent role mark, and the style of a bead title. Styling comes from
4
+ * `dashboardStyles`, colours from the theme.
5
+ *
6
+ * Client rules: React Native primitives only, no Node import, no `server/`
7
+ * import.
8
+ */
9
+ import type { PluginSurfaceProps } from "@getpaseo/plugin/client";
10
+ import type { ReactNode } from "react";
11
+ import type { BeadRow } from "../shared/contracts";
12
+ import { beadTitleTone } from "./beads-model";
13
+ import { Icon } from "@getpaseo/plugin/client/react-native";
14
+ import { Pressable, Text, View } from "react-native";
15
+ import { ROLE_MARK, barShare, toneColor, type Badge, type Bar, type GraphNode, type Tone, type dashboardStyles } from "./dashboard-model";
16
+
17
+ export type Styles = ReturnType<typeof dashboardStyles>;
18
+ export type Theme = PluginSurfaceProps["theme"];
19
+
20
+ /** Props of the two per-workspace screens, Beads and Metric. */
21
+ export interface WorkspaceScreenProps extends PluginSurfaceProps {
22
+ workspaceId: string;
23
+ /** Omitted inside the workspace's own "Beads" tab: the tab already says where it is. */
24
+ workspaceLabel?: string;
25
+ /** Omitted inside the "Beads" tab, which has no screen to go back to. */
26
+ onBack?: () => void;
27
+ /** What the ← says to a screen reader; the surface names where it leads. */
28
+ backLabel?: string;
29
+ /** The Beads Manager surface's status strip; the "Beads" tab has none. */
30
+ status?: ReactNode;
31
+ }
32
+
33
+ /**
34
+ * The first row of the Beads and Metric screens: an optional ←, the title (or
35
+ * a spacer when the screen sits in its own workspace tab), then the screen's
36
+ * own buttons. The surface's status strip, when given, sits right under it, so
37
+ * a slash command's notice is seen on these screens too. Hook-free, so tests
38
+ * can expand it.
39
+ */
40
+ export function WorkspaceScreenHeader({
41
+ title,
42
+ onBack,
43
+ backLabel,
44
+ right,
45
+ status,
46
+ styles,
47
+ }: {
48
+ title: string | null;
49
+ onBack?: () => void;
50
+ backLabel: string;
51
+ right: ReactNode;
52
+ status?: ReactNode;
53
+ styles: Styles;
54
+ }) {
55
+ return (
56
+ <>
57
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
58
+ {onBack === undefined ? null : (
59
+ <Pressable accessibilityRole="button" accessibilityLabel={backLabel} onPress={onBack} style={styles.secondaryButton}>
60
+ <Text style={styles.secondaryButtonText}>←</Text>
61
+ </Pressable>
62
+ )}
63
+ {title === null ? (
64
+ <View style={{ flex: 1 }} />
65
+ ) : (
66
+ <Text style={[styles.title, { flex: 1 }]} numberOfLines={1}>
67
+ {title}
68
+ </Text>
69
+ )}
70
+ {right}
71
+ </View>
72
+ {status}
73
+ </>
74
+ );
75
+ }
76
+
77
+ export function StatCards({
78
+ cards,
79
+ styles,
80
+ }: {
81
+ cards: ReadonlyArray<{ label: string; value: string; hint: string }>;
82
+ styles: Styles;
83
+ }) {
84
+ return (
85
+ <View style={styles.cards}>
86
+ {cards.map((card) => (
87
+ <View key={card.label} style={styles.statCard}>
88
+ <Text style={styles.body}>{card.label}</Text>
89
+ <Text style={styles.statValue}>{card.value}</Text>
90
+ <Text style={styles.body} numberOfLines={1}>
91
+ {card.hint}
92
+ </Text>
93
+ </View>
94
+ ))}
95
+ </View>
96
+ );
97
+ }
98
+
99
+ /** Horizontal bars; a bar with an `agentId` opens that agent when `onOpen` is given. */
100
+ export function BarChart({
101
+ title,
102
+ bars,
103
+ styles,
104
+ labelWidth = 140,
105
+ onOpen,
106
+ }: {
107
+ title: string;
108
+ bars: Bar[];
109
+ styles: Styles;
110
+ labelWidth?: number;
111
+ onOpen?: (agentId: string) => void;
112
+ }) {
113
+ return (
114
+ <View style={[styles.card, { flexGrow: 1, minWidth: 220 }]}>
115
+ <Text style={styles.sectionTitle}>{title}</Text>
116
+ {bars.length === 0 ? <Text style={styles.body}>Nothing yet.</Text> : null}
117
+ {bars.map((bar) => {
118
+ const row = (
119
+ <View style={{ gap: 2 }}>
120
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
121
+ <Text style={[styles.body, { width: labelWidth }]} numberOfLines={1}>
122
+ {bar.label}
123
+ </Text>
124
+ <View style={styles.barTrack}>
125
+ <View style={[styles.barFill, { width: `${Math.round(barShare(bar, bars) * 100)}%` }]} />
126
+ </View>
127
+ <Text style={[styles.body, { minWidth: 30, textAlign: "right" }]}>{bar.display}</Text>
128
+ </View>
129
+ {bar.hint === undefined ? null : (
130
+ <Text style={styles.body} numberOfLines={1}>
131
+ {bar.hint}
132
+ </Text>
133
+ )}
134
+ </View>
135
+ );
136
+ const agentId = bar.agentId;
137
+ return agentId !== undefined && onOpen !== undefined ? (
138
+ <Pressable key={agentId} accessibilityRole="button" accessibilityLabel={`Open ${bar.label}`} onPress={() => onOpen(agentId)}>
139
+ {row}
140
+ </Pressable>
141
+ ) : (
142
+ <View key={agentId ?? bar.label}>{row}</View>
143
+ );
144
+ })}
145
+ </View>
146
+ );
147
+ }
148
+
149
+ /** A coloured chip; pressable and selectable when `onPress` is given. */
150
+ export function Chip({
151
+ badge,
152
+ selected,
153
+ onPress,
154
+ styles,
155
+ theme,
156
+ }: {
157
+ badge: Badge;
158
+ selected?: boolean;
159
+ onPress?: () => void;
160
+ styles: Styles;
161
+ theme: Theme;
162
+ }) {
163
+ const colour = toneColor(theme, badge.tone);
164
+ const body = (
165
+ <View style={[styles.chip, { borderColor: colour, backgroundColor: selected ? theme.colors.surface2 : "transparent" }]}>
166
+ <Text style={[styles.badge, { color: colour }]}>{`${selected ? "● " : ""}${badge.text}`}</Text>
167
+ </View>
168
+ );
169
+ return onPress === undefined ? (
170
+ body
171
+ ) : (
172
+ <Pressable accessibilityRole="button" accessibilityState={{ selected: selected === true }} onPress={onPress}>
173
+ {body}
174
+ </Pressable>
175
+ );
176
+ }
177
+
178
+ /**
179
+ * A bead title in a list: the section size, not bold, in its status colour
180
+ * (owner decisions Q3 and Q8, delta 20260918e). `null` keeps the plain text
181
+ * colour, for a bead shown outside a list.
182
+ */
183
+ export function beadTitleStyle(styles: Styles, theme: Theme, tone: Tone | null) {
184
+ return [styles.sectionTitle, { fontWeight: "400" as const, color: tone === null ? theme.colors.foreground : toneColor(theme, tone) }];
185
+ }
186
+
187
+ /**
188
+ * One bead row, shared by the Beads screen and the "Beads in this chat" panel:
189
+ * the title coloured by status (not bold) with ▸ / ▾, then what the list shows
190
+ * about the bead (`meta`), and the detail once the row is open. The detail is
191
+ * passed in, so this file never imports the Beads screen. Hook-free.
192
+ */
193
+ export function BeadRowCard({
194
+ bead,
195
+ open,
196
+ onToggle,
197
+ meta,
198
+ detail,
199
+ styles,
200
+ theme,
201
+ }: {
202
+ bead: Pick<BeadRow, "title" | "status" | "ready">;
203
+ open: boolean;
204
+ onToggle: () => void;
205
+ meta: ReactNode;
206
+ detail: ReactNode;
207
+ styles: Styles;
208
+ theme: Theme;
209
+ }) {
210
+ return (
211
+ <View style={styles.card}>
212
+ <Pressable accessibilityRole="button" accessibilityState={{ expanded: open }} onPress={onToggle}>
213
+ {/* The title is what a reader scans for; the id comes second. */}
214
+ <Text style={beadTitleStyle(styles, theme, beadTitleTone(bead))} numberOfLines={open ? undefined : 2}>
215
+ {`${open ? "▾" : "▸"} ${bead.title ?? "(untitled)"}`}
216
+ </Text>
217
+ {meta}
218
+ </Pressable>
219
+ {open ? detail : null}
220
+ </View>
221
+ );
222
+ }
223
+
224
+ /** A small role icon on a soft, round tint of the role's colour. */
225
+ export function RoleMark({ kind, theme, size = 22 }: { kind: GraphNode["kind"]; theme: Theme; size?: number }) {
226
+ const mark = ROLE_MARK[kind];
227
+ const colour = toneColor(theme, mark.tone);
228
+ return (
229
+ <View
230
+ accessibilityLabel={mark.role}
231
+ style={{ width: size, height: size, borderRadius: size / 2, alignItems: "center", justifyContent: "center" }}
232
+ >
233
+ <View
234
+ style={{
235
+ position: "absolute",
236
+ top: 0,
237
+ left: 0,
238
+ right: 0,
239
+ bottom: 0,
240
+ borderRadius: size / 2,
241
+ backgroundColor: colour,
242
+ opacity: 0.16,
243
+ }}
244
+ />
245
+ <Icon name={mark.icon} size={Math.round(size * 0.6)} color={colour} />
246
+ </View>
247
+ );
248
+ }
249
+
250
+ /** One line naming each role next to its mark, so the graph reads without guessing. */
251
+ export function RoleLegend({ styles, theme }: { styles: Styles; theme: Theme }) {
252
+ return (
253
+ <View style={[styles.chipRow, { gap: 12 }]}>
254
+ {(Object.keys(ROLE_MARK) as Array<GraphNode["kind"]>).map((kind) => (
255
+ <View key={kind} style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
256
+ <RoleMark kind={kind} theme={theme} size={18} />
257
+ <Text style={styles.body}>{`${ROLE_MARK[kind].role} · ${ROLE_MARK[kind].does}`}</Text>
258
+ </View>
259
+ ))}
260
+ </View>
261
+ );
262
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Which "questions waiting" pills a Manager's chat shows (delta
3
+ * 20260918d-card-replies §4.8, REQ-059 j): one per Worker whose latest report
4
+ * asks the user something, as `chat.waiting` answers. And one per Manager
5
+ * whose chat has fallback incidents waiting for the user's decision, counting
6
+ * them (delta 20260921 §4.4.6, REQ-065 c).
7
+ *
8
+ * Pure: no React, no React Native, no `server/` import.
9
+ */
10
+ import type { FallbackIncident, WaitingFallback, WaitingWorker } from "../shared/contracts";
11
+ import { partyName, toChatCard } from "./chat-cards";
12
+
13
+ /** How often the pills are read again. The pill of an answered Worker goes within one period. */
14
+ export const WAITING_POLL_MS = 15_000;
15
+
16
+ /** A Lucide icon name, as the host's other buttons use. */
17
+ export const WAITING_PILL_ICON = "MessageCircleQuestion";
18
+
19
+ /** The fallback pill's Lucide icon. */
20
+ export const FALLBACK_PILL_ICON = "TriangleAlert";
21
+
22
+ /** The composer pill of one waiting Worker. */
23
+ export interface WaitingPill {
24
+ kind: "questions";
25
+ id: string;
26
+ /** Changes whenever the pill must be redrawn. */
27
+ key: string;
28
+ label: string;
29
+ title: string;
30
+ entry: WaitingWorker;
31
+ }
32
+
33
+ /** The composer pill of one Manager's pending fallback incidents. */
34
+ export interface FallbackPill {
35
+ kind: "fallback";
36
+ id: string;
37
+ /** Changes whenever the pill must be redrawn. */
38
+ key: string;
39
+ label: string;
40
+ title: string;
41
+ managerId: string;
42
+ workspaceId: string;
43
+ /** Oldest first; never empty. */
44
+ incidents: FallbackIncident[];
45
+ }
46
+
47
+ export type ComposerPill = WaitingPill | FallbackPill;
48
+
49
+ /**
50
+ * FNV-1a 32-bit over the UTF-16 code units of `text`, as 8 hex digits. Small
51
+ * and dependency-free; it only has to notice that a report's text changed.
52
+ */
53
+ export function fnv1a32Hex(text: string): string {
54
+ let hash = 0x811c9dc5;
55
+ for (let index = 0; index < text.length; index += 1) {
56
+ hash ^= text.charCodeAt(index);
57
+ hash = Math.imul(hash, 0x01000193) >>> 0;
58
+ }
59
+ return hash.toString(16).padStart(8, "0");
60
+ }
61
+
62
+ export function pillIdOf(workerId: string): string {
63
+ return `bm-waiting-${workerId}`;
64
+ }
65
+
66
+ export function fallbackPillIdOf(managerId: string): string {
67
+ return `bm-fallback-${managerId}`;
68
+ }
69
+
70
+ /** The report's questions, as the card reads them; 0 when the text has none. */
71
+ export function questionCountOf(entry: WaitingWorker): number {
72
+ return toChatCard({ type: "user_message", text: entry.text }, "complete")?.questions.length ?? 0;
73
+ }
74
+
75
+ export function pillOf(entry: WaitingWorker): WaitingPill | null {
76
+ const count = questionCountOf(entry);
77
+ if (count === 0) return null;
78
+ const name = partyName({ role: "worker", id: entry.workerId, title: entry.workerTitle });
79
+ const label = `${name} · ${count} ${count === 1 ? "question" : "questions"}`;
80
+ return {
81
+ kind: "questions",
82
+ id: pillIdOf(entry.workerId),
83
+ // The text's hash too: without a timestamp, a new report with the same
84
+ // number of questions would otherwise never reach the popover (delta
85
+ // 20260918f F14).
86
+ key: [entry.managerId, entry.workspaceId, entry.requestId, label, entry.at ?? "", fnv1a32Hex(entry.text)].join("|"),
87
+ label,
88
+ title: `Questions from ${name} about ${entry.requestId}`,
89
+ entry,
90
+ };
91
+ }
92
+
93
+ /**
94
+ * One pill per Manager, counting the `pending` incidents whose `managerId` is
95
+ * that Manager. `chat.waiting` already sends only those; the test is repeated
96
+ * here so a pill never counts anything else.
97
+ */
98
+ export function fallbackPillsOf(fallback: readonly WaitingFallback[]): FallbackPill[] {
99
+ const byManager = new Map<string, { workspaceId: string; incidents: FallbackIncident[] }>();
100
+ for (const entry of fallback) {
101
+ if (entry.incident.status !== "pending" || entry.incident.managerId !== entry.managerId) continue;
102
+ const group = byManager.get(entry.managerId) ?? { workspaceId: entry.workspaceId, incidents: [] };
103
+ if (!group.incidents.some((incident) => incident.id === entry.incident.id)) group.incidents.push(entry.incident);
104
+ byManager.set(entry.managerId, group);
105
+ }
106
+ return [...byManager].map(([managerId, { workspaceId, incidents }]) => {
107
+ const count = incidents.length;
108
+ const label = `Fallback · ${count} ${count === 1 ? "decision" : "decisions"}`;
109
+ return {
110
+ kind: "fallback",
111
+ id: fallbackPillIdOf(managerId),
112
+ key: [managerId, workspaceId, label, incidents.map((incident) => incident.id).join(",")].join("|"),
113
+ label,
114
+ title:
115
+ count === 1
116
+ ? "An agent stopped by its provider plan waits for your decision"
117
+ : `${count} agents stopped by their provider plan wait for your decision`,
118
+ managerId,
119
+ workspaceId,
120
+ incidents,
121
+ };
122
+ });
123
+ }
124
+
125
+ /** The chat a pill belongs to. */
126
+ export function pillChatOf(pill: ComposerPill): { managerId: string; workspaceId: string } {
127
+ return pill.kind === "questions"
128
+ ? { managerId: pill.entry.managerId, workspaceId: pill.entry.workspaceId }
129
+ : { managerId: pill.managerId, workspaceId: pill.workspaceId };
130
+ }
131
+
132
+ /**
133
+ * What to add, redraw and remove, given the pills shown now (`id → key`) and
134
+ * the latest `chat.waiting` answer. A pill whose key did not change is left alone.
135
+ */
136
+ export function planPills(
137
+ current: ReadonlyMap<string, string>,
138
+ waiting: readonly WaitingWorker[],
139
+ fallback: readonly WaitingFallback[] = [],
140
+ ): { add: ComposerPill[]; update: ComposerPill[]; remove: string[] } {
141
+ const next = new Map<string, ComposerPill>();
142
+ for (const entry of waiting) {
143
+ const pill = pillOf(entry);
144
+ if (pill !== null && !next.has(pill.id)) next.set(pill.id, pill);
145
+ }
146
+ for (const pill of fallbackPillsOf(fallback)) next.set(pill.id, pill);
147
+ const add: ComposerPill[] = [];
148
+ const update: ComposerPill[] = [];
149
+ for (const pill of next.values()) {
150
+ const shown = current.get(pill.id);
151
+ if (shown === undefined) add.push(pill);
152
+ else if (shown !== pill.key) update.push(pill);
153
+ }
154
+ const remove = [...current.keys()].filter((id) => !next.has(id));
155
+ return { add, update, remove };
156
+ }