@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/README.md +17 -9
- package/dist/dashboard.js +397 -282
- package/dist/login.js +240 -0
- package/dist/main.js +184 -134
- package/dist/review.js +172 -87
- package/dist/shared.js +8 -0
- package/dist/tui-theme.js +33 -22
- package/dist/workbench-events.js +71 -0
- package/package.json +1 -1
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 {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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:
|
|
22
|
-
running:
|
|
23
|
-
waiting:
|
|
24
|
-
idle:
|
|
25
|
-
completed:
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
const
|
|
70
|
-
|
|
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
|
|
73
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
// ───
|
|
127
|
+
// ─── panes and rows ─────────────────────────────────────────────────────────
|
|
133
128
|
const Gutter = ({ selected }) => (_jsx("span", { fg: selected ? COBALT : FAINT, children: selected ? "▌ " : " " }));
|
|
134
|
-
/**
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
142
|
+
const { session, annotation } = item;
|
|
144
143
|
const color = STATUS_COLOR[session.status] ?? MUTED;
|
|
145
|
-
const
|
|
146
|
-
|
|
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:
|
|
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
|
-
|
|
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 [
|
|
214
|
-
const
|
|
215
|
-
const [
|
|
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
|
-
|
|
224
|
-
const
|
|
225
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
if (
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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 =
|
|
311
|
-
|
|
312
|
-
|
|
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 =
|
|
397
|
+
const submitRename = (session, value) => {
|
|
328
398
|
setEditing(null);
|
|
329
399
|
const label = value.trim() === "" ? null : value.trim();
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
}
|
|
334
|
-
|
|
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 (
|
|
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
|
-
|
|
343
|
-
|
|
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
|
-
|
|
427
|
+
setSessionKey(item.session.id);
|
|
346
428
|
};
|
|
347
429
|
const activate = () => {
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
441
|
+
resumeSession(projectId, picker.session, choice.harness);
|
|
361
442
|
}
|
|
362
443
|
return;
|
|
363
444
|
}
|
|
364
|
-
if (
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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 (
|
|
418
|
-
|
|
419
|
-
}
|
|
524
|
+
if (selectedProject !== null)
|
|
525
|
+
openPicker(null);
|
|
420
526
|
return;
|
|
421
527
|
case "e": {
|
|
422
|
-
const item =
|
|
423
|
-
if (
|
|
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 =
|
|
430
|
-
if (item !==
|
|
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 =
|
|
438
|
-
if (
|
|
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
|
-
|
|
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
|
|
457
|
-
const
|
|
458
|
-
const
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
const
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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:
|
|
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
|
-
|
|
512
|
-
} }) })), _jsx(StatusLine, { busy: busy, busyStarted: busyStarted, status: status }), _jsx("text", { height: 1, fg: FAINT, bg: "transparent", children:
|
|
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
|
|
535
|
-
//
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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
|
-
|
|
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
|
};
|