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.
- package/LICENSE +21 -0
- package/README.md +53 -0
- package/client/agent-tree.ts +308 -0
- package/client/answer-state.ts +62 -0
- package/client/bead-chips.tsx +147 -0
- package/client/beads-header-button.ts +108 -0
- package/client/beads-model.ts +581 -0
- package/client/beads-screen.tsx +516 -0
- package/client/beads-tab.tsx +58 -0
- package/client/chat-card.tsx +636 -0
- package/client/chat-cards.ts +1038 -0
- package/client/dashboard-actions.tsx +255 -0
- package/client/dashboard-model.ts +947 -0
- package/client/dashboard-view.ts +215 -0
- package/client/dashboard.tsx +318 -0
- package/client/launch-manager.ts +323 -0
- package/client/launcher.tsx +516 -0
- package/client/markdown-view.tsx +112 -0
- package/client/markdown.ts +145 -0
- package/client/settings.tsx +104 -0
- package/client/setup-model.ts +552 -0
- package/client/setup-screen.tsx +913 -0
- package/client/slot.ts +47 -0
- package/client/tree.tsx +204 -0
- package/client/ui.tsx +262 -0
- package/client/waiting-pills-model.ts +156 -0
- package/client/waiting-pills.tsx +201 -0
- package/index.client.tsx +232 -0
- package/index.server.ts +168 -0
- package/package.json +35 -0
- package/paseo-plugin.json +6 -0
- package/roles/manager.md +181 -0
- package/roles/reviewer.md +160 -0
- package/roles/worker.md +407 -0
- package/server/agent-labels.ts +194 -0
- package/server/agent-role.ts +102 -0
- package/server/answer-marks.ts +120 -0
- package/server/bead-actions.ts +88 -0
- package/server/bead-work.ts +80 -0
- package/server/beads-store.ts +342 -0
- package/server/bm-report.ts +433 -0
- package/server/chat-peers.ts +65 -0
- package/server/chat-rpc.ts +122 -0
- package/server/chat-waiting.ts +182 -0
- package/server/collector.ts +629 -0
- package/server/config-writer.ts +222 -0
- package/server/cost.ts +88 -0
- package/server/dashboard-rpc.ts +662 -0
- package/server/fallback-detect.ts +183 -0
- package/server/fallback-handover.ts +365 -0
- package/server/fallback-manager.ts +170 -0
- package/server/fallback-reviewer.ts +198 -0
- package/server/fallback-rpc.ts +306 -0
- package/server/fallback-settings.ts +322 -0
- package/server/fallback-state.ts +518 -0
- package/server/fallback-switch.ts +191 -0
- package/server/fallback-wait.ts +188 -0
- package/server/format-check.ts +352 -0
- package/server/install-home.ts +187 -0
- package/server/live-timeline.ts +129 -0
- package/server/manager-instructions.ts +9 -0
- package/server/manager.ts +647 -0
- package/server/model-costs.ts +238 -0
- package/server/notice-queue.ts +315 -0
- package/server/notices.ts +81 -0
- package/server/paseo-cli.ts +115 -0
- package/server/provider-id.ts +12 -0
- package/server/review-budget.ts +208 -0
- package/server/reviewer-instructions.ts +9 -0
- package/server/role-choices.ts +161 -0
- package/server/role-extras.ts +270 -0
- package/server/role-hook.ts +347 -0
- package/server/role-mode.ts +397 -0
- package/server/role-settings-rpc.ts +325 -0
- package/server/roles.ts +96 -0
- package/server/settings-notices.ts +112 -0
- package/server/setup-rpc.ts +70 -0
- package/server/setup-skills.ts +121 -0
- package/server/setup-tools.ts +162 -0
- package/server/shell.ts +68 -0
- package/server/stop-propagation.ts +365 -0
- package/server/tools-check.ts +118 -0
- package/server/trace-store.ts +1137 -0
- package/server/traces.ts +1356 -0
- package/server/worker-instructions.ts +9 -0
- package/server/workflow-steps.ts +422 -0
- package/shared/bead-ids.ts +25 -0
- package/shared/bm-fallback.ts +91 -0
- package/shared/bm-format.ts +424 -0
- package/shared/bm-questions.ts +213 -0
- package/shared/bm-report.ts +433 -0
- package/shared/contracts.ts +1371 -0
- package/shared/fallback-patterns.ts +201 -0
- package/shared/fallback.ts +46 -0
- package/shared/new-request.ts +20 -0
- package/shared/order.ts +22 -0
- package/shared/prices.ts +65 -0
- package/shared/settings.ts +57 -0
- package/shared/sole-worker.ts +20 -0
- package/shared/version.ts +6 -0
- 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
|
+
}
|