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
@@ -0,0 +1,636 @@
1
+ /**
2
+ * One chat card: a message between the Manager, a Worker and a Reviewer
3
+ * (delta 20260916-chat-cards). Header with the sender's role icon and
4
+ * session name, the time under it, the recipient, the request and its status;
5
+ * the full message as Markdown on demand; and a reply that goes straight to
6
+ * the other agent.
7
+ *
8
+ * A received report that carries a `BM-QUESTIONS` block also shows its
9
+ * questions with option buttons (delta 20260918c-question-cards §4.4). A pick
10
+ * writes the answers into the Reply box, whose one Send goes to the asking
11
+ * Worker; every Reply re-reads its recipient's status first (delta
12
+ * 20260918d-card-replies §4.1–§4.3).
13
+ *
14
+ * The plugin's `BM-FALLBACK` notice is a card of its own (delta 20260921
15
+ * §4.4.6): its status and buttons come from `fallback.incidents`, never from
16
+ * the notice's text, and each button is one `fallback.act` call.
17
+ *
18
+ * All wording and decisions live in `chat-cards.ts`, tested without a
19
+ * renderer. Client rules: React Native primitives only, colours from the theme.
20
+ */
21
+ import { type PluginTimelineItemProps, usePaseo, useRpc } from "@getpaseo/plugin/client";
22
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
23
+ import { useMemo, useState, useSyncExternalStore } from "react";
24
+ import { Pressable, Text, TextInput, View } from "react-native";
25
+ import {
26
+ answersMarkRpc,
27
+ answersMarksRpc,
28
+ chatPeersRpc,
29
+ chatWaitingRpc,
30
+ fallbackActRpc,
31
+ fallbackIncidentsRpc,
32
+ rolesOptionsRpc,
33
+ type FallbackIncident,
34
+ } from "../shared/contracts";
35
+ import { errorMessageOf } from "./launch-manager";
36
+ import { answeredRecord, answersVersion, repliedAt, setAnswered, setReplied, subscribeAnswers } from "./answer-state";
37
+ import { WAITING_POLL_MS } from "./waiting-pills-model";
38
+ import type { Question } from "../shared/bm-questions";
39
+ import {
40
+ answeredHow,
41
+ answeredKey,
42
+ answersDraft,
43
+ candidateCost,
44
+ choosable,
45
+ drawAsCard,
46
+ fallbackCardView,
47
+ fallbackMarkdown,
48
+ listedCostProvider,
49
+ lookupOf,
50
+ markOf,
51
+ markdownOf,
52
+ ownerWarning,
53
+ outlineTone,
54
+ partiesOf,
55
+ partyName,
56
+ quickReplies,
57
+ recommendedPicks,
58
+ replyControls,
59
+ replyTarget,
60
+ roleName,
61
+ runFallbackAction,
62
+ sendReply,
63
+ sentSummary,
64
+ showsQuestions,
65
+ startsOpen,
66
+ stillWaiting,
67
+ statusChip,
68
+ summaryOf,
69
+ questionHeading,
70
+ withAnswersBlock,
71
+ type ChatCard,
72
+ type FallbackAction,
73
+ type FallbackLookup,
74
+ type Picks,
75
+ } from "./chat-cards";
76
+ import { dashboardStyles, toneColor } from "./dashboard-model";
77
+ import { MarkdownView } from "./markdown-view";
78
+ import { Chip, RoleMark } from "./ui";
79
+ import { BeadChips } from "./bead-chips";
80
+ import { beadIdCandidates } from "../shared/bead-ids";
81
+
82
+ /** Peers change rarely; one lookup per chat is plenty. */
83
+ const PEERS_STALE_MS = 30_000;
84
+
85
+ /** Shared by every card, so a mark shows on all copies at once. */
86
+ const ANSWER_MARKS_QUERY = ["paseo-bm", "answer-marks"] as const;
87
+
88
+ function clock(date: Date): string {
89
+ const two = (value: number) => String(value).padStart(2, "0");
90
+ return Number.isNaN(date.getTime()) ? "" : `${two(date.getHours())}:${two(date.getMinutes())}`;
91
+ }
92
+
93
+ /** A listed price changes rarely; the Switch button reads it once in a while. */
94
+ const COST_STALE_MS = 5 * 60_000;
95
+
96
+ /** One incident's state, shared by every copy of its card (the chat's, a pill's popover). */
97
+ const fallbackIncidentKey = (incidentId: string) => ["paseo-bm", "fallback-incident", incidentId] as const;
98
+
99
+ export function ChatCardView(props: PluginTimelineItemProps<ChatCard>) {
100
+ return props.item.data.type === "fallback" ? <FallbackCardView {...props} /> : <MessageCardView {...props} />;
101
+ }
102
+
103
+ function MessageCardView({ theme, layout, agentId, item, timestamp }: PluginTimelineItemProps<ChatCard>) {
104
+ const card = item.data;
105
+ const styles = useMemo(() => dashboardStyles(theme, layout.compact), [theme, layout.compact]);
106
+ const paseo = usePaseo();
107
+ const listPeers = useRpc(chatPeersRpc);
108
+ const peers = useQuery({
109
+ queryKey: ["paseo-bm", "chat-peers", agentId],
110
+ queryFn: () => listPeers({ agentId }),
111
+ staleTime: PEERS_STALE_MS,
112
+ });
113
+ const [open, setOpen] = useState(() => startsOpen(card));
114
+ const [replying, setReplying] = useState(false);
115
+ const [answer, setAnswer] = useState("");
116
+ const [sending, setSending] = useState(false);
117
+ const [outcome, setOutcome] = useState<{ text: string; tone: "success" | "danger" } | null>(null);
118
+ const [picks, setPicks] = useState<Picks>({});
119
+ const [beadsOpen, setBeadsOpen] = useState(false);
120
+ const [marking, setMarking] = useState(false);
121
+ // Every copy of this card (the chat's, a waiting pill's popover) reads the
122
+ // same session state and redraws when any copy writes (delta 20260918d §4.9).
123
+ useSyncExternalStore(subscribeAnswers, answersVersion, answersVersion);
124
+ const key = answeredKey(agentId, card);
125
+ const done = answeredRecord(key);
126
+ const replyAt = repliedAt(key);
127
+ // A question card also learns it was answered elsewhere: `chat.waiting` no
128
+ // longer lists its report (owner decision Q17). Every card shares one query.
129
+ const asksHere = peers.isSuccess && showsQuestions(card, peers.data.owner);
130
+ const listWaiting = useRpc(chatWaitingRpc);
131
+ const waiting = useQuery({
132
+ queryKey: ["paseo-bm", "chat-waiting"],
133
+ queryFn: () => listWaiting({}),
134
+ refetchInterval: WAITING_POLL_MS,
135
+ enabled: asksHere,
136
+ });
137
+ const waitingNow = waiting.isSuccess ? waiting.data.waiting : null;
138
+ // "Mark as answered" is saved in the install home, so it survives a reload
139
+ // (owner decision Q16 a); every card shares one query of the marks.
140
+ const listMarks = useRpc(answersMarksRpc);
141
+ const saveMark = useRpc(answersMarkRpc);
142
+ const queryClient = useQueryClient();
143
+ const marks = useQuery({ queryKey: ANSWER_MARKS_QUERY, queryFn: () => listMarks({}), enabled: asksHere });
144
+ const marked = marks.data?.keys.includes(key) ?? false;
145
+ const how = asksHere
146
+ ? answeredHow({
147
+ sent: done !== null,
148
+ marked,
149
+ waiting: waitingNow,
150
+ stillWaitingNow: waitingNow !== null && stillWaiting(card, agentId, waitingNow),
151
+ })
152
+ : null;
153
+
154
+ const { from, to } = partiesOf(card, peers.data?.owner ?? null, peers.data?.peers ?? []);
155
+ const counterpart = card.direction === "received" ? from : to;
156
+ const fromMark = markOf(from.role);
157
+ const chip = statusChip(card);
158
+ const warning = peers.isSuccess ? ownerWarning(peers.data.owner) : null;
159
+ const outline = outlineTone(card);
160
+ const canReply = counterpart.id !== null && counterpart.id !== agentId;
161
+ const controls = replyControls(canReply, replyAt !== null || how !== null);
162
+ const beadIds = useMemo(() => beadIdCandidates(card.text), [card.text]);
163
+ const workspaceId = peers.data?.workspaceId ?? null;
164
+
165
+ // Not a paseo-bm chat, or a block this agent only quoted: plain text, no card.
166
+ if (peers.isSuccess && !drawAsCard(card, peers.data.owner)) {
167
+ return (
168
+ <View style={{ marginVertical: 4 }}>
169
+ <MarkdownView source={fallbackMarkdown(card)} theme={theme} compact={layout.compact} />
170
+ </View>
171
+ );
172
+ }
173
+
174
+ const refreshPeers = async () => {
175
+ const fresh = await peers.refetch({ throwOnError: true });
176
+ return { owner: fresh.data?.owner ?? null, peers: fresh.data?.peers ?? [] };
177
+ };
178
+
179
+ // A pick rewrites the answers block at the top of the Reply box and opens it;
180
+ // the user's own words stay (delta 20260918d §4.1).
181
+ const changePicks = (next: Picks) => {
182
+ setPicks(next);
183
+ setAnswer((current) => withAnswersBlock(current, answersDraft(card, next)));
184
+ setReplying(true);
185
+ setOutcome(null);
186
+ };
187
+
188
+ const markAnswered = async () => {
189
+ setMarking(true);
190
+ setOutcome(null);
191
+ try {
192
+ queryClient.setQueryData(ANSWER_MARKS_QUERY, await saveMark({ key, marked: true }));
193
+ } catch (failure) {
194
+ setOutcome({ text: errorMessageOf(failure), tone: "danger" });
195
+ } finally {
196
+ setMarking(false);
197
+ }
198
+ };
199
+
200
+ // Every Reply re-reads the recipient's status first (delta 20260918d §4.2).
201
+ const send = async () => {
202
+ if (answer.trim() === "") return;
203
+ setSending(true);
204
+ setOutcome(null);
205
+ const result = await sendReply({
206
+ card,
207
+ text: answer,
208
+ refreshPeers,
209
+ send: (id, text) => paseo.agents.ref(id).send(text).then(() => undefined),
210
+ });
211
+ setSending(false);
212
+ if (!result.ok) {
213
+ setOutcome({ text: result.reason, tone: "danger" });
214
+ return;
215
+ }
216
+ const to = partyName({ role: counterpart.role, id: result.to.id, title: result.to.title });
217
+ setOutcome({ text: `Sent to ${to}.`, tone: "success" });
218
+ const at = new Date();
219
+ setReplied(key, at);
220
+ const summary = sentSummary(card, picks, answer);
221
+ if (summary !== null) setAnswered(key, { at, summary, to });
222
+ setAnswer("");
223
+ setReplying(false);
224
+ };
225
+
226
+ // Without a single recipient the options cannot be sent anywhere, so they are
227
+ // off, and the card says why. A running recipient is not a reason: the user
228
+ // may prepare answers while it works; Send refuses at send time.
229
+ const target = peers.isSuccess ? replyTarget(card, peers.data.owner, peers.data.peers) : null;
230
+ // The question form draws only once peers are known, and with no one to reply
231
+ // to `replyTarget` always gives its reason, so there is no other case to word.
232
+ const unreachable = canReply || target === null || !("reason" in target) ? null : target.reason;
233
+
234
+ return (
235
+ <View style={[styles.card, { gap: 6, marginVertical: 4 }, outline === null ? null : { borderColor: toneColor(theme, outline) }]}>
236
+ {/* No coloured border and a plain-colour name: only the role icon keeps its
237
+ colour; the time sits under the sender (delta 20260918d §4.7, Q11).
238
+ A finished report is the one exception: its whole outline takes the
239
+ success colour, at the usual width (§4.10). */}
240
+ <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 8 }}>
241
+ <View style={{ flex: 1, gap: 2 }}>
242
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
243
+ {fromMark === null ? null : <RoleMark kind={fromMark} theme={theme} />}
244
+ <Text style={[styles.sectionTitle, { color: theme.colors.foreground }]} numberOfLines={1}>
245
+ {partyName(from)}
246
+ </Text>
247
+ <Text style={styles.body} numberOfLines={1}>
248
+ {`→ ${partyName(to)}`}
249
+ </Text>
250
+ </View>
251
+ <Text style={styles.body}>{clock(timestamp)}</Text>
252
+ </View>
253
+ <View style={{ alignItems: "flex-end", gap: 4 }}>
254
+ {chip === null ? null : <Chip badge={chip} styles={styles} theme={theme} />}
255
+ {warning === null ? null : <Chip badge={warning.chip} styles={styles} theme={theme} />}
256
+ {card.formatIssues.length === 0 ? null : <Chip badge={{ text: "template error", tone: "danger" }} styles={styles} theme={theme} />}
257
+ {controls.answeredChip ? (
258
+ <Chip badge={{ text: "Answered", tone: "success" }} onPress={() => setReplying(true)} styles={styles} theme={theme} />
259
+ ) : null}
260
+ </View>
261
+ </View>
262
+
263
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
264
+ {card.requestId === null ? null : <Chip badge={{ text: card.requestId, tone: "muted" }} styles={styles} theme={theme} />}
265
+ {/* Two lines at most, also while the message is open: the full text is right below (Q50). */}
266
+ <Text style={[styles.body, { flex: 1 }]} numberOfLines={2}>
267
+ {summaryOf(card)}
268
+ </Text>
269
+ </View>
270
+
271
+ {workspaceId === null || beadIds.length === 0 ? null : (
272
+ <BeadChips
273
+ workspaceId={workspaceId}
274
+ ids={beadIds}
275
+ expanded={beadsOpen}
276
+ onExpand={() => setBeadsOpen(true)}
277
+ styles={styles}
278
+ theme={theme}
279
+ />
280
+ )}
281
+
282
+ {peers.isSuccess && showsQuestions(card, peers.data.owner) ? (
283
+ <QuestionForm
284
+ card={card}
285
+ styles={styles}
286
+ theme={theme}
287
+ recipient={partyName(from)}
288
+ picks={picks}
289
+ onChange={changePicks}
290
+ answeredLine={
291
+ how === "sent" && done !== null
292
+ ? `Answered at ${clock(done.at)} → ${done.to}: ${done.summary}`
293
+ : how === "marked"
294
+ ? "Marked as answered."
295
+ : how === "moved-on"
296
+ ? `No longer waiting: ${partyName(from)} is working or has reported since.`
297
+ : null
298
+ }
299
+ unreachable={unreachable}
300
+ marking={marking}
301
+ onMarkAnswered={() => void markAnswered()}
302
+ />
303
+ ) : null}
304
+
305
+ <View style={styles.chipRow}>
306
+ <Pressable
307
+ accessibilityRole="button"
308
+ accessibilityState={{ expanded: open }}
309
+ onPress={() => setOpen(!open)}
310
+ style={styles.secondaryButton}
311
+ >
312
+ <Text style={styles.secondaryButtonText}>{open ? "▾ Hide message" : "▸ Show message"}</Text>
313
+ </Pressable>
314
+ {controls.replyButton ? (
315
+ <Pressable accessibilityRole="button" onPress={() => setReplying(!replying)} style={styles.button}>
316
+ <Text style={styles.buttonText}>{`Reply to ${roleName(counterpart.role)}`}</Text>
317
+ </Pressable>
318
+ ) : null}
319
+ </View>
320
+
321
+ {open ? (
322
+ <View style={[styles.card, { backgroundColor: theme.colors.surface0 }]}>
323
+ {warning === null ? null : <Text style={styles.body}>{warning.line}</Text>}
324
+ {card.formatIssues.length === 0 ? null : (
325
+ <View style={{ gap: 2 }}>
326
+ <Text style={[styles.body, { color: toneColor(theme, "danger") }]}>This message breaks the template:</Text>
327
+ {card.formatIssues.map((line, index) => (
328
+ <Text key={`${index}:${line}`} style={styles.body}>{`• ${line}`}</Text>
329
+ ))}
330
+ </View>
331
+ )}
332
+ <MarkdownView source={markdownOf(card.text)} theme={theme} compact={layout.compact} />
333
+ </View>
334
+ ) : null}
335
+
336
+ {replying && canReply ? (
337
+ <View style={{ gap: 6 }}>
338
+ {quickReplies(card).length === 0 ? null : (
339
+ <View style={styles.chipRow}>
340
+ {quickReplies(card).map((text) => (
341
+ <Chip key={text} badge={{ text, tone: "info" }} onPress={() => setAnswer(text)} styles={styles} theme={theme} />
342
+ ))}
343
+ </View>
344
+ )}
345
+ <TextInput
346
+ value={answer}
347
+ onChangeText={setAnswer}
348
+ multiline
349
+ placeholder={`Your answer to ${partyName(counterpart)}${card.requestId === null ? "" : ` about ${card.requestId}`}`}
350
+ placeholderTextColor={theme.colors.foregroundMuted}
351
+ style={[styles.mono, { minHeight: 64, borderWidth: 1, borderColor: theme.colors.border, borderRadius: 8, padding: 8 }]}
352
+ />
353
+ <View style={styles.chipRow}>
354
+ <Pressable
355
+ accessibilityRole="button"
356
+ disabled={sending || answer.trim() === ""}
357
+ onPress={() => void send()}
358
+ style={styles.button}
359
+ >
360
+ <Text style={styles.buttonText}>{sending ? "Sending…" : "Send"}</Text>
361
+ </Pressable>
362
+ <Pressable accessibilityRole="button" onPress={() => setReplying(false)} style={styles.secondaryButton}>
363
+ <Text style={styles.secondaryButtonText}>Cancel</Text>
364
+ </Pressable>
365
+ </View>
366
+ </View>
367
+ ) : null}
368
+ {outcome === null ? null : <Text style={[styles.body, { color: toneColor(theme, outcome.tone) }]}>{outcome.text}</Text>}
369
+ </View>
370
+ );
371
+ }
372
+
373
+ type Styles = ReturnType<typeof dashboardStyles>;
374
+ type Theme = PluginTimelineItemProps<ChatCard>["theme"];
375
+
376
+ /**
377
+ * The card of a `BM-FALLBACK` notice (delta 20260921 §4.4.6). The notice only
378
+ * names the incident: whether there is anything to decide, and what, is read
379
+ * from `fallback.incidents` — while the incident is still open, again every
380
+ * `WAITING_POLL_MS` — so an old notice never shows a button that no longer
381
+ * applies. A button press is one `fallback.act`; the incident it returns is the
382
+ * card's new state, and a failure is shown with its code.
383
+ */
384
+ function FallbackCardView({ theme, layout, item, timestamp }: PluginTimelineItemProps<ChatCard>) {
385
+ const card = item.data;
386
+ const incidentId = card.fallback?.incident ?? "";
387
+ const styles = useMemo(() => dashboardStyles(theme, layout.compact), [theme, layout.compact]);
388
+ const queryClient = useQueryClient();
389
+ const listIncidents = useRpc(fallbackIncidentsRpc);
390
+ const act = useRpc(fallbackActRpc);
391
+ const getOptions = useRpc(rolesOptionsRpc);
392
+ const incidents = useQuery({
393
+ queryKey: fallbackIncidentKey(incidentId),
394
+ queryFn: async (): Promise<FallbackIncident[]> => (await listIncidents({ ids: [incidentId] })).incidents,
395
+ enabled: incidentId !== "",
396
+ // Only an open incident can still change without a press here (the reset
397
+ // timer, a copy of this card in a pill's popover).
398
+ refetchInterval: (query) => {
399
+ const status = query.state.data?.find((candidate) => candidate.id === incidentId)?.status;
400
+ return status === "pending" || status === "waiting" ? WAITING_POLL_MS : false;
401
+ },
402
+ });
403
+ const lookup: FallbackLookup = incidents.isSuccess
404
+ ? lookupOf(incidentId, incidents.data)
405
+ : incidents.isError
406
+ ? { state: "failed", error: incidents.error }
407
+ : { state: "loading" };
408
+ // The Switch button's price: the bundled table, else what Paseo lists.
409
+ const costProvider = listedCostProvider(lookup);
410
+ const listed = useQuery({
411
+ queryKey: ["paseo-bm", "fallback-candidate-options", costProvider ?? ""],
412
+ queryFn: () => getOptions({ provider: costProvider! }),
413
+ enabled: costProvider !== null,
414
+ staleTime: COST_STALE_MS,
415
+ retry: false,
416
+ });
417
+ const [acting, setActing] = useState<FallbackAction | null>(null);
418
+ const [failure, setFailure] = useState<string | null>(null);
419
+ const cost = lookup.state === "found" ? candidateCost(lookup.incident.candidate, listed.data?.models) : null;
420
+ const view = fallbackCardView(card, lookup, new Date(), cost);
421
+ const mark = markOf(view.role);
422
+
423
+ const press = async (action: FallbackAction) => {
424
+ setActing(action);
425
+ setFailure(null);
426
+ const result = await runFallbackAction({ incidentId, action, act });
427
+ setActing(null);
428
+ if (result.ok) {
429
+ queryClient.setQueryData<FallbackIncident[]>(fallbackIncidentKey(incidentId), [result.incident]);
430
+ return;
431
+ }
432
+ setFailure(result.reason);
433
+ // Refused because the incident moved on (another copy, the timer): show where it is now.
434
+ void queryClient.invalidateQueries({ queryKey: fallbackIncidentKey(incidentId) });
435
+ };
436
+
437
+ return (
438
+ <View style={[styles.card, { gap: 6, marginVertical: 4 }]}>
439
+ <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 8 }}>
440
+ <View style={{ flex: 1, gap: 2 }}>
441
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
442
+ {mark === null ? null : <RoleMark kind={mark} theme={theme} />}
443
+ <Text style={[styles.sectionTitle, { color: theme.colors.foreground }]} numberOfLines={1}>
444
+ {view.title}
445
+ </Text>
446
+ </View>
447
+ <Text style={styles.body}>{clock(timestamp)}</Text>
448
+ </View>
449
+ {view.chip === null ? null : <Chip badge={view.chip} styles={styles} theme={theme} />}
450
+ </View>
451
+
452
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
453
+ {view.requestId === null ? null : <Chip badge={{ text: view.requestId, tone: "muted" }} styles={styles} theme={theme} />}
454
+ {view.summary === "" ? null : (
455
+ <Text style={[styles.body, { flex: 1 }]} numberOfLines={2}>
456
+ {view.summary}
457
+ </Text>
458
+ )}
459
+ </View>
460
+ {view.message === null ? null : (
461
+ <Text style={[styles.mono, { color: theme.colors.foregroundMuted }]} numberOfLines={3}>
462
+ {view.message}
463
+ </Text>
464
+ )}
465
+
466
+ {view.buttons.length === 0 ? null : (
467
+ <View style={styles.chipRow}>
468
+ {view.buttons.map((button) => (
469
+ <Pressable
470
+ key={button.action}
471
+ accessibilityRole="button"
472
+ accessibilityState={{ disabled: acting !== null }}
473
+ disabled={acting !== null}
474
+ onPress={() => void press(button.action)}
475
+ style={[button.action === "dismiss" ? styles.secondaryButton : styles.button, { opacity: acting !== null && acting !== button.action ? 0.5 : 1 }]}
476
+ >
477
+ <Text style={button.action === "dismiss" ? styles.secondaryButtonText : styles.buttonText}>
478
+ {acting === button.action ? "Working…" : button.label}
479
+ </Text>
480
+ </Pressable>
481
+ ))}
482
+ </View>
483
+ )}
484
+ {view.statusLine === null ? null : <Text style={[styles.body, { color: toneColor(theme, view.statusLine.tone) }]}>{view.statusLine.text}</Text>}
485
+ {failure === null ? null : <Text style={[styles.body, { color: toneColor(theme, "danger") }]}>{failure}</Text>}
486
+ </View>
487
+ );
488
+ }
489
+
490
+ /** One option of a question: a full-width row, outlined, filled when chosen (delta 20260918d §4.4). */
491
+ function optionRow(theme: Theme, selected: boolean) {
492
+ return {
493
+ alignSelf: "stretch" as const,
494
+ flexDirection: "row" as const,
495
+ alignItems: "flex-start" as const,
496
+ gap: 8,
497
+ paddingVertical: 8,
498
+ paddingHorizontal: 10,
499
+ borderRadius: 8,
500
+ borderWidth: 1,
501
+ borderColor: selected ? toneColor(theme, "info") : theme.colors.border,
502
+ backgroundColor: selected ? theme.colors.surface2 : "transparent",
503
+ };
504
+ }
505
+
506
+ /**
507
+ * The questions of a Worker's `blocked` report, answered with one tap per
508
+ * question. Draw-only: the picks live in the card, which writes them into the
509
+ * Reply box. Nothing is pre-selected; "Use recommendations" fills the Worker's
510
+ * recommendations in and sends nothing.
511
+ */
512
+ function QuestionForm({
513
+ card,
514
+ styles,
515
+ theme,
516
+ recipient,
517
+ picks,
518
+ onChange,
519
+ answeredLine,
520
+ unreachable,
521
+ marking,
522
+ onMarkAnswered,
523
+ }: {
524
+ card: ChatCard;
525
+ styles: Styles;
526
+ theme: Theme;
527
+ recipient: string;
528
+ picks: Picks;
529
+ onChange: (next: Picks) => void;
530
+ /** What replaces the options once the card counts as answered, or null. */
531
+ answeredLine: string | null;
532
+ /** Why the options are off, or null when they can be sent. */
533
+ unreachable: string | null;
534
+ /** True while "Mark as answered" is being saved. */
535
+ marking: boolean;
536
+ onMarkAnswered: () => void;
537
+ }) {
538
+ if (answeredLine !== null) {
539
+ return <Text style={[styles.body, { color: toneColor(theme, "success") }]}>{answeredLine}</Text>;
540
+ }
541
+
542
+ const off = unreachable !== null;
543
+ const pick = (question: Question, next: Picks[string]) => onChange({ ...picks, [question.id]: next });
544
+
545
+ return (
546
+ <View style={{ gap: 8 }}>
547
+ {card.questions.map((question, index) => {
548
+ const { heading, body } = questionHeading(question);
549
+ const current = picks[question.id];
550
+ const other = current !== undefined && "other" in current ? current : null;
551
+ return (
552
+ <View
553
+ key={question.id}
554
+ style={[
555
+ { gap: 6, paddingVertical: 8 },
556
+ // A divider between questions (delta 20260918d §4.4, owner decision Q5).
557
+ index === 0 ? null : { borderTopWidth: 1, borderTopColor: theme.colors.border },
558
+ ]}
559
+ >
560
+ <Text style={[styles.body, { color: theme.colors.foreground, fontWeight: "600" }]}>{heading}</Text>
561
+ <Text style={[styles.body, { color: theme.colors.foreground }]}>{body}</Text>
562
+ <View style={{ gap: 8 }}>
563
+ {choosable(question)
564
+ ? question.options.map((option) => {
565
+ const selected = current !== undefined && "key" in current && current.key === option.key;
566
+ return (
567
+ <Pressable
568
+ key={option.key}
569
+ accessibilityRole="button"
570
+ accessibilityState={{ selected, disabled: off }}
571
+ disabled={off}
572
+ onPress={() => pick(question, { key: option.key })}
573
+ style={optionRow(theme, selected)}
574
+ >
575
+ <Text style={[styles.body, { width: 14, color: theme.colors.foreground }]}>{selected ? "●" : "○"}</Text>
576
+ <Text style={[styles.body, { width: 16, color: theme.colors.foreground, fontWeight: "600" }]}>{option.key}</Text>
577
+ <Text style={[styles.body, { flex: 1, color: theme.colors.foreground }]}>{option.text}</Text>
578
+ {option.recommended ? <Chip badge={{ text: "recommended", tone: "success" }} styles={styles} theme={theme} /> : null}
579
+ </Pressable>
580
+ );
581
+ })
582
+ : null}
583
+ <Pressable
584
+ accessibilityRole="button"
585
+ accessibilityState={{ selected: other !== null, disabled: off }}
586
+ disabled={off}
587
+ onPress={() => pick(question, { other: other?.other ?? "" })}
588
+ style={optionRow(theme, other !== null)}
589
+ >
590
+ <Text style={[styles.body, { width: 14, color: theme.colors.foreground }]}>{other !== null ? "●" : "○"}</Text>
591
+ <Text style={[styles.body, { flex: 1, color: theme.colors.foreground }]}>Other…</Text>
592
+ </Pressable>
593
+ {other === null ? null : (
594
+ <TextInput
595
+ value={other.other}
596
+ onChangeText={(text) => pick(question, { other: text })}
597
+ editable={!off}
598
+ multiline
599
+ placeholder={`Your answer to ${question.id}`}
600
+ placeholderTextColor={theme.colors.foregroundMuted}
601
+ style={[styles.mono, { minHeight: 48, borderWidth: 1, borderColor: theme.colors.border, borderRadius: 8, padding: 8 }]}
602
+ />
603
+ )}
604
+ </View>
605
+ </View>
606
+ );
607
+ })}
608
+ <Text style={styles.body}>{`Answers go to ${recipient}${card.requestId === null ? "" : ` · ${card.requestId}`}`}</Text>
609
+ {unreachable === null ? null : <Text style={[styles.body, { color: toneColor(theme, "danger") }]}>{unreachable}</Text>}
610
+ <View style={styles.chipRow}>
611
+ <Pressable
612
+ accessibilityRole="button"
613
+ accessibilityState={{ disabled: off }}
614
+ disabled={off}
615
+ onPress={() => onChange(recommendedPicks(card.questions, picks))}
616
+ style={styles.secondaryButton}
617
+ >
618
+ <Text style={styles.secondaryButtonText}>Use recommendations</Text>
619
+ </Pressable>
620
+ <Pressable accessibilityRole="button" accessibilityState={{ disabled: off }} disabled={off} onPress={() => onChange({})} style={styles.secondaryButton}>
621
+ <Text style={styles.secondaryButtonText}>Clear</Text>
622
+ </Pressable>
623
+ {/* Saved in the install home: the card stays answered after a reload (Q16 a). */}
624
+ <Pressable
625
+ accessibilityRole="button"
626
+ accessibilityState={{ disabled: marking }}
627
+ disabled={marking}
628
+ onPress={onMarkAnswered}
629
+ style={[styles.secondaryButton, { opacity: marking ? 0.5 : 1 }]}
630
+ >
631
+ <Text style={styles.secondaryButtonText}>{marking ? "Marking…" : "Mark as answered"}</Text>
632
+ </Pressable>
633
+ </View>
634
+ </View>
635
+ );
636
+ }