bottleship 0.1.0-prototype.1 → 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.
- package/CHANGELOG.md +10 -0
- package/README.md +118 -117
- package/dist/src/boxes.d.ts +41 -39
- package/dist/src/boxes.js +181 -179
- package/dist/src/cli.js +298 -84
- package/dist/src/config.d.ts +3 -6
- package/dist/src/config.js +19 -85
- package/dist/src/init.d.ts +2 -4
- package/dist/src/init.js +92 -228
- package/dist/src/local.d.ts +8 -0
- package/dist/src/local.js +24 -0
- package/dist/src/onboard.d.ts +5 -0
- package/dist/src/onboard.js +25 -0
- package/dist/src/paths.d.ts +0 -1
- package/dist/src/paths.js +0 -1
- package/dist/src/provider.d.ts +16 -0
- package/dist/src/provider.js +81 -0
- package/dist/src/repo.d.ts +21 -0
- package/dist/src/repo.js +75 -0
- package/dist/src/review/comments.d.ts +66 -0
- package/dist/src/review/comments.js +344 -0
- package/dist/src/review/contract.d.ts +82 -0
- package/dist/src/review/contract.js +189 -0
- package/dist/src/review/github.d.ts +120 -0
- package/dist/src/review/github.js +160 -0
- package/dist/src/review/local.d.ts +14 -0
- package/dist/src/review/local.js +70 -0
- package/dist/src/review/loop.d.ts +12 -0
- package/dist/src/review/loop.js +119 -0
- package/dist/src/review/pr.d.ts +14 -0
- package/dist/src/review/pr.js +68 -0
- package/dist/src/review/prompt.d.ts +15 -0
- package/dist/src/review/prompt.js +48 -0
- package/dist/src/review/serve.d.ts +1 -0
- package/dist/src/review/serve.js +8 -0
- package/dist/src/review/server.d.ts +14 -0
- package/dist/src/review/server.js +484 -0
- package/dist/src/review/setting-up.d.ts +4 -0
- package/dist/src/review/setting-up.js +74 -0
- package/dist/src/review/start.d.ts +32 -0
- package/dist/src/review/start.js +140 -0
- package/dist/src/review/target.d.ts +20 -0
- package/dist/src/review/target.js +66 -0
- package/dist/src/review/template.d.ts +3 -0
- package/dist/src/review/template.js +13 -0
- package/dist/src/review/terminal.d.ts +4 -0
- package/dist/src/review/terminal.js +91 -0
- package/dist/src/types.d.ts +1 -71
- package/dist/src/ui.d.ts +6 -1
- package/dist/src/ui.js +22 -23
- package/dist/src/util.d.ts +11 -0
- package/dist/src/util.js +40 -0
- package/package.json +7 -14
- package/presets/agents/claude.json +1 -5
- package/presets/agents/codex.json +1 -10
- package/presets/agents/cursor.json +1 -9
- package/presets/agents/opencode.json +1 -14
- package/presets/providers/docker/create +12 -0
- package/presets/providers/docker/destroy +2 -0
- package/presets/providers/docker/exec +2 -0
- package/presets/providers/docker/list +4 -0
- package/presets/providers/docker/status +2 -0
- package/presets/providers/docker/url +4 -0
- package/review-app/KIT.md +106 -0
- package/review-app/WRITING.md +93 -0
- package/review-app/examples/rply-16.tsx +98 -0
- package/review-app/index.html +15 -0
- package/review-app/package-lock.json +2539 -0
- package/review-app/package.json +24 -0
- package/review-app/playground/fixtures/excalidraw-12602/data/box.json +1 -0
- package/review-app/playground/fixtures/excalidraw-12602/data/pr.diff +91 -0
- package/review-app/playground/fixtures/excalidraw-12602/data/pr.json +62 -0
- package/review-app/playground/fixtures/excalidraw-12602/data/review-comments.json +44 -0
- package/review-app/playground/fixtures/excalidraw-12602/events.json +1 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-ar-zoom.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-en-US-zoom.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-IL-zoom.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-dark.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-hover.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-page.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-tip.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-zen.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-ar-zoom.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-en-US-zoom.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-IL-zoom.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-dark.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-hover.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-page.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-tip.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-zen.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/instances.json +1 -0
- package/review-app/playground/fixtures/excalidraw-12602/review.tsx +103 -0
- package/review-app/playground/fixtures/rply-16/data/box.json +1 -0
- package/review-app/playground/fixtures/rply-16/data/hooks.diff +28 -0
- package/review-app/playground/fixtures/rply-16/data/pr.diff +114 -0
- package/review-app/playground/fixtures/rply-16/data/pr.json +68 -0
- package/review-app/playground/fixtures/rply-16/data/review-comments.json +1 -0
- package/review-app/playground/fixtures/rply-16/events.json +156 -0
- package/review-app/playground/fixtures/rply-16/instances.json +1 -0
- package/review-app/playground/fixtures/rply-16/review.tsx +98 -0
- package/review-app/playground/gallery.tsx +127 -0
- package/review-app/playground/index.html +15 -0
- package/review-app/playground/main.tsx +26 -0
- package/review-app/playground/mock.ts +215 -0
- package/review-app/playground/playground.css +18 -0
- package/review-app/playground/vite.config.ts +32 -0
- package/review-app/scripts/check.cjs +47 -0
- package/review-app/scripts/clip.cjs +104 -0
- package/review-app/scripts/shot.cjs +66 -0
- package/review-app/src/App.tsx +132 -0
- package/review-app/src/data/box.json +1 -0
- package/review-app/src/data/hooks.diff +28 -0
- package/review-app/src/data/pr.diff +114 -0
- package/review-app/src/data/pr.json +68 -0
- package/review-app/src/data/review-comments.json +1 -0
- package/review-app/src/kit/agent.tsx +287 -0
- package/review-app/src/kit/chapter.tsx +178 -0
- package/review-app/src/kit/code.tsx +217 -0
- package/review-app/src/kit/comments.tsx +377 -0
- package/review-app/src/kit/finish.tsx +274 -0
- package/review-app/src/kit/flow.tsx +184 -0
- package/review-app/src/kit/index.ts +10 -0
- package/review-app/src/kit/kit.css +696 -0
- package/review-app/src/kit/live.ts +91 -0
- package/review-app/src/kit/media.tsx +348 -0
- package/review-app/src/kit/overview.tsx +161 -0
- package/review-app/src/kit/pr.ts +100 -0
- package/review-app/src/kit/pullrequest.tsx +298 -0
- package/review-app/src/kit/review.ts +74 -0
- package/review-app/src/kit/theme.ts +30 -0
- package/review-app/src/kit/ui.tsx +127 -0
- package/review-app/src/main.tsx +6 -0
- package/review-app/src/review.tsx +22 -0
- package/review-app/tsconfig.json +17 -0
- package/review-app/vite.config.ts +12 -0
- package/dist/presets/providers/command.d.ts +0 -3
- package/dist/presets/providers/command.js +0 -39
- package/dist/presets/providers/daytona.d.ts +0 -3
- package/dist/presets/providers/daytona.js +0 -173
- package/dist/presets/providers/docker.d.ts +0 -3
- package/dist/presets/providers/docker.js +0 -31
- package/dist/presets/providers/e2b.d.ts +0 -3
- package/dist/presets/providers/e2b.js +0 -124
- package/dist/presets/providers/exe.d.ts +0 -3
- package/dist/presets/providers/exe.js +0 -28
- package/dist/presets/providers/http.example.d.ts +0 -3
- package/dist/presets/providers/http.example.js +0 -47
- package/dist/presets/providers/local.d.ts +0 -3
- package/dist/presets/providers/local.js +0 -52
- package/dist/presets/providers/modal.d.ts +0 -3
- package/dist/presets/providers/modal.js +0 -117
- package/dist/presets/providers/ssh.d.ts +0 -3
- package/dist/presets/providers/ssh.js +0 -49
- package/dist/presets/providers/util.d.ts +0 -13
- package/dist/presets/providers/util.js +0 -102
- package/presets/providers/command.ts +0 -43
- package/presets/providers/daytona.ts +0 -181
- package/presets/providers/docker.ts +0 -38
- package/presets/providers/e2b.ts +0 -130
- package/presets/providers/exe.ts +0 -35
- package/presets/providers/http.example.ts +0 -64
- package/presets/providers/local.ts +0 -56
- package/presets/providers/modal.ts +0 -121
- package/presets/providers/ssh.ts +0 -54
- 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
|
+
}
|