bottleship 0.1.0-prototype.0 → 0.1.0-prototype.2

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 (167) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +118 -113
  3. package/dist/src/boxes.d.ts +41 -39
  4. package/dist/src/boxes.js +181 -179
  5. package/dist/src/cli.js +298 -83
  6. package/dist/src/config.d.ts +3 -6
  7. package/dist/src/config.js +19 -85
  8. package/dist/src/init.d.ts +3 -4
  9. package/dist/src/init.js +120 -205
  10. package/dist/src/local.d.ts +8 -0
  11. package/dist/src/local.js +24 -0
  12. package/dist/src/onboard.d.ts +5 -0
  13. package/dist/src/onboard.js +25 -0
  14. package/dist/src/paths.d.ts +0 -1
  15. package/dist/src/paths.js +0 -1
  16. package/dist/src/provider.d.ts +16 -0
  17. package/dist/src/provider.js +81 -0
  18. package/dist/src/repo.d.ts +21 -0
  19. package/dist/src/repo.js +75 -0
  20. package/dist/src/review/comments.d.ts +66 -0
  21. package/dist/src/review/comments.js +344 -0
  22. package/dist/src/review/contract.d.ts +82 -0
  23. package/dist/src/review/contract.js +189 -0
  24. package/dist/src/review/github.d.ts +120 -0
  25. package/dist/src/review/github.js +160 -0
  26. package/dist/src/review/local.d.ts +14 -0
  27. package/dist/src/review/local.js +70 -0
  28. package/dist/src/review/loop.d.ts +12 -0
  29. package/dist/src/review/loop.js +119 -0
  30. package/dist/src/review/pr.d.ts +14 -0
  31. package/dist/src/review/pr.js +68 -0
  32. package/dist/src/review/prompt.d.ts +15 -0
  33. package/dist/src/review/prompt.js +48 -0
  34. package/dist/src/review/serve.d.ts +1 -0
  35. package/dist/src/review/serve.js +8 -0
  36. package/dist/src/review/server.d.ts +14 -0
  37. package/dist/src/review/server.js +484 -0
  38. package/dist/src/review/setting-up.d.ts +4 -0
  39. package/dist/src/review/setting-up.js +74 -0
  40. package/dist/src/review/start.d.ts +32 -0
  41. package/dist/src/review/start.js +140 -0
  42. package/dist/src/review/target.d.ts +20 -0
  43. package/dist/src/review/target.js +66 -0
  44. package/dist/src/review/template.d.ts +3 -0
  45. package/dist/src/review/template.js +13 -0
  46. package/dist/src/review/terminal.d.ts +4 -0
  47. package/dist/src/review/terminal.js +91 -0
  48. package/dist/src/secrets.d.ts +1 -0
  49. package/dist/src/secrets.js +11 -1
  50. package/dist/src/types.d.ts +1 -71
  51. package/dist/src/ui.d.ts +6 -1
  52. package/dist/src/ui.js +22 -23
  53. package/dist/src/util.d.ts +11 -0
  54. package/dist/src/util.js +40 -0
  55. package/package.json +7 -14
  56. package/presets/agents/claude.json +1 -5
  57. package/presets/agents/codex.json +1 -10
  58. package/presets/agents/cursor.json +1 -9
  59. package/presets/agents/opencode.json +1 -14
  60. package/presets/providers/docker/create +12 -0
  61. package/presets/providers/docker/destroy +2 -0
  62. package/presets/providers/docker/exec +2 -0
  63. package/presets/providers/docker/list +4 -0
  64. package/presets/providers/docker/status +2 -0
  65. package/presets/providers/docker/url +4 -0
  66. package/review-app/KIT.md +106 -0
  67. package/review-app/WRITING.md +93 -0
  68. package/review-app/examples/rply-16.tsx +98 -0
  69. package/review-app/index.html +15 -0
  70. package/review-app/package-lock.json +2539 -0
  71. package/review-app/package.json +24 -0
  72. package/review-app/playground/fixtures/excalidraw-12602/data/box.json +1 -0
  73. package/review-app/playground/fixtures/excalidraw-12602/data/pr.diff +91 -0
  74. package/review-app/playground/fixtures/excalidraw-12602/data/pr.json +62 -0
  75. package/review-app/playground/fixtures/excalidraw-12602/data/review-comments.json +44 -0
  76. package/review-app/playground/fixtures/excalidraw-12602/events.json +1 -0
  77. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-ar-zoom.png +0 -0
  78. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-en-US-zoom.png +0 -0
  79. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-IL-zoom.png +0 -0
  80. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-dark.png +0 -0
  81. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-hover.png +0 -0
  82. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-page.png +0 -0
  83. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-tip.png +0 -0
  84. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-zen.png +0 -0
  85. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-ar-zoom.png +0 -0
  86. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-en-US-zoom.png +0 -0
  87. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-IL-zoom.png +0 -0
  88. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-dark.png +0 -0
  89. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-hover.png +0 -0
  90. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-page.png +0 -0
  91. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-tip.png +0 -0
  92. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-zen.png +0 -0
  93. package/review-app/playground/fixtures/excalidraw-12602/instances.json +1 -0
  94. package/review-app/playground/fixtures/excalidraw-12602/review.tsx +103 -0
  95. package/review-app/playground/fixtures/rply-16/data/box.json +1 -0
  96. package/review-app/playground/fixtures/rply-16/data/hooks.diff +28 -0
  97. package/review-app/playground/fixtures/rply-16/data/pr.diff +114 -0
  98. package/review-app/playground/fixtures/rply-16/data/pr.json +68 -0
  99. package/review-app/playground/fixtures/rply-16/data/review-comments.json +1 -0
  100. package/review-app/playground/fixtures/rply-16/events.json +156 -0
  101. package/review-app/playground/fixtures/rply-16/instances.json +1 -0
  102. package/review-app/playground/fixtures/rply-16/review.tsx +98 -0
  103. package/review-app/playground/gallery.tsx +127 -0
  104. package/review-app/playground/index.html +15 -0
  105. package/review-app/playground/main.tsx +26 -0
  106. package/review-app/playground/mock.ts +215 -0
  107. package/review-app/playground/playground.css +18 -0
  108. package/review-app/playground/vite.config.ts +32 -0
  109. package/review-app/scripts/check.cjs +47 -0
  110. package/review-app/scripts/clip.cjs +104 -0
  111. package/review-app/scripts/shot.cjs +66 -0
  112. package/review-app/src/App.tsx +132 -0
  113. package/review-app/src/data/box.json +1 -0
  114. package/review-app/src/data/hooks.diff +28 -0
  115. package/review-app/src/data/pr.diff +114 -0
  116. package/review-app/src/data/pr.json +68 -0
  117. package/review-app/src/data/review-comments.json +1 -0
  118. package/review-app/src/kit/agent.tsx +287 -0
  119. package/review-app/src/kit/chapter.tsx +178 -0
  120. package/review-app/src/kit/code.tsx +217 -0
  121. package/review-app/src/kit/comments.tsx +377 -0
  122. package/review-app/src/kit/finish.tsx +274 -0
  123. package/review-app/src/kit/flow.tsx +184 -0
  124. package/review-app/src/kit/index.ts +10 -0
  125. package/review-app/src/kit/kit.css +696 -0
  126. package/review-app/src/kit/live.ts +91 -0
  127. package/review-app/src/kit/media.tsx +348 -0
  128. package/review-app/src/kit/overview.tsx +161 -0
  129. package/review-app/src/kit/pr.ts +100 -0
  130. package/review-app/src/kit/pullrequest.tsx +298 -0
  131. package/review-app/src/kit/review.ts +74 -0
  132. package/review-app/src/kit/theme.ts +30 -0
  133. package/review-app/src/kit/ui.tsx +127 -0
  134. package/review-app/src/main.tsx +6 -0
  135. package/review-app/src/review.tsx +22 -0
  136. package/review-app/tsconfig.json +17 -0
  137. package/review-app/vite.config.ts +12 -0
  138. package/dist/presets/providers/command.d.ts +0 -3
  139. package/dist/presets/providers/command.js +0 -39
  140. package/dist/presets/providers/daytona.d.ts +0 -3
  141. package/dist/presets/providers/daytona.js +0 -173
  142. package/dist/presets/providers/docker.d.ts +0 -3
  143. package/dist/presets/providers/docker.js +0 -31
  144. package/dist/presets/providers/e2b.d.ts +0 -3
  145. package/dist/presets/providers/e2b.js +0 -124
  146. package/dist/presets/providers/exe.d.ts +0 -3
  147. package/dist/presets/providers/exe.js +0 -28
  148. package/dist/presets/providers/http.example.d.ts +0 -3
  149. package/dist/presets/providers/http.example.js +0 -47
  150. package/dist/presets/providers/local.d.ts +0 -3
  151. package/dist/presets/providers/local.js +0 -52
  152. package/dist/presets/providers/modal.d.ts +0 -3
  153. package/dist/presets/providers/modal.js +0 -117
  154. package/dist/presets/providers/ssh.d.ts +0 -3
  155. package/dist/presets/providers/ssh.js +0 -49
  156. package/dist/presets/providers/util.d.ts +0 -13
  157. package/dist/presets/providers/util.js +0 -102
  158. package/presets/providers/command.ts +0 -43
  159. package/presets/providers/daytona.ts +0 -181
  160. package/presets/providers/docker.ts +0 -38
  161. package/presets/providers/e2b.ts +0 -130
  162. package/presets/providers/exe.ts +0 -35
  163. package/presets/providers/http.example.ts +0 -64
  164. package/presets/providers/local.ts +0 -56
  165. package/presets/providers/modal.ts +0 -121
  166. package/presets/providers/ssh.ts +0 -54
  167. package/presets/providers/util.ts +0 -112
@@ -0,0 +1,287 @@
1
+ // The agent, on every page: a footer under the content with the agent's button on the right, opening a chat. The chat
2
+ // is one thread of comments for the agent (the box agent in a PR review, the developer's own agent in a local one),
3
+ // each message labelled with the page it was written on.
4
+ import { useEffect, useLayoutEffect, useRef, useState } from "react";
5
+ import { createPortal } from "react-dom";
6
+ import { addComment, Body, dedent, onAskInChat, onReferToChat, retryComment, type Quote, type ReviewComment } from "./comments";
7
+ import { Listening } from "./finish";
8
+ import { highlight } from "./code";
9
+ import { liveComments, useLive } from "./live";
10
+ import { pr } from "./pr";
11
+ import { Icon } from "./ui";
12
+
13
+ export const isChat = (c: ReviewComment) => !!c.topic?.chat;
14
+
15
+ // the chat: the reviewer's messages and the agent's answers to them, in order
16
+ export function chatOf(all: ReviewComment[]): ReviewComment[] {
17
+ const mine = new Set(all.filter(isChat).map((c) => c.id));
18
+ return all.filter((c) => mine.has(c.id) || (c.by === "agent" && c.replyTo && mine.has(c.replyTo)));
19
+ }
20
+
21
+ // where the reviewer is, so the agent knows what they are looking at
22
+ // label: what the agent reads; short: what the chip shows
23
+ export interface Page { label: string; short: string; chapter?: string }
24
+
25
+ // whether the chat is open, and floating over the page or docked next to the content, for as long as the tab lives
26
+ const KEY = "review-agent";
27
+ const SEEN = "review-agent-seen";
28
+ interface ChatState { open: boolean; docked: boolean }
29
+ const readState = (): ChatState => {
30
+ try {
31
+ const v = JSON.parse(sessionStorage.getItem(KEY) ?? "{}");
32
+ return { open: v.open === true, docked: v.docked === true };
33
+ } catch {
34
+ return { open: false, docked: false };
35
+ }
36
+ };
37
+ const writeState = (v: ChatState) => {
38
+ try {
39
+ sessionStorage.setItem(KEY, JSON.stringify(v));
40
+ } catch {}
41
+ };
42
+
43
+ export function Footer({ page }: { page: Page }) {
44
+ const { comments } = useLive();
45
+ const chat = chatOf(comments);
46
+ const [{ open, docked }, setState] = useState(readState);
47
+ const update = (next: Partial<ChatState>) =>
48
+ setState((v) => {
49
+ const out = { ...v, ...next };
50
+ writeState(out);
51
+ return out;
52
+ });
53
+ const setOpen = (o: boolean) => update({ open: o });
54
+ // comments brought into the chat from the page; they go with the next message
55
+ const [refs, setRefs] = useState<Quote[]>([]);
56
+ useEffect(() => onReferToChat((q) => {
57
+ update({ open: true });
58
+ setRefs((r) => (r.some((x) => x.text === q.text && x.where === q.where) ? r : [...r, q]));
59
+ }), []);
60
+ const dock = (d: boolean) => update({ docked: d });
61
+ // a message to the agent, with the page it was written on and what it quotes, as the next turn of the one thread
62
+ const sendChat = (body: string, quotes: Quote[]) => {
63
+ const root = chatOf(liveComments()).find((c) => c.by === "reviewer");
64
+ return addComment({ to: "agent", topic: { chat: true, label: page.label, ...(page.chapter && { chapter: page.chapter }) }, ...(quotes.length && { quotes }), ...(root && { replyTo: root.id }), body });
65
+ };
66
+ // Ask agent in a comment: the chat opens and sends it
67
+ const sendRef = useRef(sendChat);
68
+ sendRef.current = sendChat;
69
+ useEffect(() => onAskInChat((m) => {
70
+ update({ open: true });
71
+ return sendRef.current(m.body, m.quotes);
72
+ }), []);
73
+ // the dock is a slot next to the content (in App.tsx); it exists once the frame has mounted
74
+ const [, mounted] = useState(false);
75
+ useEffect(() => mounted(true), []);
76
+ const slot = document.getElementById("agent-dock");
77
+ // an answer that came while the chat was closed fills the cursor. The newest answer seen is kept for the tab; what
78
+ // is there when a tab first connects counts as seen.
79
+ const { connected } = useLive();
80
+ const newest = [...chat].reverse().find((c) => c.by === "agent")?.id;
81
+ const [seen, setSeen] = useState(() => {
82
+ try {
83
+ return sessionStorage.getItem(SEEN) ?? undefined;
84
+ } catch {
85
+ return undefined;
86
+ }
87
+ });
88
+ const markSeen = (id?: string) => {
89
+ setSeen(id);
90
+ try {
91
+ if (id) sessionStorage.setItem(SEEN, id);
92
+ } catch {}
93
+ };
94
+ useEffect(() => {
95
+ if (connected && (open || seen === undefined) && newest !== seen) markSeen(newest ?? "");
96
+ }, [connected, open, newest]);
97
+ const last = [...chat].reverse().find((c) => c.by === "reviewer");
98
+ const state = last?.status === "working" || last?.status === "sent" ? "busy" : last?.status === "stopped" ? "stopped" : connected && newest && seen !== undefined && newest !== seen ? "new" : "";
99
+ useEffect(() => {
100
+ if (!open || docked) return;
101
+ const key = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
102
+ addEventListener("keydown", key);
103
+ return () => removeEventListener("keydown", key);
104
+ }, [open, docked]);
105
+ const chatEl = open && <Chat page={page} chat={chat} refs={refs} setRefs={setRefs} send={sendChat} docked={docked} onDock={() => dock(!docked)} onClose={() => setOpen(false)} />;
106
+ return (
107
+ <footer className="foot">
108
+ {!pr.number && <Listening />}
109
+ <span className="grow" />
110
+ <button className={`agent-btn${state ? ` ${state}` : ""}`} aria-expanded={open} aria-controls="agent-chat" onClick={() => setOpen(!open)}>
111
+ {Icon.cursor}
112
+ Agent
113
+ </button>
114
+ {docked && slot ? chatEl && createPortal(chatEl, slot) : chatEl}
115
+ </footer>
116
+ );
117
+ }
118
+
119
+ // where a selection is: lines of a file in a diff, or the picture it is in
120
+ function whereOf(r: Range): string | undefined {
121
+ const el = (n: Node) => (n instanceof Element ? n : n.parentElement);
122
+ const a = el(r.startContainer), b = el(r.endContainer);
123
+ const file = a?.closest<HTMLElement>("[data-file]")?.dataset.file;
124
+ if (file && file === b?.closest<HTMLElement>("[data-file]")?.dataset.file) {
125
+ const lines = [a, b].map((x) => Number(x?.closest<HTMLElement>("[data-line]")?.dataset.line)).filter(Boolean);
126
+ const name = file.split("/").pop();
127
+ const [lo, hi] = [Math.min(...lines), Math.max(...lines)];
128
+ return !lines.length ? name : lo === hi ? `${name}, line ${lo}` : `${name}, lines ${lo} to ${hi}`;
129
+ }
130
+ const fig = a?.closest(".fig")?.querySelector(".fig-title")?.textContent?.trim();
131
+ return fig ? `the picture "${fig}"` : undefined;
132
+ }
133
+
134
+ // what the reviewer selects on the page while the chat is open, until they deselect it, remove it or send
135
+ function useSelection(): [Quote | undefined, () => void] {
136
+ const [quote, setQuote] = useState<Quote>();
137
+ useEffect(() => {
138
+ let t: ReturnType<typeof setTimeout>;
139
+ const on = () => {
140
+ clearTimeout(t);
141
+ t = setTimeout(() => {
142
+ const s = document.getSelection();
143
+ if (!s || !s.rangeCount) return;
144
+ const r = s.getRangeAt(0);
145
+ const at = r.commonAncestorContainer instanceof Element ? r.commonAncestorContainer : r.commonAncestorContainer.parentElement;
146
+ // working in the chat keeps what was selected; deselecting on the page drops it
147
+ if (!at || at.closest(".chat")) return;
148
+ if (s.isCollapsed) return setQuote(undefined);
149
+ const text = dedent(s.toString().replace(/\s+$/, "").replace(/^\n+/, "").split("\n"));
150
+ if (!text.trim()) return;
151
+ setQuote({ text: text.slice(0, 8000), kind: "selection", ...(whereOf(r) && { where: whereOf(r) }) });
152
+ }, 120);
153
+ };
154
+ document.addEventListener("selectionchange", on);
155
+ return () => (clearTimeout(t), document.removeEventListener("selectionchange", on));
156
+ }, []);
157
+ return [quote, () => setQuote(undefined)];
158
+ }
159
+
160
+ // a selection in a few words: file:lines for code, else the start of what was selected
161
+ const chipOf = (q: Quote) => {
162
+ // a comment by whose it is: "dwelle's comment", "Your comment", "Agent's answer"
163
+ if (q.kind === "comment") return (q.where?.match(/^(.*?) on /)?.[1] ?? q.where ?? "Comment").replace(/^your/, "Your").replace(/^the agent's/, "Agent's");
164
+ const picture = q.where?.match(/^the picture "(.+)"$/);
165
+ if (picture) return picture[1];
166
+ const code = q.where?.match(/^(.+), lines? (\d+)(?: to (\d+))?$/);
167
+ if (code) return `${code[1]}:${code[2]}${code[3] ? `\u2013${code[3]}` : ""}`;
168
+ return `\u201c${clip(q.text.replace(/\s+/g, " "), 24)}\u201d`;
169
+ };
170
+ // the agent at work: a sail riding two waves, as in the porthole on the page that shows while the tour is set up
171
+ // one wave is a crest and a trough, 16 units; long enough to still fill the view after rolling a full wave
172
+ const WAVE = "q4-2.5 8 0t8 0 8 0 8 0 8 0 8 0 8 0 8 0 8 0 8 0";
173
+ function Sailing() {
174
+ return (
175
+ <svg className="sailing" width="30" height="21" viewBox="0 -3 30 21" aria-hidden="true">
176
+ <path className="sea back wave slow" d={`M-8 12.5${WAVE}V18H-8Z`} />
177
+ <path className="boat" d="M13 3v11h7.5Z" />
178
+ <path className="sea wave" d={`M0 14${WAVE}V18H0Z`} />
179
+ </svg>
180
+ );
181
+ }
182
+
183
+ // a quoted selection in a message: where it is, then the text, highlighted when it is code; long ones fade out
184
+ function QuoteCard({ quote }: { quote: Quote }) {
185
+ const comment = quote.kind === "comment";
186
+ const code = !comment && /, lines? \d/.test(quote.where ?? "");
187
+ return (
188
+ <div className={`msg-quote${comment ? " comment" : ""}`} title={quote.where}>
189
+ <div className="msg-quote-head">{comment ? Icon.comment : code ? Icon.code : Icon.quote}<span>{comment ? quote.where : quote.where ? chipOf(quote) : "Selection"}</span></div>
190
+ {comment ? <div className="msg-quote-body"><Body text={quote.text} /></div> : <pre className={`${code ? "code" : ""}${quote.text.split("\n").length > 5 ? " long" : ""}`}>{code ? highlight(quote.text) : quote.text}</pre>}
191
+ </div>
192
+ );
193
+ }
194
+
195
+ const clip = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1).trimEnd() + "\u2026" : s);
196
+
197
+ function Chat({ page, chat, refs, setRefs, send: sendChat, docked, onDock, onClose }: { page: Page; chat: ReviewComment[]; refs: Quote[]; setRefs: (r: Quote[]) => void; send: (body: string, quotes: Quote[]) => Promise<string | undefined>; docked: boolean; onDock: () => void; onClose: () => void }) {
198
+ const { agent } = useLive();
199
+ const [body, setBody] = useState("");
200
+ const [error, setError] = useState<string>();
201
+ const [busy, setBusy] = useState(false);
202
+ const list = useRef<HTMLDivElement>(null);
203
+ const input = useRef<HTMLTextAreaElement>(null);
204
+ useEffect(() => input.current?.focus(), []);
205
+ // the newest message in view, as in any chat
206
+ useLayoutEffect(() => {
207
+ list.current?.scrollTo(0, list.current.scrollHeight);
208
+ }, [chat.length]);
209
+ useEffect(() => {
210
+ const t = input.current;
211
+ if (t) {
212
+ t.style.height = "auto";
213
+ t.style.height = `${t.scrollHeight}px`;
214
+ }
215
+ }, [body]);
216
+ const [quote, dropQuote] = useSelection();
217
+ const send = async () => {
218
+ const text = body.trim();
219
+ if (!text || busy) return;
220
+ setBusy(true);
221
+ const e = await sendChat(text, [...refs, ...(quote ? [quote] : [])]);
222
+ setBusy(false);
223
+ setError(e);
224
+ if (!e) {
225
+ setBody("");
226
+ dropQuote();
227
+ setRefs([]);
228
+ }
229
+ };
230
+ const last = [...chat].reverse().find((c) => c.by === "reviewer");
231
+ const where = pr.number ? "box" : "local";
232
+ return (
233
+ <div className={`chat${docked ? " docked" : ""}`} id="agent-chat" role={docked ? "complementary" : "dialog"} aria-label="Chat with the agent">
234
+ <div className="chat-head">
235
+ <b>Agent</b>
236
+ <span className="chat-where" title={pr.number ? "The agent in the review's box" : `Your own agent, ${agent.listening ? "listening" : "not listening"}`}>({where})</span>
237
+ <span className="grow" />
238
+ <button className="icon-btn" onClick={onDock} title={docked ? "Float over the page" : "Dock next to the page"} aria-label={docked ? "Float the chat over the page" : "Dock the chat next to the page"}>{docked ? Icon.float : Icon.dock}</button>
239
+ <button className="icon-btn" onClick={onClose} title={docked ? "Close" : "Close (Esc)"} aria-label="Close the chat">{Icon.cross}</button>
240
+ </div>
241
+ <div className="chat-list" ref={list}>
242
+ {chat.length === 0 && <p className="chat-empty">Ask about the change, the tour, or anything on this page. {pr.number ? "Ask it to change something and it commits and pushes, as Fix it does." : "Your agent answers here."}</p>}
243
+ {chat.map((c, i) => (
244
+ <div key={c.id} className={`msg ${c.by}`}>
245
+ {/* the page, when it is not the one the last message came from */}
246
+ {c.by === "reviewer" && c.topic?.label && c.topic.label !== chat.slice(0, i).reverse().find((x) => x.by === "reviewer")?.topic?.label && <span className="msg-from">{c.topic.label}</span>}
247
+ <div className="msg-body">
248
+ {c.quotes?.map((q, i) => <QuoteCard key={i} quote={q} />)}
249
+ <Body text={c.body} />
250
+ </div>
251
+ {c.commit && <span className="msg-from mono">{c.commit.slice(0, 7)}</span>}
252
+ </div>
253
+ ))}
254
+ {last && (last.status === "sent" || last.status === "working") && <div className="msg agent typing" aria-live="polite"><Sailing />{last.status === "sent" ? "Sent, waiting for the agent" : "Working on it"}</div>}
255
+ {last?.status === "stopped" && (
256
+ <div className="msg agent stopped">The agent stopped without answering. <button className="link" onClick={async () => setError(await retryComment(last.id))}>Send again</button></div>
257
+ )}
258
+ </div>
259
+ {error && <div className="composer-error chat-error" role="alert">{error}</div>}
260
+ <div className="chat-box" onClick={(e) => e.target === e.currentTarget && input.current?.focus()}>
261
+ <div className="chat-chips">
262
+ <span className="ctx-chip" title={`You are on ${page.label}`}>{Icon.page}<span>{page.short}</span></span>
263
+ {refs.map((q, i) => (
264
+ <span key={`${q.where}${i}`} className="ctx-chip" title={`${q.where ?? "A comment"}\n\n${q.text}`}>
265
+ {Icon.comment}<span>{chipOf(q)}</span>
266
+ <button onClick={() => setRefs(refs.filter((x) => x !== q))} aria-label="Leave this comment out">{Icon.cross}</button>
267
+ </span>
268
+ ))}
269
+ {quote && (
270
+ <span className="ctx-chip" title={quote.where ? `${quote.where}\n\n${quote.text}` : quote.text}>
271
+ {Icon.quote}<span>{chipOf(quote)}</span>
272
+ <button onClick={dropQuote} aria-label="Leave the selection out">{Icon.cross}</button>
273
+ </span>
274
+ )}
275
+ </div>
276
+ <textarea ref={input} rows={1} value={body} placeholder="Message the agent" aria-label="Message" onChange={(e) => setBody(e.target.value)} onKeyDown={(e) => {
277
+ // Enter sends, Shift+Enter is a new line, as in chat apps
278
+ if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); void send(); }
279
+ }} />
280
+ <div className="chat-box-bar">
281
+ <span className="chip-hint">{quote || refs.length ? "" : "Select something on the page, or add a comment, to bring it in"}</span>
282
+ <button className="send" disabled={!body.trim() || busy} onClick={() => void send()} aria-label="Send" title="Send">{Icon.send}</button>
283
+ </div>
284
+ </div>
285
+ </div>
286
+ );
287
+ }
@@ -0,0 +1,178 @@
1
+ // A chapter of the tour: the guide on the left (why, what should happen and what did, the code), the evidence on the right.
2
+ import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
3
+ import { runStep, useLive, type ReviewEvent } from "./live";
4
+ import { ChapterContext, outcomeOf, useChapter, useNav, type Instance } from "./review";
5
+ import { FlowCompare, type FlowDef } from "./flow";
6
+ import { Live } from "./media";
7
+ import { Diff } from "./code";
8
+ import { pr } from "./pr";
9
+ import { FileLine, Icon, Label } from "./ui";
10
+
11
+ // events since Run was last pressed for each step, so a rerun starts clean
12
+ const since: Record<string, number> = {};
13
+
14
+ export function Chapter({
15
+ title, children, step, expect, flow, here, group, evidence,
16
+ }: {
17
+ title: ReactNode;
18
+ // why this chapter matters and what it does, in a sentence or two
19
+ children?: ReactNode;
20
+ // ~/review/steps/<step>.sh, run by the Run button
21
+ step?: string;
22
+ // what should happen on each instance, by instance id
23
+ expect?: Record<string, ReactNode>;
24
+ flow?: FlowDef;
25
+ // the flow node this chapter is about
26
+ here?: string;
27
+ // the group of the diff behind this chapter
28
+ group?: string;
29
+ // what the reviewer looks at, on the right; the live instances when left out
30
+ evidence?: ReactNode;
31
+ }) {
32
+ const { review, chapter, go } = useNav();
33
+ const live = useLive();
34
+ const [, rerender] = useState(0);
35
+ const index = chapter?.index ?? 0, total = review.chapters.length;
36
+ const events = step ? live.events.filter((e) => e.step === step && e.seq > (since[step] ?? 0)) : [];
37
+ const state = step ? live.steps[step] : undefined;
38
+ const g = group ? review.groups.find((x) => x.id === group) : undefined;
39
+ const insts = review.instances.filter((i) => !expect || i.id in expect);
40
+ const prev = index > 0 ? { chapter: review.chapters[index - 1].id } : "overview";
41
+ const next = index < total - 1 ? { chapter: review.chapters[index + 1].id } : "changes";
42
+ const run = () => {
43
+ if (!step) return;
44
+ since[step] = live.events.at(-1)?.seq ?? 0;
45
+ rerender((n) => n + 1);
46
+ runStep(step);
47
+ };
48
+ const file = g && pr.files.find((f) => f.path === g.file);
49
+
50
+ // the code behind the chapter opens in place of the evidence; Escape closes it
51
+ const [code, setCode] = useState(false);
52
+ useEffect(() => {
53
+ if (!code) return;
54
+ const key = (e: KeyboardEvent) => e.key === "Escape" && setCode(false);
55
+ addEventListener("keydown", key);
56
+ return () => removeEventListener("keydown", key);
57
+ }, [code]);
58
+ const status = state?.status === "running" ? ["run", "Running"] : state?.status === "failed" ? ["fail", "Step failed"] : state?.status === "done" ? ["done", "Done"] : undefined;
59
+ const results = insts.map((i) => ({ inst: i, outcome: outcomeOf(events, i.id) }));
60
+ const ran = results.some((r) => r.outcome);
61
+
62
+ return (
63
+ <ChapterContext.Provider value={{ step, events, instances: insts }}>
64
+ <section className="view chapter">
65
+ <aside className="guide">
66
+ <div>
67
+ <h1 className="h">{title}</h1>
68
+ {children && <Clamped>{children}</Clamped>}
69
+ </div>
70
+ {expect && (
71
+ <div>
72
+ <div className="label-row">
73
+ <Label>{ran ? "What happened" : "What should happen"}</Label>
74
+ {step && <button className="btn sm" id="run" onClick={run} disabled={state?.status === "running"}>{Icon.play} {state?.status === "running" ? "Running" : state ? "Run again" : "Run"}</button>}
75
+ </div>
76
+ <div className="cols" style={{ gridTemplateColumns: `repeat(${results.length}, minmax(0, 1fr))` }}>
77
+ {results.map(({ inst, outcome }) => (
78
+ <div key={inst.id} className="col">
79
+ <b>{inst.label}</b>
80
+ {outcome
81
+ ? <><p className={`got ${outcome.kind}`}>{outcome.overlay?.title ?? outcome.text}</p><Clamped lines={2} className="should">Should: {expect[inst.id]}</Clamped></>
82
+ : <p>{expect[inst.id]}</p>}
83
+ </div>
84
+ ))}
85
+ </div>
86
+ {step && (events.length > 0 || status) && <Events events={events} status={status} />}
87
+ {state?.status === "failed" && state.output && <pre className="failed">{state.output}</pre>}
88
+ </div>
89
+ )}
90
+ {flow && (
91
+ <div>
92
+ <Label>Where</Label>
93
+ <FlowCompare def={flow} here={here} instances={insts} events={events} />
94
+ </div>
95
+ )}
96
+ {g && file && <button className="code-link" aria-pressed={code} onClick={() => setCode(!code)}><FileLine path={g.file} add={file.add} del={file.del} /></button>}
97
+ <div className="pager">
98
+ <button onClick={() => go(prev)}><kbd>K</kbd><span>{index > 0 ? review.chapters[index - 1].title : "Overview"}</span></button>
99
+ <button onClick={() => go(next)}><span>{index < total - 1 ? review.chapters[index + 1].title : "Changes"}</span><kbd>J</kbd></button>
100
+ </div>
101
+ </aside>
102
+ {evidence ? <div className="evidence scroll" hidden={code}>{evidence}</div> : <div className="evidence" hidden={code}><Live /></div>}
103
+ {code && g && file && (
104
+ <div className="evidence code-view">
105
+ <div className="file">
106
+ <div className="fh">
107
+ <FileLine path={g.file} add={file.add} del={file.del} />
108
+ <button className="btn ghost sm" onClick={() => go({ group: g.id })}>{Icon.code} In all changes</button>
109
+ <button className="icon-btn" onClick={() => setCode(false)} title="Close (Esc)" aria-label="Close the code">{Icon.cross}</button>
110
+ </div>
111
+ <Diff file={g.file} lines={g.lines} />
112
+ </div>
113
+ </div>
114
+ )}
115
+ </section>
116
+ </ChapterContext.Provider>
117
+ );
118
+ }
119
+
120
+ // text cut to a few lines, with More when it runs longer
121
+ function Clamped({ children, lines = 3, className = "prose" }: { children: ReactNode; lines?: number; className?: string }) {
122
+ const ref = useRef<HTMLDivElement>(null);
123
+ const [long, setLong] = useState(false);
124
+ const [open, setOpen] = useState(false);
125
+ useLayoutEffect(() => { const el = ref.current; if (el && !open) setLong(el.scrollHeight > el.clientHeight + 1); }, [children, open]);
126
+ return (
127
+ <>
128
+ <div ref={ref} className={`${className}${open ? "" : " clamp"}`} style={open ? undefined : { WebkitLineClamp: lines }}>{children}</div>
129
+ {long && <button className="show-more" onClick={() => setOpen(!open)}>{open ? "Less" : "More"}</button>}
130
+ </>
131
+ );
132
+ }
133
+
134
+ // what the step reported, as a card row that opens in place, with the run's status
135
+ function Events({ events, status }: { events: ReviewEvent[]; status?: string[] }) {
136
+ const [open, setOpen] = useState(false);
137
+ return (
138
+ <div className="events" data-open={open || undefined}>
139
+ <button className="events-head" aria-expanded={open} onClick={() => setOpen(!open)} disabled={!events.length}>
140
+ <span className="acc-chev">{Icon.chevron}</span>
141
+ <span className="events-title">Events <span className="c">{events.length}</span></span>
142
+ {status && <span className={`chip ${status[0]}`}>{status[1]}</span>}
143
+ </button>
144
+ <div className="acc-body" inert={!open}>
145
+ <div><div className="events-lines">{events.map((e) => <div key={e.seq}><b>{e.instance}</b>{e.text}</div>)}</div></div>
146
+ </div>
147
+ </div>
148
+ );
149
+ }
150
+
151
+ // what each instance reported, for results that are not visible in the app: test output, a script's result, an API response.
152
+ // One row per instance: its result as a heading, the details (the overlay body) under it at full width, so long output
153
+ // like an email reads as a page; instances still running say so. Before the first run it is one line saying what Run does.
154
+ export function Outcome({ ids, empty = "Press Run to see what each instance reports." }: { ids?: string[]; empty?: ReactNode }) {
155
+ const { step, events, instances } = useChapter();
156
+ const running = useLive().steps[step ?? ""]?.status === "running";
157
+ const list = instances.filter((i) => !ids || ids.includes(i.id)).map((i) => ({ inst: i, o: outcomeOf(events, i.id) }));
158
+ if (!running && !list.some((r) => r.o)) return <div className="outcomes"><p className="oe">{empty}</p></div>;
159
+ return (
160
+ <div className="outcomes">
161
+ {list.map(({ inst, o }) => {
162
+ const title = o ? o.overlay?.title ?? o.text : undefined;
163
+ const body = o?.overlay?.body?.trim();
164
+ const status = o ? o.overlay?.status ?? o.kind : running ? "wait" : "none";
165
+ return (
166
+ <div key={inst.id} className={`outcome ${status}`}>
167
+ <div className="oh">
168
+ <span className="lamp" />
169
+ <span className="on">{inst.label}</span>
170
+ <span className="ot">{title ?? (running ? "Running" : "Nothing reported")}</span>
171
+ </div>
172
+ {body && body !== title && (body.includes("\n") || body.length > 160 ? <pre className="ob">{body}</pre> : <p className="os">{body}</p>)}
173
+ </div>
174
+ );
175
+ })}
176
+ </div>
177
+ );
178
+ }