@sealant/mend 0.8.0 → 0.10.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/dist/dashboard.js CHANGED
@@ -1,28 +1,22 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "@opentui/react/jsx-runtime";
2
2
  import { CliRenderEvents, createCliRenderer } from "@opentui/core";
3
3
  import { createRoot, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react";
4
- import { QueryClient, QueryClientProvider, useQuery, useQueryClient } from "@tanstack/react-query";
4
+ import { QueryClient, QueryClientProvider, useMutation, useQuery, useQueryClient, } from "@tanstack/react-query";
5
5
  import { useEffect, useRef, useState } from "react";
6
6
  import { reviewTargetForSession } from "./review-workflow.js";
7
7
  import { ReviewScreen } from "./review.js";
8
- import { cwdFacts, HARNESS_COMMANDS, LIVE_STATUSES, matchProjectByCwd } from "./shared.js";
8
+ import { cwdFacts, HARNESS_COMMANDS, isPendingId, LIVE_STATUSES, matchProjectByCwd, pendingId, } from "./shared.js";
9
9
  import { openUrl } from "./terminal.js";
10
- import { AMBER, BG, COBALT, FAINT, GREEN, INK, MUTED, RED, RULE, WASH } from "./tui-theme.js";
11
- const STATUS_GLYPH = {
12
- starting: "○",
13
- running: "●",
14
- waiting: "●",
15
- idle: "●",
16
- completed: "●",
17
- failed: "●",
18
- stopped: "○",
19
- };
10
+ import { COBALT, FAINT, INK, INK_2, MUTED, RED, RULE, WASH } from "./tui-theme.js";
11
+ import { createSseParser, eventFamilies } from "./workbench-events.js";
12
+ // Near-mono on purpose: a status is a word, and only an observed failure
13
+ // earns color. Live states read at full ink; settled ones recede.
20
14
  const STATUS_COLOR = {
21
- starting: COBALT,
22
- running: COBALT,
23
- waiting: AMBER,
24
- idle: COBALT,
25
- completed: GREEN,
15
+ starting: INK_2,
16
+ running: INK,
17
+ waiting: INK_2,
18
+ idle: INK_2,
19
+ completed: FAINT,
26
20
  failed: RED,
27
21
  stopped: FAINT,
28
22
  };
@@ -59,59 +53,61 @@ const fetchWorkbench = async (ctx) => {
59
53
  servicesBySession,
60
54
  };
61
55
  };
62
- const itemKey = (item) => item.kind === "session"
63
- ? item.session.id
64
- : item.kind === "project"
65
- ? item.project.id
66
- : String(item.harness);
67
- const itemHeight = (view) => (view.kind === "sessions" ? 2 : 1);
68
- const homeView = (cwd, data) => {
69
- const home = matchProjectByCwd(data.projects, cwdFacts(cwd));
70
- return home === undefined ? { kind: "projects" } : { kind: "sessions", projectId: home.id };
56
+ // ─── optimistic cache surgery: pure Workbench → Workbench ───────────────────
57
+ /** Apply `f` to every session row in every project detail. */
58
+ const mapWorkbenchSessions = (data, f) => ({
59
+ ...data,
60
+ details: new Map([...data.details].map(([id, detail]) => [id, { ...detail, sessions: detail.sessions.map(f) }])),
61
+ });
62
+ const mapProjectSessions = (data, projectId, f) => {
63
+ const detail = data.details.get(projectId);
64
+ if (detail === undefined)
65
+ return data;
66
+ const details = new Map(data.details);
67
+ details.set(projectId, { ...detail, sessions: f(detail.sessions) });
68
+ return { ...data, details };
71
69
  };
72
- const deriveItems = (view, data) => {
73
- if (data === undefined)
70
+ const prependSession = (data, projectId, session) => mapProjectSessions(data, projectId, (sessions) => [session, ...sessions]);
71
+ const replaceSession = (data, projectId, oldId, session) => mapProjectSessions(data, projectId, (sessions) => sessions.map((candidate) => (candidate.id === oldId ? session : candidate)));
72
+ const removeSession = (data, projectId, sessionId) => mapProjectSessions(data, projectId, (sessions) => sessions.filter((candidate) => candidate.id !== sessionId));
73
+ const deriveProjects = (data) => (data?.projects ?? []).map((project) => {
74
+ const detail = data?.details.get(project.id);
75
+ const sessions = detail?.sessions ?? [];
76
+ const live = sessions.filter((s) => LIVE_STATUSES.has(s.status)).length;
77
+ const open = (detail?.annotations ?? []).reduce((sum, a) => sum + a.openComments, 0);
78
+ return { project, total: sessions.length, live, open };
79
+ });
80
+ const deriveSessions = (data, projectId) => {
81
+ if (data === undefined || projectId === null)
74
82
  return [];
75
- if (view.kind === "projects") {
76
- return data.projects.map((project) => {
77
- const detail = data.details.get(project.id);
78
- const sessions = detail?.sessions ?? [];
79
- const live = sessions.filter((s) => LIVE_STATUSES.has(s.status)).length;
80
- const open = (detail?.annotations ?? []).reduce((sum, a) => sum + a.openComments, 0);
81
- return { kind: "project", project, total: sessions.length, live, open };
82
- });
83
- }
84
- if (view.kind === "sessions") {
85
- const detail = data.details.get(view.projectId);
86
- if (detail === undefined)
87
- return [];
88
- return detail.sessions
89
- .toSorted((a, b) => {
90
- const aLive = LIVE_STATUSES.has(a.status) ? 1 : 0;
91
- const bLive = LIVE_STATUSES.has(b.status) ? 1 : 0;
92
- if (aLive !== bLive)
93
- return bLive - aLive;
94
- return b.createdAt.localeCompare(a.createdAt);
95
- })
96
- .map((session) => ({
97
- kind: "session",
98
- session,
99
- annotation: detail.annotations.find((a) => a.sessionId === session.id),
100
- services: data.servicesBySession.get(session.id) ?? [],
101
- }));
102
- }
103
- const resuming = view.session;
83
+ const detail = data.details.get(projectId);
84
+ if (detail === undefined)
85
+ return [];
86
+ return detail.sessions
87
+ .toSorted((a, b) => {
88
+ const aLive = LIVE_STATUSES.has(a.status) ? 1 : 0;
89
+ const bLive = LIVE_STATUSES.has(b.status) ? 1 : 0;
90
+ if (aLive !== bLive)
91
+ return bLive - aLive;
92
+ return b.createdAt.localeCompare(a.createdAt);
93
+ })
94
+ .map((session) => ({
95
+ session,
96
+ annotation: detail.annotations.find((a) => a.sessionId === session.id),
97
+ services: data.servicesBySession.get(session.id) ?? [],
98
+ }));
99
+ };
100
+ /** The picker's rows: resume offers the same harness first, then the crossings. */
101
+ const deriveHarnesses = (resuming) => {
104
102
  if (resuming !== null) {
105
103
  const others = Object.keys(HARNESS_COMMANDS).filter((h) => h !== resuming.harness);
106
104
  return [
107
105
  {
108
- kind: "harness",
109
106
  harness: null,
110
107
  label: resuming.harness,
111
108
  hint: "same harness — native resume, conversation intact",
112
109
  },
113
110
  ...others.map((harness) => ({
114
- kind: "harness",
115
111
  harness,
116
112
  label: harness,
117
113
  hint: harness === "shell"
@@ -121,7 +117,6 @@ const deriveItems = (view, data) => {
121
117
  ];
122
118
  }
123
119
  return Object.keys(HARNESS_COMMANDS).map((harness) => ({
124
- kind: "harness",
125
120
  harness,
126
121
  label: harness,
127
122
  hint: harness === "shell"
@@ -129,53 +124,41 @@ const deriveItems = (view, data) => {
129
124
  : `mend ${harness} — new worktree, recorded session`,
130
125
  }));
131
126
  };
132
- // ─── rows: JSX spans instead of StyledText chunks ───────────────────────────
127
+ // ─── panes and rows ─────────────────────────────────────────────────────────
133
128
  const Gutter = ({ selected }) => (_jsx("span", { fg: selected ? COBALT : FAINT, children: selected ? "▌ " : " " }));
134
- /** Facts joined with dim separators — label, review state, age. */
135
- const Facts = ({ facts }) => {
136
- if (facts.length === 0)
137
- return _jsx("span", { fg: FAINT, children: "\u2014" });
138
- return (_jsx(_Fragment, { children: facts.map((fact, index) => (
139
- // eslint-disable-next-line react/no-array-index-key -- order is the identity here
140
- _jsxs("span", { children: [index > 0 ? _jsx("span", { fg: FAINT, children: " \u00B7 " }) : null, fact] }, index))) }));
129
+ /**
130
+ * A drawn pane: rounded border, a title in the frame, cobalt when the
131
+ * keyboard lives here. Everything the dashboard shows sits in one of these.
132
+ */
133
+ const Pane = ({ title, focused, children, width, height, grow, }) => (_jsx("box", { border: true, borderStyle: "rounded", borderColor: focused ? COBALT : RULE, title: ` ${title} `, titleAlignment: "left", backgroundColor: "transparent", ...(width === undefined ? {} : { width, flexShrink: 0, minHeight: 0 }), ...(height === undefined ? {} : { height, flexShrink: 0 }), ...(grow === true ? { flexGrow: 1, flexShrink: 1, minHeight: 0, minWidth: 0 } : {}), flexDirection: "column", children: children }));
134
+ const paneScrollStyle = {
135
+ rootOptions: { backgroundColor: "transparent", border: false },
136
+ wrapperOptions: { backgroundColor: "transparent" },
137
+ viewportOptions: { backgroundColor: "transparent" },
138
+ contentOptions: { backgroundColor: "transparent" },
141
139
  };
140
+ const ProjectRow = ({ item, selected, nameWidth, }) => (_jsx("box", { height: 1, flexShrink: 0, backgroundColor: selected ? WASH : "transparent", children: _jsxs("text", { height: 1, bg: "transparent", children: [_jsx(Gutter, { selected: selected }), _jsx("span", { fg: INK, children: item.project.name.padEnd(nameWidth) }), _jsx("span", { fg: item.total === 0 ? FAINT : MUTED, children: ` ${item.total}` }), item.live > 0 ? _jsx("span", { fg: MUTED, children: ` · ${item.live} live` }) : null, item.open > 0 ? _jsx("span", { fg: MUTED, children: ` · ${item.open}` }) : null] }) }));
142
141
  const SessionRow = ({ item, selected, }) => {
143
- const { session, annotation, services } = item;
142
+ const { session, annotation } = item;
144
143
  const color = STATUS_COLOR[session.status] ?? MUTED;
145
- const glyph = STATUS_GLYPH[session.status] ?? "·";
146
- const facts = [];
147
- const age = timeAgo(session.createdAt);
148
- if (age !== "")
149
- facts.push(_jsx("span", { fg: FAINT, children: age }));
150
- // What runs right now: name :port → host port, observed state colored.
151
- for (const service of services.slice(0, 2)) {
152
- facts.push(_jsx("span", { fg: service.status === "reachable" ? GREEN : AMBER, children: `${service.label ?? service.id.slice(0, 6)} :${service.workspacePort ?? "?"}${service.protocol === "udp" ? "u" : ""}→${service.hostPort ?? "?"} ${service.status}` }));
153
- }
154
- if (services.length > 2) {
155
- facts.push(_jsx("span", { fg: FAINT, children: `+${services.length - 2} services` }));
156
- }
157
- if (session.label !== null)
158
- facts.push(_jsx("span", { fg: MUTED, children: session.label }));
159
- if (annotation !== undefined && annotation.openComments > 0) {
160
- facts.push(_jsxs("span", { fg: AMBER, children: [annotation.openComments, " open comment", annotation.openComments === 1 ? "" : "s"] }));
161
- }
162
- if (annotation !== undefined && annotation.pendingFollowUp) {
163
- facts.push(_jsx("span", { fg: AMBER, children: "follow-up pending" }));
164
- }
165
- if (session.summary !== null && facts.length < 3) {
166
- facts.push(_jsx("span", { fg: FAINT, children: session.summary.split("\n")[0]?.slice(0, 60) ?? "" }));
167
- }
168
- return (_jsxs("box", { height: 2, flexDirection: "column", flexShrink: 0, backgroundColor: selected ? WASH : "transparent", children: [_jsxs("text", { height: 1, bg: "transparent", children: [_jsx(Gutter, { selected: selected }), _jsxs("span", { fg: color, children: [glyph, " "] }), _jsx("span", { fg: INK, children: session.harness.padEnd(9) }), _jsx("span", { fg: FAINT, children: session.id.slice(0, 8) }), _jsx("span", { fg: color, children: ` ${session.status.padEnd(10)}` }), _jsx("span", { fg: MUTED, children: session.branch })] }), _jsxs("text", { height: 1, bg: "transparent", children: [_jsx(Gutter, { selected: selected }), _jsx("span", { children: " " }), _jsx(Facts, { facts: facts })] })] }));
169
- };
170
- const ProjectRow = ({ item, selected, nameWidth, branchWidth, }) => {
171
- const { project, total, live, open } = item;
172
- const counts = total === 0 ? "no sessions" : `${total} session${total === 1 ? "" : "s"}`;
173
- const liveText = live > 0 ? ` · ${live} live` : "";
174
- const openText = open > 0 ? ` · ${open} open` : "";
175
- const pad = " ".repeat(Math.max(1, 24 - counts.length - liveText.length - openText.length));
176
- return (_jsx("box", { height: 1, flexShrink: 0, backgroundColor: selected ? WASH : "transparent", children: _jsxs("text", { height: 1, bg: "transparent", children: [_jsx(Gutter, { selected: selected }), _jsx("span", { fg: INK, children: project.name.padEnd(nameWidth) }), _jsx("span", { children: " " }), _jsx("span", { fg: MUTED, children: project.defaultBranch.padEnd(branchWidth) }), _jsx("span", { children: " " }), _jsx("span", { fg: total === 0 ? FAINT : MUTED, children: counts }), liveText === "" ? null : _jsx("span", { fg: COBALT, children: liveText }), openText === "" ? null : _jsx("span", { fg: AMBER, children: openText }), _jsxs("span", { fg: FAINT, children: [pad, project.storePath] })] }) }));
144
+ const age = timeAgo(session.createdAt).replace(" ago", "");
145
+ return (_jsx("box", { height: 1, flexShrink: 0, backgroundColor: selected ? WASH : "transparent", children: _jsxs("text", { height: 1, bg: "transparent", children: [_jsx(Gutter, { selected: selected }), _jsx("span", { fg: INK, children: session.harness.padEnd(9) }), _jsx("span", { fg: FAINT, children: session.id.slice(0, 8) }), _jsx("span", { fg: color, children: ` ${session.status.padEnd(10)}` }), _jsx("span", { fg: FAINT, children: age.padEnd(9) }), annotation !== undefined && annotation.openComments > 0 ? (_jsx("span", { fg: MUTED, children: `${annotation.openComments} open ` })) : null, _jsx("span", { fg: MUTED, children: (session.label ?? "").slice(0, 40) })] }) }));
177
146
  };
178
147
  const HarnessRow = ({ item, selected, }) => (_jsx("box", { height: 1, flexShrink: 0, backgroundColor: selected ? WASH : "transparent", children: _jsxs("text", { height: 1, bg: "transparent", children: [_jsx(Gutter, { selected: selected }), _jsx("span", { fg: INK, children: item.label.padEnd(12) }), _jsx("span", { fg: FAINT, children: item.hint })] }) }));
148
+ /**
149
+ * The detail panel: everything about the selected session that the one-line
150
+ * rows no longer carry — branch and base, services, the review state, the
151
+ * summary. The panes above stay scannable because this panel holds the depth.
152
+ */
153
+ const SessionDetail = ({ item }) => {
154
+ if (item === null) {
155
+ return (_jsx("text", { height: 1, bg: "transparent", fg: FAINT, children: " no session selected — n starts one" }));
156
+ }
157
+ const { session, annotation, services } = item;
158
+ const color = STATUS_COLOR[session.status] ?? MUTED;
159
+ const summary = session.summary?.split("\n")[0] ?? null;
160
+ return (_jsxs(_Fragment, { children: [_jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { children: " " }), _jsx("span", { fg: color, children: session.status }), _jsx("span", { fg: FAINT, children: " \u00B7 " }), _jsx("span", { fg: MUTED, children: session.branch }), _jsxs("span", { fg: FAINT, children: [session.baseSha === "" ? "" : ` vs ${session.baseSha.slice(0, 12)}`, " \u00B7 started", " ", timeAgo(session.createdAt)] })] }), _jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { children: " " }), services.length === 0 ? (_jsx("span", { fg: FAINT, children: "no services running" })) : (services.slice(0, 3).map((service, index) => (_jsxs("span", { children: [index > 0 ? _jsx("span", { fg: FAINT, children: " \u00B7 " }) : null, _jsx("span", { fg: service.status === "reachable" ? INK_2 : MUTED, children: `${service.label ?? service.id.slice(0, 6)} :${service.workspacePort ?? "?"}${service.protocol === "udp" ? "u" : ""}→${service.hostPort ?? "?"} ${service.status}` })] }, service.id)))), services.length > 3 ? _jsx("span", { fg: FAINT, children: ` · +${services.length - 3} more` }) : null] }), _jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { children: " " }), annotation === undefined || annotation.openComments === 0 ? (_jsx("span", { fg: FAINT, children: "no open review comments" })) : (_jsxs("span", { fg: INK_2, children: [annotation.openComments, " open comment", annotation.openComments === 1 ? "" : "s"] })), annotation?.pendingFollowUp === true ? (_jsxs(_Fragment, { children: [_jsx("span", { fg: FAINT, children: " \u00B7 " }), _jsx("span", { fg: INK_2, children: "follow-up pending" })] })) : null, annotation?.changeId != null ? (_jsxs(_Fragment, { children: [_jsx("span", { fg: FAINT, children: " \u00B7 " }), _jsx("span", { fg: MUTED, children: "v reviews the change" })] })) : null] }), _jsx("text", { height: 1, bg: "transparent", fg: summary === null ? FAINT : MUTED, children: ` ${summary ?? "no summary yet"}` })] }));
161
+ };
179
162
  const StatusLine = ({ busy, busyStarted, status, }) => {
180
163
  const [now, setNow] = useState(() => Date.now());
181
164
  // Timers are the one thing React cannot express declaratively: a 1s tick
@@ -196,10 +179,20 @@ const StatusLine = ({ busy, busyStarted, status, }) => {
196
179
  : status !== null && Date.now() - status.at < 5000
197
180
  ? ` ${status.text}`
198
181
  : "";
199
- return (_jsx("text", { height: 1, fg: AMBER, bg: "transparent", children: text }));
182
+ return (_jsx("text", { height: 1, fg: INK_2, bg: "transparent", children: text }));
183
+ };
184
+ const errorText = (error) => error instanceof Error ? error.message : String(error);
185
+ /** Keep a 1-line-per-row selection inside its scrollbox's real viewport. */
186
+ const keepRowVisible = (scroll, index) => {
187
+ if (scroll === null)
188
+ return;
189
+ const viewH = Math.max(1, scroll.viewport.height);
190
+ if (index < scroll.scrollTop)
191
+ scroll.scrollTo(index);
192
+ else if (index + 1 > scroll.scrollTop + viewH)
193
+ scroll.scrollTo(index + 1 - viewH);
200
194
  };
201
- // ─── the app ────────────────────────────────────────────────────────────────
202
- const CHROME_ROWS = 7; // margin + header + margin + 2 borders + status + footer
195
+ const PROJECTS_PANE_WIDTH = 30;
203
196
  const App = ({ ctx, onQuit }) => {
204
197
  const renderer = useRenderer();
205
198
  const queryClient = useQueryClient();
@@ -210,9 +203,11 @@ const App = ({ ctx, onQuit }) => {
210
203
  retry: true,
211
204
  retryDelay: 1000,
212
205
  });
213
- const [navigated, setNavigated] = useState(null);
214
- const view = navigated ?? (data === undefined ? { kind: "projects" } : homeView(ctx.cwd, data));
215
- const [selectedKey, setSelectedKey] = useState(null);
206
+ const [focusState, setFocus] = useState("sessions");
207
+ const [projectKey, setProjectKey] = useState(null);
208
+ const [sessionKey, setSessionKey] = useState(null);
209
+ const [picker, setPicker] = useState(null);
210
+ const [pickerIndex, setPickerIndex] = useState(0);
216
211
  const [busy, setBusy] = useState(null);
217
212
  const [busyStarted, setBusyStarted] = useState(0);
218
213
  const [status, setStatus] = useState(null);
@@ -220,39 +215,39 @@ const App = ({ ctx, onQuit }) => {
220
215
  const [reviewing, setReviewing] = useState(null);
221
216
  /** Set synchronously around attach/launch so keystrokes can't double-fire. */
222
217
  const lockRef = useRef(false);
223
- const items = deriveItems(view, data);
224
- const foundIndex = selectedKey === null ? -1 : items.findIndex((i) => itemKey(i) === selectedKey);
225
- const selectedIndex = foundIndex === -1 ? 0 : foundIndex;
218
+ /** Latest modal state for mutation callbacks — closures there go stale. */
219
+ const modalRef = useRef(false);
220
+ modalRef.current = editing !== null || reviewing !== null || picker !== null;
221
+ const { width: terminalCols, height: terminalRows } = useTerminalDimensions();
222
+ const showProjectsPane = terminalCols >= 72;
223
+ const showDetail = terminalRows >= 18;
224
+ const focus = showProjectsPane ? focusState : "sessions";
225
+ // ── pane data ──
226
+ const projectItems = deriveProjects(data);
227
+ const homeProject = data === undefined ? undefined : matchProjectByCwd(data.projects, cwdFacts(ctx.cwd));
228
+ const projectIndexRaw = projectKey === null ? -1 : projectItems.findIndex((p) => p.project.id === projectKey);
229
+ const projectIndex = projectIndexRaw !== -1
230
+ ? projectIndexRaw
231
+ : Math.max(0, homeProject === undefined
232
+ ? 0
233
+ : projectItems.findIndex((p) => p.project.id === homeProject.id));
234
+ const selectedProject = projectItems[projectIndex] ?? null;
235
+ const sessionItems = deriveSessions(data, selectedProject?.project.id ?? null);
236
+ const sessionIndexRaw = sessionKey === null ? -1 : sessionItems.findIndex((s) => s.session.id === sessionKey);
237
+ const sessionIndex = sessionIndexRaw === -1 ? 0 : sessionIndexRaw;
238
+ const selectedSession = sessionItems[sessionIndex] ?? null;
239
+ const pickerItems = picker === null ? [] : deriveHarnesses(picker.session);
226
240
  const say = (text) => setStatus({ text, at: Date.now() });
227
241
  const refetch = () => void queryClient.invalidateQueries({ queryKey: WORKBENCH_KEY });
228
- const setView = (next) => {
229
- setNavigated(next);
230
- setSelectedKey(null);
231
- };
232
- // Keep the selection on screen — the one imperative escape hatch.
233
- const scrollRef = useRef(null);
234
- const { height: terminalRows } = useTerminalDimensions();
235
- const rowH = itemHeight(view);
242
+ // Keep each pane's selection on screen — the one imperative escape hatch.
243
+ const projectScrollRef = useRef(null);
244
+ const sessionScrollRef = useRef(null);
236
245
  useEffect(() => {
237
- const scroll = scrollRef.current;
238
- if (scroll === null)
239
- return;
240
- const top = selectedIndex * rowH;
241
- const viewH = Math.max(1, terminalRows - CHROME_ROWS - (editing === null ? 0 : 3));
242
- if (top < scroll.scrollTop)
243
- scroll.scrollTo(top);
244
- else if (top + rowH > scroll.scrollTop + viewH)
245
- scroll.scrollTo(top + rowH - viewH);
246
- }, [selectedIndex, rowH, terminalRows, editing]);
247
- const beginBusy = (label) => {
248
- lockRef.current = true;
249
- setBusy(label);
250
- setBusyStarted(Date.now());
251
- };
252
- const endBusy = () => {
253
- lockRef.current = false;
254
- setBusy(null);
255
- };
246
+ keepRowVisible(projectScrollRef.current, projectIndex);
247
+ }, [projectIndex]);
248
+ useEffect(() => {
249
+ keepRowVisible(sessionScrollRef.current, sessionIndex);
250
+ }, [sessionIndex]);
256
251
  const attachFlow = async (session) => {
257
252
  const short = session.id.slice(0, 8);
258
253
  lockRef.current = true;
@@ -285,99 +280,184 @@ const App = ({ ctx, onQuit }) => {
285
280
  : `session settled · ${short}`);
286
281
  refetch();
287
282
  };
288
- const startSession = async (projectId, harness) => {
289
- const argv = HARNESS_COMMANDS[harness];
290
- if (argv === undefined)
283
+ /** Converge on the server's truth once, after the LAST in-flight mutation. */
284
+ const settleRefetch = () => {
285
+ if (queryClient.isMutating() === 1)
286
+ refetch();
287
+ };
288
+ /** The current cache entry, for surgical optimistic edits. */
289
+ const workbench = () => queryClient.getQueryData(WORKBENCH_KEY);
290
+ const patchWorkbench = (f) => {
291
+ const current = workbench();
292
+ if (current !== undefined)
293
+ queryClient.setQueryData(WORKBENCH_KEY, f(current));
294
+ };
295
+ /** Attaching yanks the terminal; only do it unasked when nothing else is open. */
296
+ const attachIfIdle = async (session) => {
297
+ if (lockRef.current || modalRef.current) {
298
+ say(`session ready · ${session.id.slice(0, 8)} — enter attaches`);
291
299
  return;
292
- setView({ kind: "sessions", projectId });
293
- beginBusy(`provisioning ${harness} workspace — a first launch builds the harness image ·`);
294
- try {
295
- const session = await ctx.api("POST", `/projects/${projectId}/sessions`, {
296
- harness,
300
+ }
301
+ await attachFlow(session);
302
+ };
303
+ // A new session appears as a `starting` row the moment enter is pressed;
304
+ // the keyboard stays free while the workspace provisions, and the terminal
305
+ // attaches when the launch answers — unless something else has the screen.
306
+ const launchMutation = useMutation({
307
+ mutationFn: async (vars) => {
308
+ const argv = HARNESS_COMMANDS[vars.harness];
309
+ if (argv === undefined)
310
+ throw new Error(`unknown harness "${vars.harness}"`);
311
+ const session = await ctx.api("POST", `/projects/${vars.projectId}/sessions`, {
312
+ harness: vars.harness,
297
313
  label: null,
298
314
  base: null,
299
315
  });
300
316
  await ctx.api("POST", `/sessions/${session.id}/launch`, { argv });
301
- endBusy();
302
- await attachFlow(session);
303
- }
304
- catch (error) {
305
- endBusy();
306
- say(error instanceof Error ? error.message : String(error));
307
- }
308
- refetch();
317
+ return session;
318
+ },
319
+ onMutate: async (vars) => {
320
+ await queryClient.cancelQueries({ queryKey: WORKBENCH_KEY });
321
+ patchWorkbench((current) => prependSession(current, vars.projectId, {
322
+ id: vars.pendingKey,
323
+ harness: vars.harness,
324
+ label: null,
325
+ branch: "provisioning…",
326
+ baseSha: "",
327
+ status: "starting",
328
+ summary: null,
329
+ createdAt: new Date().toISOString(),
330
+ }));
331
+ setSessionKey(vars.pendingKey);
332
+ setBusy(`provisioning ${vars.harness} workspace — a first launch builds the harness image ·`);
333
+ setBusyStarted(Date.now());
334
+ },
335
+ onError: (error, vars) => {
336
+ patchWorkbench((current) => removeSession(current, vars.projectId, vars.pendingKey));
337
+ setBusy(null);
338
+ say(errorText(error));
339
+ },
340
+ onSuccess: async (session, vars) => {
341
+ patchWorkbench((current) => replaceSession(current, vars.projectId, vars.pendingKey, session));
342
+ setSessionKey(session.id);
343
+ setBusy(null);
344
+ await attachIfIdle(session);
345
+ },
346
+ onSettled: settleRefetch,
347
+ });
348
+ const resumeMutation = useMutation({
349
+ mutationFn: (vars) => ctx.api("POST", `/sessions/${vars.session.id}/resume`, {
350
+ harness: vars.harness,
351
+ }),
352
+ onMutate: async (vars) => {
353
+ await queryClient.cancelQueries({ queryKey: WORKBENCH_KEY });
354
+ patchWorkbench((current) => mapWorkbenchSessions(current, (session) => session.id === vars.session.id ? { ...session, status: "starting" } : session));
355
+ setSessionKey(vars.session.id);
356
+ setBusy(`resuming ${vars.session.id.slice(0, 8)} — a fresh workspace restores the saved state ·`);
357
+ setBusyStarted(Date.now());
358
+ },
359
+ onError: (error) => {
360
+ setBusy(null);
361
+ say(errorText(error));
362
+ refetch();
363
+ },
364
+ onSuccess: async (resumed, vars) => {
365
+ patchWorkbench((current) => replaceSession(current, vars.projectId, vars.session.id, resumed));
366
+ setSessionKey(resumed.id);
367
+ setBusy(null);
368
+ await attachIfIdle(resumed);
369
+ },
370
+ onSettled: settleRefetch,
371
+ });
372
+ // The label lands in the row before the server answers; an error puts the
373
+ // truth back on the next refetch.
374
+ const renameMutation = useMutation({
375
+ mutationFn: (vars) => ctx.api("POST", `/sessions/${vars.session.id}/label`, { label: vars.label }),
376
+ onMutate: async (vars) => {
377
+ await queryClient.cancelQueries({ queryKey: WORKBENCH_KEY });
378
+ patchWorkbench((current) => mapWorkbenchSessions(current, (session) => session.id === vars.session.id ? { ...session, label: vars.label } : session));
379
+ say(vars.label === null
380
+ ? `label cleared · ${vars.session.id.slice(0, 8)}`
381
+ : `labeled · ${vars.label}`);
382
+ },
383
+ onError: (error) => {
384
+ say(errorText(error));
385
+ refetch();
386
+ },
387
+ onSettled: settleRefetch,
388
+ });
389
+ const startSession = (projectId, harness) => {
390
+ setFocus("sessions");
391
+ launchMutation.mutate({ projectId, harness, pendingKey: pendingId() });
309
392
  };
310
- const resumeSession = async (projectId, session, harness) => {
311
- setView({ kind: "sessions", projectId });
312
- setSelectedKey(session.id);
313
- beginBusy(`resuming ${session.id.slice(0, 8)} — a fresh workspace restores the saved state ·`);
314
- try {
315
- const resumed = await ctx.api("POST", `/sessions/${session.id}/resume`, {
316
- harness,
317
- });
318
- endBusy();
319
- await attachFlow(resumed);
320
- }
321
- catch (error) {
322
- endBusy();
323
- say(error instanceof Error ? error.message : String(error));
324
- }
325
- refetch();
393
+ const resumeSession = (projectId, session, harness) => {
394
+ setFocus("sessions");
395
+ resumeMutation.mutate({ projectId, session, harness });
326
396
  };
327
- const submitRename = async (session, value) => {
397
+ const submitRename = (session, value) => {
328
398
  setEditing(null);
329
399
  const label = value.trim() === "" ? null : value.trim();
330
- try {
331
- await ctx.api("POST", `/sessions/${session.id}/label`, { label });
332
- say(label === null ? `label cleared · ${session.id.slice(0, 8)}` : `labeled · ${label}`);
333
- }
334
- catch (error) {
335
- say(error instanceof Error ? error.message : String(error));
336
- }
337
- refetch();
400
+ renameMutation.mutate({ session, label });
401
+ };
402
+ const openPicker = (session) => {
403
+ setPicker({ session });
404
+ setPickerIndex(0);
338
405
  };
339
406
  const moveSelection = (delta) => {
340
- if (items.length === 0)
407
+ if (picker !== null) {
408
+ setPickerIndex((current) => Math.max(0, Math.min(pickerItems.length - 1, current + delta)));
409
+ return;
410
+ }
411
+ if (focus === "projects") {
412
+ if (projectItems.length === 0)
413
+ return;
414
+ const next = Math.max(0, Math.min(projectItems.length - 1, projectIndex + delta));
415
+ const item = projectItems[next];
416
+ if (item !== undefined) {
417
+ setProjectKey(item.project.id);
418
+ setSessionKey(null);
419
+ }
341
420
  return;
342
- const next = Math.max(0, Math.min(items.length - 1, selectedIndex + delta));
343
- const item = items[next];
421
+ }
422
+ if (sessionItems.length === 0)
423
+ return;
424
+ const next = Math.max(0, Math.min(sessionItems.length - 1, sessionIndex + delta));
425
+ const item = sessionItems[next];
344
426
  if (item !== undefined)
345
- setSelectedKey(itemKey(item));
427
+ setSessionKey(item.session.id);
346
428
  };
347
429
  const activate = () => {
348
- const item = items[selectedIndex];
349
- if (item === undefined)
350
- return;
351
- if (item.kind === "project") {
352
- setView({ kind: "sessions", projectId: item.project.id });
353
- return;
354
- }
355
- if (item.kind === "session" && view.kind === "sessions") {
356
- if (LIVE_STATUSES.has(item.session.status)) {
357
- void attachFlow(item.session);
430
+ if (picker !== null) {
431
+ const choice = pickerItems[pickerIndex];
432
+ const projectId = selectedProject?.project.id;
433
+ if (choice === undefined || projectId === undefined)
434
+ return;
435
+ setPicker(null);
436
+ if (picker.session === null) {
437
+ if (choice.harness !== null)
438
+ startSession(projectId, choice.harness);
358
439
  }
359
440
  else {
360
- setView({ kind: "picker", projectId: view.projectId, session: item.session });
441
+ resumeSession(projectId, picker.session, choice.harness);
361
442
  }
362
443
  return;
363
444
  }
364
- if (item.kind === "harness" && view.kind === "picker") {
365
- const { projectId, session } = view;
366
- if (session === null) {
367
- if (item.harness !== null)
368
- void startSession(projectId, item.harness);
369
- }
370
- else {
371
- void resumeSession(projectId, session, item.harness);
372
- }
445
+ if (focus === "projects") {
446
+ setFocus("sessions");
447
+ return;
448
+ }
449
+ const item = selectedSession;
450
+ if (item === null)
451
+ return;
452
+ if (isPendingId(item.session.id)) {
453
+ say("still provisioning — the row fills in when the workspace answers");
454
+ }
455
+ else if (LIVE_STATUSES.has(item.session.status)) {
456
+ void attachFlow(item.session);
457
+ }
458
+ else {
459
+ openPicker(item.session);
373
460
  }
374
- };
375
- /** `-`, backspace, or vim `h`: one level up. */
376
- const goUp = () => {
377
- if (view.kind === "sessions")
378
- setView({ kind: "projects" });
379
- if (view.kind === "picker")
380
- setView({ kind: "sessions", projectId: view.projectId });
381
461
  };
382
462
  useKeyboard((key) => {
383
463
  if (reviewing !== null)
@@ -392,6 +472,26 @@ const App = ({ ctx, onQuit }) => {
392
472
  setEditing(null);
393
473
  return;
394
474
  }
475
+ if (picker !== null) {
476
+ switch (key.name) {
477
+ case "down":
478
+ case "j":
479
+ return moveSelection(1);
480
+ case "up":
481
+ case "k":
482
+ return moveSelection(-1);
483
+ case "return":
484
+ case "linefeed":
485
+ case "l":
486
+ return activate();
487
+ case "escape":
488
+ case "q":
489
+ case "h":
490
+ return setPicker(null);
491
+ default:
492
+ return;
493
+ }
494
+ }
395
495
  switch (key.name) {
396
496
  case "q":
397
497
  return onQuit();
@@ -403,41 +503,50 @@ const App = ({ ctx, onQuit }) => {
403
503
  return moveSelection(-1);
404
504
  case "return":
405
505
  case "linefeed":
406
- case "l":
407
506
  return activate();
507
+ case "l":
508
+ case "right":
509
+ if (focus === "projects")
510
+ setFocus("sessions");
511
+ return;
512
+ case "h":
513
+ case "left":
408
514
  case "-":
409
515
  case "backspace":
410
- case "h":
411
- return goUp();
412
- case "escape":
413
- if (view.kind === "picker")
414
- setView({ kind: "sessions", projectId: view.projectId });
516
+ if (showProjectsPane)
517
+ setFocus("projects");
518
+ return;
519
+ case "tab":
520
+ if (showProjectsPane)
521
+ setFocus(focus === "projects" ? "sessions" : "projects");
415
522
  return;
416
523
  case "n":
417
- if (view.kind === "sessions") {
418
- setView({ kind: "picker", projectId: view.projectId, session: null });
419
- }
524
+ if (selectedProject !== null)
525
+ openPicker(null);
420
526
  return;
421
527
  case "e": {
422
- const item = items[selectedIndex];
423
- if (view.kind === "sessions" && item !== undefined && item.kind === "session") {
528
+ const item = selectedSession;
529
+ if (item !== null && !isPendingId(item.session.id))
424
530
  setEditing(item.session);
425
- }
426
531
  return;
427
532
  }
428
533
  case "o": {
429
- const item = items[selectedIndex];
430
- if (item !== undefined && item.kind === "session") {
534
+ const item = selectedSession;
535
+ if (item !== null && !isPendingId(item.session.id)) {
431
536
  openUrl(`${ctx.config.url}/sessions/${item.session.id}`);
432
537
  say(`opened · ${ctx.config.url}/sessions/${item.session.id.slice(0, 8)}…`);
433
538
  }
434
539
  return;
435
540
  }
436
541
  case "v": {
437
- const item = items[selectedIndex];
438
- if (view.kind !== "sessions" || item === undefined || item.kind !== "session")
542
+ const item = selectedSession;
543
+ if (item === null)
544
+ return;
545
+ if (isPendingId(item.session.id)) {
546
+ say("still provisioning — nothing to review yet");
439
547
  return;
440
- const target = reviewTargetForSession(item.session, item.annotation, detail?.project.name ?? "project");
548
+ }
549
+ const target = reviewTargetForSession(item.session, item.annotation, selectedProject?.project.name ?? "project");
441
550
  if (target === null) {
442
551
  say("this session has no reviewable change yet");
443
552
  return;
@@ -453,43 +562,27 @@ const App = ({ ctx, onQuit }) => {
453
562
  return;
454
563
  }
455
564
  });
456
- // ── chrome derived from the view ──
457
- const projects = data?.projects ?? [];
458
- const liveTotal = data === undefined
459
- ? 0
460
- : [...data.details.values()]
461
- .flatMap((d) => d.sessions)
462
- .filter((s) => LIVE_STATUSES.has(s.status)).length;
463
- const detail = view.kind === "projects" ? undefined : data?.details.get(view.projectId);
464
- const nameWidth = Math.max(...projects.map((p) => p.name.length), 4);
465
- const branchWidth = Math.max(...projects.map((p) => p.defaultBranch.length), 4);
466
- let title;
467
- let headerContext;
468
- let footerText;
469
- if (view.kind === "projects") {
470
- title = " projects ";
471
- headerContext = (_jsxs(_Fragment, { children: [_jsxs("span", { fg: MUTED, children: [projects.length, " project", projects.length === 1 ? "" : "s"] }), _jsx("span", { fg: FAINT, children: " \u00B7 " }), _jsxs("span", { fg: liveTotal > 0 ? COBALT : FAINT, children: [liveTotal, " live"] })] }));
472
- footerText = " ↑↓/jk move · enter/l open project · r refresh · q quit";
473
- }
474
- else if (view.kind === "sessions") {
475
- const name = detail?.project.name ?? "?";
476
- const live = (detail?.sessions ?? []).filter((s) => LIVE_STATUSES.has(s.status)).length;
477
- title = ` sessions — ${name} `;
478
- headerContext = (_jsxs(_Fragment, { children: [_jsx("span", { fg: COBALT, children: name }), _jsxs("span", { fg: FAINT, children: [" ", detail?.project.defaultBranch ?? ""] }), _jsx("span", { fg: FAINT, children: " \u00B7 " }), _jsxs("span", { fg: live > 0 ? COBALT : FAINT, children: [live, " live"] })] }));
479
- footerText =
480
- " ↑↓/jk move · enter/l attach/resume · v review in TUI · o web · n new · e rename · h/- projects · q quit";
481
- }
482
- else {
483
- const forSession = view.session;
484
- title =
485
- forSession === null
486
- ? " new session — pick a harness "
487
- : ` resume ${forSession.id.slice(0, 8)} — pick a harness `;
488
- headerContext = (_jsx("span", { fg: MUTED, children: forSession === null
489
- ? "a fresh worktree, recorded from the first keystroke"
490
- : "same worktree, restored state" }));
491
- footerText = " ↑↓ move · enter start · esc/h back";
492
- }
565
+ // ── chrome ──
566
+ const liveTotal = projectItems.reduce((sum, item) => sum + item.live, 0);
567
+ const nameWidth = Math.min(16, Math.max(...projectItems.map((p) => p.project.name.length), 4));
568
+ const sessionsTitle = selectedProject === null
569
+ ? "sessions"
570
+ : `sessions — ${selectedProject.project.name}${selectedProject.live > 0 ? ` · ${selectedProject.live} live` : ""}`;
571
+ const detailTitle = selectedSession === null
572
+ ? "session"
573
+ : `session — ${selectedSession.session.harness} ${selectedSession.session.id.slice(0, 8)}`;
574
+ const pickerTitle = picker === null
575
+ ? ""
576
+ : picker.session === null
577
+ ? "new session — pick a harness"
578
+ : `resume ${picker.session.id.slice(0, 8)} — pick a harness`;
579
+ const footerText = editing !== null
580
+ ? " enter save · esc cancel"
581
+ : picker !== null
582
+ ? " ↑↓ move · enter start · esc cancel"
583
+ : focus === "projects"
584
+ ? " ↑↓ move · enter/l sessions · ⇥ panes · r refresh · q quit"
585
+ : " ↑↓ move · enter attach/resume · n new · v review · e rename · o web · h/⇥ projects · q quit";
493
586
  const loadFailure = data === undefined && failureReason !== null
494
587
  ? failureReason instanceof Error
495
588
  ? failureReason.message
@@ -501,20 +594,14 @@ const App = ({ ctx, onQuit }) => {
501
594
  refetch();
502
595
  }, onQuit: onQuit }));
503
596
  }
504
- return (_jsxs("box", { flexGrow: 1, flexDirection: "column", backgroundColor: BG, children: [_jsxs("box", { height: 1, marginTop: 1, flexDirection: "row", justifyContent: "space-between", backgroundColor: "transparent", children: [_jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { fg: INK, children: " mend" }), _jsx("span", { children: " " }), headerContext] }), _jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: `${ctx.config.url} ` })] }), _jsx("box", { border: true, borderStyle: "rounded", borderColor: RULE, title: title, titleAlignment: "left", backgroundColor: BG, flexGrow: 1, flexShrink: 1, minHeight: 0, marginTop: 1, children: _jsxs("scrollbox", { ref: scrollRef, flexGrow: 1, flexShrink: 1, minHeight: 0, style: {
505
- rootOptions: { backgroundColor: BG, border: false },
506
- wrapperOptions: { backgroundColor: BG },
507
- viewportOptions: { backgroundColor: BG },
508
- contentOptions: { backgroundColor: BG },
509
- }, children: [items.map((item, index) => item.kind === "session" ? (_jsx(SessionRow, { item: item, selected: index === selectedIndex }, itemKey(item))) : item.kind === "project" ? (_jsx(ProjectRow, { item: item, selected: index === selectedIndex, nameWidth: nameWidth, branchWidth: branchWidth }, itemKey(item))) : (_jsx(HarnessRow, { item: item, selected: index === selectedIndex }, itemKey(item)))), view.kind === "sessions" && data !== undefined && items.length === 0 ? (_jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: " no sessions yet — n starts one" })) : null, loadFailure !== null ? (_jsx("text", { height: 1, fg: AMBER, bg: "transparent", children: ` ${loadFailure} — retrying` })) : null] }) }), editing === null ? null : (_jsx("box", { border: true, borderStyle: "rounded", borderColor: COBALT, title: ` label — ${editing.harness} ${editing.id.slice(0, 8)} `, titleAlignment: "left", backgroundColor: BG, height: 3, flexShrink: 0, children: _jsx("input", { focused: true, value: editing.label ?? "", placeholder: "a few words for what this session is doing (empty clears)", backgroundColor: BG, focusedBackgroundColor: BG, textColor: INK, focusedTextColor: INK, placeholderColor: FAINT, cursorColor: INK, flexGrow: 1, onSubmit: (value) => {
597
+ return (_jsxs("box", { flexGrow: 1, flexDirection: "column", backgroundColor: "transparent", children: [_jsxs("box", { height: 1, flexDirection: "row", justifyContent: "space-between", children: [_jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { fg: INK, children: " mend" }), _jsxs("span", { fg: MUTED, children: [" ", projectItems.length, " project", projectItems.length === 1 ? "" : "s"] }), _jsx("span", { fg: FAINT, children: " \u00B7 " }), _jsxs("span", { fg: liveTotal > 0 ? MUTED : FAINT, children: [liveTotal, " live"] })] }), _jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: `${ctx.config.url} ` })] }), _jsxs("box", { flexGrow: 1, flexShrink: 1, minHeight: 0, flexDirection: "row", children: [showProjectsPane ? (_jsx(Pane, { title: "projects", focused: focus === "projects", width: PROJECTS_PANE_WIDTH, children: _jsxs("scrollbox", { ref: projectScrollRef, flexGrow: 1, flexShrink: 1, minHeight: 0, style: paneScrollStyle, children: [projectItems.map((item, index) => (_jsx(ProjectRow, { item: item, selected: index === projectIndex, nameWidth: nameWidth }, item.project.id))), data !== undefined && projectItems.length === 0 ? (_jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: " none adopted yet" })) : null] }) })) : null, _jsx(Pane, { title: sessionsTitle, focused: focus === "sessions", grow: true, children: _jsxs("scrollbox", { ref: sessionScrollRef, flexGrow: 1, flexShrink: 1, minHeight: 0, style: paneScrollStyle, children: [sessionItems.map((item, index) => (_jsx(SessionRow, { item: item, selected: index === sessionIndex }, item.session.id))), data !== undefined && sessionItems.length === 0 ? (_jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: " no sessions yet — n starts one" })) : null, loadFailure !== null ? (_jsx("text", { height: 1, fg: INK_2, bg: "transparent", children: ` ${loadFailure} — retrying` })) : null] }) })] }), picker !== null ? (_jsx(Pane, { title: pickerTitle, focused: true, height: 2 + pickerItems.length, children: pickerItems.map((item, index) => (_jsx(HarnessRow, { item: item, selected: index === pickerIndex }, String(item.harness)))) })) : editing !== null ? (_jsx("box", { border: true, borderStyle: "rounded", borderColor: COBALT, title: ` label — ${editing.harness} ${editing.id.slice(0, 8)} `, titleAlignment: "left", backgroundColor: "transparent", height: 3, flexShrink: 0, children: _jsx("input", { focused: true, value: editing.label ?? "", placeholder: "a few words for what this session is doing (empty clears)", backgroundColor: "transparent", focusedBackgroundColor: "transparent", textColor: INK, focusedTextColor: INK, placeholderColor: FAINT, cursorColor: INK, flexGrow: 1, onSubmit: (value) => {
510
598
  if (typeof value === "string")
511
- void submitRename(editing, value);
512
- } }) })), _jsx(StatusLine, { busy: busy, busyStarted: busyStarted, status: status }), _jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: editing === null ? footerText : " enter save · esc cancel" })] }));
599
+ submitRename(editing, value);
600
+ } }) })) : showDetail ? (_jsx(Pane, { title: detailTitle, focused: false, height: 6, children: _jsx(SessionDetail, { item: selectedSession }) })) : null, _jsx(StatusLine, { busy: busy, busyStarted: busyStarted, status: status }), _jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: footerText })] }));
513
601
  };
514
602
  // ─── entry ──────────────────────────────────────────────────────────────────
515
603
  export const runDashboard = async (ctx) => {
516
604
  const renderer = await createCliRenderer({ exitOnCtrlC: false });
517
- renderer.setBackgroundColor(BG);
518
605
  const queryClient = new QueryClient();
519
606
  const controller = new AbortController();
520
607
  let eventTimer = null;
@@ -531,14 +618,33 @@ export const runDashboard = async (ctx) => {
531
618
  // ^[[..;..:.u key-release sequences.
532
619
  process.nextTick(() => renderer.destroy());
533
620
  };
534
- // The SSE stream invalidates the cache from outside React — any traffic
535
- // (heartbeats included) means state may have moved, so re-read it.
536
- const scheduleInvalidate = () => {
537
- if (eventTimer !== null)
538
- clearTimeout(eventTimer);
539
- eventTimer = setTimeout(() => {
540
- void queryClient.invalidateQueries();
541
- }, 250);
621
+ // The SSE stream invalidates the cache from outside React. Frames are
622
+ // parsed and each pointer event stales only its query families — the
623
+ // 25-second heartbeat and per-record-line progress invalidate nothing, so
624
+ // an idle dashboard makes no requests and a busy session doesn't turn push
625
+ // into a refetch loop. The flush waits out in-flight mutations: their
626
+ // onSettled refetch converges on the same truth without a mid-write
627
+ // snapshot clobbering an optimistic row.
628
+ const pendingFamilies = new Set();
629
+ const flushInvalidations = () => {
630
+ eventTimer = null;
631
+ if (queryClient.isMutating() > 0) {
632
+ eventTimer = setTimeout(flushInvalidations, 250);
633
+ return;
634
+ }
635
+ const families = [...pendingFamilies];
636
+ pendingFamilies.clear();
637
+ for (const family of families) {
638
+ void queryClient.invalidateQueries({ queryKey: [family] });
639
+ }
640
+ };
641
+ const scheduleInvalidate = (families) => {
642
+ if (families.length === 0)
643
+ return;
644
+ for (const family of families)
645
+ pendingFamilies.add(family);
646
+ if (eventTimer === null)
647
+ eventTimer = setTimeout(flushInvalidations, 250);
542
648
  };
543
649
  const watch = async () => {
544
650
  while (!controller.signal.aborted) {
@@ -553,17 +659,26 @@ export const runDashboard = async (ctx) => {
553
659
  if (!response.ok || response.body === null)
554
660
  throw new Error(String(response.status));
555
661
  const reader = response.body.getReader();
662
+ const parser = createSseParser();
663
+ const decoder = new TextDecoder();
556
664
  for (;;) {
557
- const { done } = await reader.read();
665
+ const { done, value } = await reader.read();
558
666
  if (done)
559
667
  break;
560
- scheduleInvalidate();
668
+ for (const payload of parser.push(decoder.decode(value, { stream: true }))) {
669
+ scheduleInvalidate(eventFamilies(payload));
670
+ }
561
671
  }
562
672
  }
563
673
  catch {
564
674
  if (controller.signal.aborted)
565
675
  return;
566
676
  }
677
+ // A dropped stream may have swallowed events — re-read once on reconnect.
678
+ pendingFamilies.add("workbench").add("review");
679
+ if (eventTimer === null && !controller.signal.aborted) {
680
+ eventTimer = setTimeout(flushInvalidations, 250);
681
+ }
567
682
  await new Promise((resolve) => setTimeout(resolve, 2000));
568
683
  }
569
684
  };