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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 hieu.nt10 (hieunt286)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,53 @@
1
+ # paseo-bm-plugin — the paseo-bm payload
2
+
3
+ This package is the **plugin payload** of [paseo-bm](https://github.com/hieunt286/paseo-bm): the
4
+ screens it contributes to [Paseo](https://paseo.sh) and the instructions its three agent roles run
5
+ on. Loading it gives you **Metric** (what each request did and cost), **Beads** (the workspace's
6
+ beads) and **Setup** (tools, skills and role instructions).
7
+
8
+ ![The Beads screen: totals, progress, filters and the bead list](images/01-beads-screen.jpg)
9
+
10
+ ## Install paseo-bm with `npx paseo-bm`
11
+
12
+ ```bash
13
+ npx paseo-bm
14
+ ```
15
+
16
+ That is the supported install, and it is what you want. The installer copies this payload to a
17
+ stable path, registers it with the Paseo daemon, **registers the three agent roles** `bm-manager`,
18
+ `bm-worker` and `bm-reviewer`, and asks for the consent those roles need.
19
+
20
+ ## Limitations of installing this package directly
21
+
22
+ You can point Paseo at this payload on its own:
23
+
24
+ ```bash
25
+ # Paseo 0.9 and newer — npm source
26
+ paseo plugin add npm:paseo-bm-plugin@<version>
27
+
28
+ # Paseo 0.8 — Git source; 0.8 has no npm source type
29
+ paseo plugin add hieunt286/paseo-bm --ref <commit> --path plugin
30
+ ```
31
+
32
+ Either way the plugin **loads and the screens appear, but the three agent roles are missing**,
33
+ because the installer registers them, not the payload. Without those roles the Beads Manager cannot
34
+ create a Worker, so the product does not work end to end. Use `npx paseo-bm` instead.
35
+
36
+ Everything else worth knowing before you install — what paseo-bm writes on your machine, that Paseo
37
+ plugins run without a sandbox, and that the Worker runs without permission prompts — is in
38
+ [GUIDE.md](https://github.com/hieunt286/paseo-bm/blob/main/GUIDE.md#before-you-install-read-these-warnings).
39
+
40
+ ## Versions
41
+
42
+ This package always carries the same version as the `paseo-bm` CLI, published from the same commit
43
+ in the same release.
44
+
45
+ ## Documentation
46
+
47
+ - [Repository and README](https://github.com/hieunt286/paseo-bm#readme)
48
+ - [GUIDE.md](https://github.com/hieunt286/paseo-bm/blob/main/GUIDE.md) — requirements, install options, the screens, commands, exit codes, troubleshooting
49
+ - [Guided tour](https://paseo-bm.erai.pro)
50
+
51
+ ## License
52
+
53
+ MIT, see [LICENSE](LICENSE).
@@ -0,0 +1,308 @@
1
+ /**
2
+ * Logic behind the "Beads agents" workspace panel (WP-113, design §2.2, §5, §8,
3
+ * REQ-025a, REQ-032d). Pure: no React, no React Native, no JSX, so it is
4
+ * testable without a renderer. `tree.tsx` renders from it.
5
+ *
6
+ * Paseo 0.8 facts this is built on (checked against @getpaseo/plugin 0.8.0
7
+ * `dist/client/contracts.d.ts`, not guessed):
8
+ * - `addWorkspacePanel({ id, title, icon, context: "workspace", Component })`;
9
+ * the component receives `workspaceId` directly (unlike a surface), plus
10
+ * `theme`, `layout` and the optional `navigation.openAgent`.
11
+ *
12
+ * Read-only by design (ADR-005): the panel lists and opens agents. It offers no
13
+ * way to stop, archive or delete one.
14
+ */
15
+ import type { PluginTheme } from "@getpaseo/plugin";
16
+ import type { AgentNode, RoleDescriptor } from "../shared/contracts";
17
+ import { errorCodeOf, errorMessageOf, type NoticeTone } from "./launch-manager";
18
+
19
+ /** Workspace panel id. */
20
+ export const AGENT_TREE_PANEL_ID = "beads-agents";
21
+
22
+ /** Lucide icon name (lucide.dev/icons/list-tree). */
23
+ export const AGENT_TREE_ICON = "ListTree";
24
+
25
+ /**
26
+ * How often the panel re-reads `agents.list`. The design says the tree is
27
+ * polled (§2.4) but fixes no interval; this value is a placeholder pending an
28
+ * owner decision (see the WP-113 panel report).
29
+ */
30
+ export const AGENT_TREE_POLL_MS = 5000;
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Tree building.
34
+ // ---------------------------------------------------------------------------
35
+
36
+ export interface AgentTreeNode {
37
+ agent: AgentNode;
38
+ children: AgentTreeNode[];
39
+ }
40
+
41
+ export interface AgentTree {
42
+ /** Roots whose role is `manager`, each with its descendants. */
43
+ managers: AgentTreeNode[];
44
+ /**
45
+ * Every other root: an orphaned Worker or Reviewer (its Manager deleted or
46
+ * archived) or an unlabeled root agent. Shown in a separate branch, never hidden.
47
+ */
48
+ withoutManager: AgentTreeNode[];
49
+ }
50
+
51
+ /**
52
+ * Builds the tree from the flat `agents.list` output, keeping its order.
53
+ *
54
+ * Defensive beyond the contract: a `parentId` that names no listed agent, or
55
+ * that would close a cycle, makes the agent a root instead of dropping it, and
56
+ * a duplicated id is shown once.
57
+ */
58
+ export function buildAgentTree(agents: readonly AgentNode[]): AgentTree {
59
+ const byId = new Map<string, AgentNode>();
60
+ for (const agent of agents) if (!byId.has(agent.id)) byId.set(agent.id, agent);
61
+
62
+ // An agent is a root when its parent chain does not reach a listed agent
63
+ // without looping back to itself.
64
+ const parentOf = (agent: AgentNode): string | null => {
65
+ const parentId = agent.parentId;
66
+ if (parentId === null || parentId === agent.id || !byId.has(parentId)) return null;
67
+ const seen = new Set<string>([agent.id]);
68
+ for (let cursor: string | null = parentId; cursor !== null; ) {
69
+ if (seen.has(cursor)) return null;
70
+ seen.add(cursor);
71
+ const next: AgentNode | undefined = byId.get(cursor);
72
+ cursor = next && next.parentId !== null && byId.has(next.parentId) ? next.parentId : null;
73
+ }
74
+ return parentId;
75
+ };
76
+
77
+ const childrenOf = new Map<string, AgentNode[]>();
78
+ const roots: AgentNode[] = [];
79
+ for (const agent of byId.values()) {
80
+ const parentId = parentOf(agent);
81
+ if (parentId === null) roots.push(agent);
82
+ else childrenOf.set(parentId, [...(childrenOf.get(parentId) ?? []), agent]);
83
+ }
84
+
85
+ const toNode = (agent: AgentNode): AgentTreeNode => ({
86
+ agent,
87
+ children: (childrenOf.get(agent.id) ?? []).map(toNode),
88
+ });
89
+
90
+ return {
91
+ managers: roots.filter((agent) => agent.role === "manager").map(toNode),
92
+ withoutManager: roots.filter((agent) => agent.role !== "manager").map(toNode),
93
+ };
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Rows the panel renders.
98
+ // ---------------------------------------------------------------------------
99
+
100
+ export interface AgentRow {
101
+ agentId: string;
102
+ depth: number;
103
+ roleLabel: string;
104
+ /** Title, or a placeholder when the agent has none. */
105
+ label: string;
106
+ status: string;
107
+ statusTone: StatusTone;
108
+ /** Screen-reader text for the row's open action. */
109
+ accessibilityLabel: string;
110
+ }
111
+
112
+ export interface AgentSection {
113
+ key: "managers" | "without-manager";
114
+ title: string;
115
+ rows: AgentRow[];
116
+ }
117
+
118
+ export type StatusTone = "success" | "accent" | "muted" | "warning" | "danger";
119
+
120
+ const ROLE_LABELS: Record<AgentNode["role"], string> = {
121
+ manager: "Manager",
122
+ worker: "Worker",
123
+ reviewer: "Reviewer",
124
+ unknown: "Unknown role",
125
+ };
126
+
127
+ export function roleLabel(role: string): string {
128
+ return role in ROLE_LABELS ? ROLE_LABELS[role as AgentNode["role"]] : ROLE_LABELS.unknown;
129
+ }
130
+
131
+ /** Tone for a Paseo agent status (`initializing | idle | running | error | closed`). */
132
+ export function statusTone(status: string): StatusTone {
133
+ switch (status) {
134
+ case "running":
135
+ return "accent";
136
+ case "idle":
137
+ return "success";
138
+ case "initializing":
139
+ return "warning";
140
+ case "error":
141
+ return "danger";
142
+ default:
143
+ return "muted";
144
+ }
145
+ }
146
+
147
+ function agentLabel(agent: AgentNode): string {
148
+ const title = agent.title?.trim();
149
+ return title ? title : `Untitled agent (${agent.id})`;
150
+ }
151
+
152
+ /**
153
+ * The role text of a row: `Manager`, or `Manager · no label` for a paseo-bm
154
+ * agent with no valid `bm.role` label — started outside Beads Manager and
155
+ * recognised by its provider (delta 20260918g §4.4). An `Unknown role` row
156
+ * already says as much, so it gets no mark. An agent a fallback agent took
157
+ * over from reads `· replaced by <id>`, whatever its role (delta 20260921 §4.4.8).
158
+ */
159
+ export function rowRoleLabel(agent: Pick<AgentNode, "role" | "labelled"> & { replacedBy?: string | null }): string {
160
+ const role = roleLabel(agent.role);
161
+ const label = agent.labelled === false && agent.role !== "unknown" ? `${role} · no label` : role;
162
+ return agent.replacedBy ? `${label} · replaced by ${agent.replacedBy}` : label;
163
+ }
164
+
165
+ function flatten(nodes: readonly AgentTreeNode[], depth: number, out: AgentRow[]): AgentRow[] {
166
+ for (const { agent, children } of nodes) {
167
+ const label = agentLabel(agent);
168
+ const role = rowRoleLabel(agent);
169
+ out.push({
170
+ agentId: agent.id,
171
+ depth,
172
+ roleLabel: role,
173
+ label,
174
+ status: agent.status,
175
+ statusTone: statusTone(agent.status),
176
+ accessibilityLabel: `Open ${role} ${label}, status ${agent.status}`,
177
+ });
178
+ flatten(children, depth + 1, out);
179
+ }
180
+ return out;
181
+ }
182
+
183
+ /** Sections in display order; a section with no rows is omitted. */
184
+ export function agentSections(tree: AgentTree): AgentSection[] {
185
+ const sections: AgentSection[] = [
186
+ { key: "managers", title: "Managers", rows: flatten(tree.managers, 0, []) },
187
+ { key: "without-manager", title: "Without a Manager", rows: flatten(tree.withoutManager, 0, []) },
188
+ ];
189
+ return sections.filter((section) => section.rows.length > 0);
190
+ }
191
+
192
+ export const EMPTY_AGENTS_TEXT =
193
+ "No Beads agents in this workspace yet. Open Beads Manager to start one.";
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // Role configuration (roles.describe).
197
+ // ---------------------------------------------------------------------------
198
+
199
+ export interface RoleConfigRow {
200
+ role: string;
201
+ fields: Array<{ label: string; value: string }>;
202
+ }
203
+
204
+ const ROLE_ORDER: Record<string, number> = { manager: 0, worker: 1, reviewer: 2 };
205
+
206
+ export function roleConfigRows(roles: readonly RoleDescriptor[]): RoleConfigRow[] {
207
+ return [...roles]
208
+ .sort((a, b) => (ROLE_ORDER[a.role] ?? 9) - (ROLE_ORDER[b.role] ?? 9))
209
+ .map((descriptor) => ({
210
+ role: roleLabel(descriptor.role),
211
+ fields: [
212
+ { label: "Provider", value: descriptor.provider || "(not set)" },
213
+ { label: "Model", value: descriptor.model || "(provider default)" },
214
+ { label: "Paseo tools", value: descriptor.paseoTools ? "Granted" : "Not granted" },
215
+ { label: "Instructions", value: descriptor.instructionsPath },
216
+ ],
217
+ }));
218
+ }
219
+
220
+ export const NO_ROLES_TEXT =
221
+ "No role configuration found: paseo-bm is not installed on this host, or its roles are not registered with Paseo. Run `npx paseo-bm doctor`.";
222
+
223
+ // ---------------------------------------------------------------------------
224
+ // Errors.
225
+ // ---------------------------------------------------------------------------
226
+
227
+ /** Error line for a failed RPC, with its registry code when the server sent one. */
228
+ export function loadErrorText(what: string, error: unknown): string {
229
+ const code = errorCodeOf(error);
230
+ const message = errorMessageOf(error);
231
+ return code ? `Could not load ${what} (${code}). ${message}` : `Could not load ${what}. ${message}`;
232
+ }
233
+
234
+ // ---------------------------------------------------------------------------
235
+ // Presentation: styles from theme.colors only.
236
+ // ---------------------------------------------------------------------------
237
+
238
+ export function treeToneColor(theme: PluginTheme, tone: StatusTone | NoticeTone): string {
239
+ switch (tone) {
240
+ case "success":
241
+ return theme.colors.statusSuccess;
242
+ case "accent":
243
+ return theme.colors.accent;
244
+ case "muted":
245
+ return theme.colors.foregroundMuted;
246
+ case "warning":
247
+ return theme.colors.statusWarning;
248
+ case "danger":
249
+ return theme.colors.statusDanger;
250
+ }
251
+ }
252
+
253
+ /** Horizontal indent of a tree row per depth level. */
254
+ export function indentFor(depth: number, compact: boolean): number {
255
+ return depth * (compact ? 12 : 20);
256
+ }
257
+
258
+ export function treeStyles(theme: PluginTheme, compact: boolean) {
259
+ return {
260
+ screen: { flex: 1, backgroundColor: theme.colors.surface0 },
261
+ content: { padding: compact ? 12 : 20, gap: compact ? 8 : 12 },
262
+ heading: { color: theme.colors.foreground, fontSize: compact ? 16 : 18, fontWeight: "600" as const },
263
+ sectionTitle: { color: theme.colors.foregroundMuted, fontSize: 12, fontWeight: "600" as const },
264
+ body: { color: theme.colors.foregroundMuted, fontSize: 13 },
265
+ notice: { fontSize: 13 },
266
+ row: {
267
+ flexDirection: compact ? ("column" as const) : ("row" as const),
268
+ alignItems: compact ? ("flex-start" as const) : ("center" as const),
269
+ justifyContent: "space-between" as const,
270
+ gap: compact ? 2 : 12,
271
+ paddingVertical: compact ? 8 : 10,
272
+ paddingHorizontal: compact ? 10 : 12,
273
+ borderRadius: 8,
274
+ borderWidth: 1,
275
+ borderColor: theme.colors.border,
276
+ backgroundColor: theme.colors.surface1,
277
+ },
278
+ rowPressed: { backgroundColor: theme.colors.surface2 },
279
+ rowTitle: { color: theme.colors.foreground, fontSize: 14 },
280
+ rowRole: { color: theme.colors.foregroundMuted, fontSize: 12 },
281
+ rowStatus: { fontSize: 12 },
282
+ card: {
283
+ gap: 4,
284
+ padding: compact ? 10 : 12,
285
+ borderRadius: 8,
286
+ borderWidth: 1,
287
+ borderColor: theme.colors.border,
288
+ backgroundColor: theme.colors.surface1,
289
+ },
290
+ field: {
291
+ flexDirection: compact ? ("column" as const) : ("row" as const),
292
+ gap: compact ? 0 : 8,
293
+ },
294
+ fieldLabel: { color: theme.colors.foregroundMuted, fontSize: 12, minWidth: compact ? 0 : 96 },
295
+ fieldValue: { color: theme.colors.foreground, fontSize: 12, flexShrink: 1 },
296
+ button: {
297
+ alignSelf: "flex-start" as const,
298
+ paddingVertical: 6,
299
+ paddingHorizontal: 12,
300
+ borderRadius: 6,
301
+ backgroundColor: theme.colors.accent,
302
+ },
303
+ buttonText: { color: theme.colors.accentForeground, fontSize: 13 },
304
+ spinner: { color: theme.colors.foregroundMuted },
305
+ };
306
+ }
307
+
308
+ export type TreeStyles = ReturnType<typeof treeStyles>;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * What this app session knows about answers sent from chat cards (delta
3
+ * 20260918d-card-replies §4.9, REQ-059 k).
4
+ *
5
+ * A report can be drawn twice at once — in the chat and in a waiting pill's
6
+ * popover — and the chat list may unmount a card while scrolling, so this
7
+ * cannot live in a card's own state. Every copy subscribes here and redraws
8
+ * when any copy writes. Nothing is kept across a reload: marks that must
9
+ * survive one go to the install home (`answers.mark`).
10
+ *
11
+ * No React here: `chat-card.tsx` reads it through `useSyncExternalStore`.
12
+ */
13
+
14
+ /** Answers sent from a card: when, the short summary, and to whom. */
15
+ export interface AnsweredRecord {
16
+ at: Date;
17
+ summary: string;
18
+ to: string;
19
+ }
20
+
21
+ const answered = new Map<string, AnsweredRecord>();
22
+ const replied = new Map<string, Date>();
23
+ const listeners = new Set<() => void>();
24
+ let version = 0;
25
+
26
+ function notify(): void {
27
+ version += 1;
28
+ for (const listener of [...listeners]) listener();
29
+ }
30
+
31
+ /** The answers sent from the card with this `answeredKey`, or null. */
32
+ export function answeredRecord(key: string): AnsweredRecord | null {
33
+ return answered.get(key) ?? null;
34
+ }
35
+
36
+ export function setAnswered(key: string, record: AnsweredRecord): void {
37
+ answered.set(key, record);
38
+ notify();
39
+ }
40
+
41
+ /** When a reply last went out from the card with this `answeredKey`, or null. */
42
+ export function repliedAt(key: string): Date | null {
43
+ return replied.get(key) ?? null;
44
+ }
45
+
46
+ export function setReplied(key: string, at: Date): void {
47
+ replied.set(key, at);
48
+ notify();
49
+ }
50
+
51
+ /** Calls `listener` after every write; returns the unsubscribe. */
52
+ export function subscribeAnswers(listener: () => void): () => void {
53
+ listeners.add(listener);
54
+ return () => {
55
+ listeners.delete(listener);
56
+ };
57
+ }
58
+
59
+ /** Changes on every write: the snapshot `useSyncExternalStore` compares. */
60
+ export function answersVersion(): number {
61
+ return version;
62
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Beads named in a chat, as chips that open the bead in place
3
+ * (owner request 2026-09-16: "click vào mã beads ... xem đc nội dung").
4
+ *
5
+ * `BeadChips` sits inside a chat card; `ChatBeadsPanel` is the agent panel
6
+ * that lists every bead the chat named recently, for messages Paseo shows as
7
+ * plain text. Client rules: React Native primitives only, colours from the
8
+ * theme.
9
+ */
10
+ import { type PluginAgentPanelProps, useRpc } from "@getpaseo/plugin/client";
11
+ import { useQuery } from "@tanstack/react-query";
12
+ import { useMemo, useState } from "react";
13
+ import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
14
+ import { beadsLookupRpc, chatBeadsRpc, type BeadRow } from "../shared/contracts";
15
+ import { BeadDetailPanel } from "./beads-screen";
16
+ import { beadChipText, formatClock, statusBadge } from "./beads-model";
17
+ import { beadChipsView } from "./chat-cards";
18
+ import { dashboardStyles, toneColor } from "./dashboard-model";
19
+ import { errorMessageOf } from "./launch-manager";
20
+ import { BeadRowCard, Chip, beadTitleStyle, type Styles, type Theme } from "./ui";
21
+
22
+ /**
23
+ * Chips for the ids a message names; only ids the bead store has are shown.
24
+ * All candidates are looked up first; then two chips, and a "…" chip for the
25
+ * rest found (delta 20260918f F10).
26
+ */
27
+ export function BeadChips({
28
+ workspaceId,
29
+ ids,
30
+ expanded,
31
+ onExpand,
32
+ styles,
33
+ theme,
34
+ }: {
35
+ workspaceId: string;
36
+ ids: readonly string[];
37
+ expanded: boolean;
38
+ onExpand: () => void;
39
+ styles: Styles;
40
+ theme: Theme;
41
+ }) {
42
+ const lookup = useRpc(beadsLookupRpc);
43
+ const found = useQuery({
44
+ queryKey: ["paseo-bm", "bead-lookup", workspaceId, ids.join(" ")],
45
+ queryFn: () => lookup({ workspaceId, ids: ids.slice(0, 100) }),
46
+ enabled: ids.length > 0,
47
+ staleTime: 30_000,
48
+ });
49
+ const [openId, setOpenId] = useState<string | null>(null);
50
+ const beads = found.data?.beads ?? [];
51
+ const view = beadChipsView(beads, expanded);
52
+ if (view.shown.length === 0) return null;
53
+ const open = beads.find((bead) => bead.id === openId);
54
+ return (
55
+ <View style={{ gap: 6 }}>
56
+ <View style={styles.chipRow}>
57
+ {view.shown.map((bead) => (
58
+ <Chip
59
+ key={bead.id}
60
+ badge={{ text: beadChipText(bead), tone: statusBadge(bead).tone }}
61
+ selected={bead.id === openId}
62
+ onPress={() => setOpenId(bead.id === openId ? null : bead.id)}
63
+ styles={styles}
64
+ theme={theme}
65
+ />
66
+ ))}
67
+ </View>
68
+ {open === undefined ? null : <BeadInline workspaceId={workspaceId} bead={open} styles={styles} theme={theme} />}
69
+ {/* Two chips, then "…" on the next line for the rest (delta 20260918d §4.7). */}
70
+ {view.hidden === 0 ? null : (
71
+ <View style={styles.chipRow}>
72
+ <Pressable accessibilityRole="button" accessibilityLabel={`Show all ${beads.length} beads`} onPress={onExpand}>
73
+ <Chip badge={{ text: "…", tone: "muted" }} styles={styles} theme={theme} />
74
+ </Pressable>
75
+ </View>
76
+ )}
77
+ </View>
78
+ );
79
+ }
80
+
81
+ function BeadInline({ workspaceId, bead, styles, theme }: { workspaceId: string; bead: BeadRow; styles: Styles; theme: Theme }) {
82
+ return (
83
+ <View style={[styles.card, { backgroundColor: theme.colors.surface0, gap: 2 }]}>
84
+ <Text style={beadTitleStyle(styles, theme, null)}>{bead.title ?? "(untitled)"}</Text>
85
+ <Text style={[styles.body, { fontSize: 12 }]} selectable>
86
+ {`${bead.id} · ${statusBadge(bead).text}`}
87
+ </Text>
88
+ <BeadDetailPanel workspaceId={workspaceId} bead={bead} styles={styles} theme={theme} />
89
+ </View>
90
+ );
91
+ }
92
+
93
+ /** Agent panel: the beads this chat named recently, newest mention first. */
94
+ export function ChatBeadsPanel({ theme, layout, workspaceId, agentId }: PluginAgentPanelProps) {
95
+ const styles = useMemo(() => dashboardStyles(theme, layout.compact), [theme, layout.compact]);
96
+ const list = useRpc(chatBeadsRpc);
97
+ const beads = useQuery({
98
+ queryKey: ["paseo-bm", "chat-beads", workspaceId, agentId],
99
+ queryFn: () => list({ workspaceId, agentId }),
100
+ refetchInterval: 15_000,
101
+ });
102
+ const [openId, setOpenId] = useState<string | null>(null);
103
+ const rows = beads.data?.beads ?? [];
104
+ return (
105
+ <ScrollView style={styles.screen} contentContainerStyle={styles.content}>
106
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
107
+ <Text style={[styles.sectionTitle, { flex: 1 }]}>Beads in this chat</Text>
108
+ <Pressable accessibilityRole="button" onPress={() => void beads.refetch()} style={styles.secondaryButton}>
109
+ <Text style={styles.secondaryButtonText}>Refresh</Text>
110
+ </Pressable>
111
+ </View>
112
+ {beads.isPending ? <ActivityIndicator color={styles.spinner.color} /> : null}
113
+ {beads.isError ? <Text style={[styles.body, { color: toneColor(theme, "danger") }]}>{errorMessageOf(beads.error)}</Text> : null}
114
+ {beads.data !== undefined && rows.length === 0 ? (
115
+ <Text style={styles.body}>No bead of this workspace is named in the recent messages of this chat.</Text>
116
+ ) : null}
117
+ {rows.map(({ bead, mentions, lastMentionedAt }) => {
118
+ const open = bead.id === openId;
119
+ return (
120
+ <BeadRowCard
121
+ key={bead.id}
122
+ bead={bead}
123
+ open={open}
124
+ onToggle={() => setOpenId(open ? null : bead.id)}
125
+ styles={styles}
126
+ theme={theme}
127
+ meta={
128
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8, flexWrap: "wrap", marginTop: 2 }}>
129
+ <Text style={[styles.body, { fontSize: 12 }]} selectable>
130
+ {bead.id}
131
+ </Text>
132
+ <Text style={[styles.body, { fontSize: 12, flex: 1 }]}>
133
+ {`${mentions}× in chat${lastMentionedAt === null ? "" : ` · last ${formatClock(lastMentionedAt)}`}`}
134
+ </Text>
135
+ <Chip badge={statusBadge(bead)} styles={styles} theme={theme} />
136
+ </View>
137
+ }
138
+ detail={<BeadDetailPanel workspaceId={workspaceId} bead={bead} styles={styles} theme={theme} />}
139
+ />
140
+ );
141
+ })}
142
+ {beads.data === undefined ? null : (
143
+ <Text style={[styles.body, { fontSize: 11 }]}>{`Read from the last ${beads.data.scannedItems} items of this chat.`}</Text>
144
+ )}
145
+ </ScrollView>
146
+ );
147
+ }