@sealant/mend 0.14.0 → 0.15.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/dashboard-model.js +297 -0
- package/dist/dashboard.js +538 -181
- package/dist/main.js +166 -8
- package/dist/shared.js +32 -1
- package/dist/tui-theme.js +5 -0
- package/dist/workbench-events.js +1 -0
- package/package.json +1 -1
package/dist/dashboard.js
CHANGED
|
@@ -3,12 +3,12 @@ import { CliRenderEvents, createCliRenderer } from "@opentui/core";
|
|
|
3
3
|
import { createRoot, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react";
|
|
4
4
|
import { QueryClient, QueryClientProvider, useMutation, useQuery, useQueryClient, } from "@tanstack/react-query";
|
|
5
5
|
import { useEffect, useRef, useState } from "react";
|
|
6
|
+
import { deriveHarnesses, deriveProjects, advanceFromBase, deriveRows, deriveWorktrees, filterBranches, foldGroupStatus, labelIsIdentity, rowKeyOf, worktreeDisplayName, fetchWorkbench, liveShellOf, mapWorkbenchSessions, markSessionStopped, removeWorktreeGroup, prependSession, removeSession, replaceSession, WORKBENCH_KEY, } from "./dashboard-model.js";
|
|
6
7
|
import { reviewTargetForSession } from "./review-workflow.js";
|
|
7
8
|
import { ReviewScreen } from "./review.js";
|
|
8
9
|
import { cwdFacts, HARNESS_COMMANDS, isPendingId, LIVE_STATUSES, matchProjectByCwd, normalizeProjectName, pendingId, } from "./shared.js";
|
|
9
10
|
import { openUrl } from "./terminal.js";
|
|
10
|
-
import { COBALT, FAINT, INK, INK_2, MUTED, RED, RULE, WASH } from "./tui-theme.js";
|
|
11
|
-
import { createSseParser, eventFamilies } from "./workbench-events.js";
|
|
11
|
+
import { COBALT, FAINT, INK, INK_2, MUTED, RED, RULE, SURFACE, WASH } from "./tui-theme.js";
|
|
12
12
|
// Near-mono on purpose: a status is a word, and only an observed failure
|
|
13
13
|
// earns color. Live states read at full ink; settled ones recede.
|
|
14
14
|
const STATUS_COLOR = {
|
|
@@ -34,96 +34,7 @@ const timeAgo = (iso) => {
|
|
|
34
34
|
return `${hours}h ago`;
|
|
35
35
|
return `${Math.floor(hours / 24)}d ago`;
|
|
36
36
|
};
|
|
37
|
-
|
|
38
|
-
const fetchWorkbench = async (ctx) => {
|
|
39
|
-
const projects = await ctx.api("GET", "/projects");
|
|
40
|
-
const [fetched, services] = await Promise.all([
|
|
41
|
-
Promise.all(projects.map((project) => ctx.api("GET", `/projects/${project.id}`))),
|
|
42
|
-
ctx.api("GET", "/services"),
|
|
43
|
-
]);
|
|
44
|
-
const servicesBySession = new Map();
|
|
45
|
-
for (const service of services) {
|
|
46
|
-
const bucket = servicesBySession.get(service.sessionId) ?? [];
|
|
47
|
-
bucket.push(service);
|
|
48
|
-
servicesBySession.set(service.sessionId, bucket);
|
|
49
|
-
}
|
|
50
|
-
return {
|
|
51
|
-
projects,
|
|
52
|
-
details: new Map(fetched.map((detail) => [detail.project.id, detail])),
|
|
53
|
-
servicesBySession,
|
|
54
|
-
};
|
|
55
|
-
};
|
|
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 };
|
|
69
|
-
};
|
|
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)
|
|
82
|
-
return [];
|
|
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) => {
|
|
102
|
-
if (resuming !== null) {
|
|
103
|
-
const others = Object.keys(HARNESS_COMMANDS).filter((h) => h !== resuming.harness);
|
|
104
|
-
return [
|
|
105
|
-
{
|
|
106
|
-
harness: null,
|
|
107
|
-
label: resuming.harness,
|
|
108
|
-
hint: "same harness — native resume, conversation intact",
|
|
109
|
-
},
|
|
110
|
-
...others.map((harness) => ({
|
|
111
|
-
harness,
|
|
112
|
-
label: harness,
|
|
113
|
-
hint: harness === "shell"
|
|
114
|
-
? "a bash in the worktree — resume either agent from inside"
|
|
115
|
-
: "the conversation crosses as a distilled prompt",
|
|
116
|
-
})),
|
|
117
|
-
];
|
|
118
|
-
}
|
|
119
|
-
return Object.keys(HARNESS_COMMANDS).map((harness) => ({
|
|
120
|
-
harness,
|
|
121
|
-
label: harness,
|
|
122
|
-
hint: harness === "shell"
|
|
123
|
-
? "a plain bash session — new worktree, recorded"
|
|
124
|
-
: `mend ${harness} — new worktree, recorded session`,
|
|
125
|
-
}));
|
|
126
|
-
};
|
|
37
|
+
import { createSseParser, eventFamilies } from "./workbench-events.js";
|
|
127
38
|
// ─── panes and rows ─────────────────────────────────────────────────────────
|
|
128
39
|
const Gutter = ({ selected }) => (_jsx("span", { fg: selected ? COBALT : FAINT, children: selected ? "▌ " : " " }));
|
|
129
40
|
/**
|
|
@@ -138,23 +49,39 @@ const paneScrollStyle = {
|
|
|
138
49
|
contentOptions: { backgroundColor: "transparent" },
|
|
139
50
|
};
|
|
140
51
|
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] }) }));
|
|
52
|
+
const ProcessLines = ({ processes, services, indent, }) => (_jsxs(_Fragment, { children: [processes.map((process) => (_jsx("box", { height: 1, flexShrink: 0, backgroundColor: "transparent", children: _jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { fg: FAINT, children: indent }), _jsx("span", { fg: INK_2, children: process.kind === "shell"
|
|
53
|
+
? (process.label ?? "shell")
|
|
54
|
+
: (process.harness ?? process.kind) }), _jsxs("span", { fg: FAINT, children: [process.kind === "agent-protocol"
|
|
55
|
+
? " · protocol"
|
|
56
|
+
: process.kind === "agent-external"
|
|
57
|
+
? " · external"
|
|
58
|
+
: "", ` ${process.status}`] })] }) }, process.id))), services.map((service) => (_jsx("box", { height: 1, flexShrink: 0, backgroundColor: "transparent", children: _jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { fg: FAINT, children: indent }), _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 ?? "?"}` }), _jsx("span", { fg: FAINT, children: ` ${service.status}` })] }) }, service.id)))] }));
|
|
141
59
|
/**
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
* leads every row; the harness is a fact about it, not its name.
|
|
60
|
+
* A lone-session worktree, combined into one row (today's density): the
|
|
61
|
+
* worktree name leads, everything live inside hangs underneath as facts.
|
|
145
62
|
*/
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
63
|
+
const CombinedRow = ({ group, item, selected, }) => {
|
|
64
|
+
const { session, annotation, services, processes } = item;
|
|
65
|
+
const color = STATUS_COLOR[session.status] ?? MUTED;
|
|
66
|
+
const age = timeAgo(session.createdAt).replace(" ago", "");
|
|
67
|
+
const trailingLabel = labelIsIdentity(session) ? "" : (session.label ?? "");
|
|
68
|
+
return (_jsxs("box", { flexShrink: 0, flexDirection: "column", backgroundColor: "transparent", children: [_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: group.name.slice(0, 30).padEnd(31) }), _jsx("span", { fg: color, children: session.status.padEnd(10) }), _jsx("span", { fg: FAINT, children: age.padEnd(9) }), _jsx("span", { fg: MUTED, children: session.harness.padEnd(10) }), annotation !== undefined && annotation.openComments > 0 ? (_jsx("span", { fg: MUTED, children: `${annotation.openComments} open ` })) : null, _jsx("span", { fg: FAINT, children: trailingLabel.slice(0, 40) })] }) }), _jsx(ProcessLines, { processes: processes, services: services, indent: " └ " })] }));
|
|
69
|
+
};
|
|
70
|
+
/** A shared worktree's header: the place, its folded status, its member facts. */
|
|
71
|
+
const WorktreeHeaderRow = ({ group, selected, }) => {
|
|
72
|
+
const folded = foldGroupStatus(group);
|
|
73
|
+
const color = group.live > 0 ? (STATUS_COLOR[folded] ?? MUTED) : FAINT;
|
|
74
|
+
const age = timeAgo(group.sessions.at(-1)?.session.createdAt ?? group.createdAt).replace(" ago", "");
|
|
75
|
+
const open = group.annotation?.openComments ?? 0;
|
|
76
|
+
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: group.name.slice(0, 30).padEnd(31) }), _jsx("span", { fg: color, children: (group.live > 0 ? folded : "settled").padEnd(10) }), _jsx("span", { fg: FAINT, children: age.padEnd(9) }), _jsx("span", { fg: MUTED, children: `${group.sessions.length} sessions` }), open > 0 ? _jsx("span", { fg: MUTED, children: ` · ${open} open` }) : null] }) }));
|
|
77
|
+
};
|
|
78
|
+
/** One conversation inside a shared worktree — the label is its identity. */
|
|
79
|
+
const SessionChildRow = ({ item, selected, }) => {
|
|
80
|
+
const { session, services, processes } = item;
|
|
154
81
|
const color = STATUS_COLOR[session.status] ?? MUTED;
|
|
155
82
|
const age = timeAgo(session.createdAt).replace(" ago", "");
|
|
156
|
-
const
|
|
157
|
-
return (_jsxs("box", { flexShrink: 0, flexDirection: "column", backgroundColor: "transparent", children: [_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:
|
|
83
|
+
const name = session.label ?? `session ${session.id.slice(0, 8)}`;
|
|
84
|
+
return (_jsxs("box", { flexShrink: 0, flexDirection: "column", backgroundColor: "transparent", children: [_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: FAINT, children: " └ " }), _jsx("span", { fg: INK, children: name.slice(0, 26).padEnd(27) }), _jsx("span", { fg: color, children: session.status.padEnd(10) }), _jsx("span", { fg: FAINT, children: age.padEnd(9) }), _jsx("span", { fg: MUTED, children: session.harness.padEnd(10) })] }) }), _jsx(ProcessLines, { processes: processes, services: services, indent: " └ " })] }));
|
|
158
85
|
};
|
|
159
86
|
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 })] }) }));
|
|
160
87
|
/**
|
|
@@ -218,6 +145,7 @@ const App = ({ ctx, onQuit }) => {
|
|
|
218
145
|
});
|
|
219
146
|
const [focusState, setFocus] = useState("sessions");
|
|
220
147
|
const [projectKey, setProjectKey] = useState(null);
|
|
148
|
+
/** A row key (`wt:<id>` | `s:<id>`), so selection survives regrouping. */
|
|
221
149
|
const [sessionKey, setSessionKey] = useState(null);
|
|
222
150
|
const [picker, setPicker] = useState(null);
|
|
223
151
|
const [pickerIndex, setPickerIndex] = useState(0);
|
|
@@ -227,15 +155,24 @@ const App = ({ ctx, onQuit }) => {
|
|
|
227
155
|
const [editing, setEditing] = useState(null);
|
|
228
156
|
/** Session id a stop is armed against; the second press fires it. */
|
|
229
157
|
const [stopArmed, setStopArmed] = useState(null);
|
|
230
|
-
/**
|
|
231
|
-
|
|
232
|
-
|
|
158
|
+
/**
|
|
159
|
+
* The whole worktree creation is ONE modal: name, base (fuzzy over the
|
|
160
|
+
* project's branches), harness — every step visible, the active one
|
|
161
|
+
* expanded. Enter advances, esc steps back (cancels from the name).
|
|
162
|
+
*/
|
|
163
|
+
const [creating, setCreating] = useState(null);
|
|
164
|
+
/**
|
|
165
|
+
* The cwd is a git repo the store doesn't know: offer to adopt it, once
|
|
166
|
+
* per dashboard run. Enter adopts, esc declines and stays quiet.
|
|
167
|
+
*/
|
|
168
|
+
const [adoptOffer, setAdoptOffer] = useState(null);
|
|
169
|
+
const adoptOfferDecided = useRef(false);
|
|
233
170
|
const [reviewing, setReviewing] = useState(null);
|
|
234
171
|
/** Set synchronously around attach/launch so keystrokes can't double-fire. */
|
|
235
172
|
const lockRef = useRef(false);
|
|
236
173
|
/** Latest modal state for mutation callbacks — closures there go stale. */
|
|
237
174
|
const modalRef = useRef(false);
|
|
238
|
-
modalRef.current = editing !== null || reviewing !== null || picker !== null;
|
|
175
|
+
modalRef.current = editing !== null || reviewing !== null || picker !== null || creating !== null;
|
|
239
176
|
const { width: terminalCols, height: terminalRows } = useTerminalDimensions();
|
|
240
177
|
const showProjectsPane = terminalCols >= 72;
|
|
241
178
|
const showDetail = terminalRows >= 18;
|
|
@@ -250,13 +187,45 @@ const App = ({ ctx, onQuit }) => {
|
|
|
250
187
|
? 0
|
|
251
188
|
: projectItems.findIndex((p) => p.project.id === homeProject.id));
|
|
252
189
|
const selectedProject = projectItems[projectIndex] ?? null;
|
|
253
|
-
const
|
|
254
|
-
const
|
|
255
|
-
const
|
|
256
|
-
const
|
|
190
|
+
const worktreeGroups = deriveWorktrees(data, selectedProject?.project.id ?? null);
|
|
191
|
+
const rows = deriveRows(worktreeGroups);
|
|
192
|
+
const rowIndexRaw = sessionKey === null ? -1 : rows.findIndex((row) => rowKeyOf(row) === sessionKey);
|
|
193
|
+
const rowIndex = rowIndexRaw === -1 ? 0 : rowIndexRaw;
|
|
194
|
+
const selectedRow = rows[rowIndex] ?? null;
|
|
195
|
+
const selectedGroup = selectedRow?.group ?? null;
|
|
196
|
+
// A worktree header still names a concrete conversation for session verbs:
|
|
197
|
+
// the newest live member, else the newest at all.
|
|
198
|
+
const selectedSession = selectedRow === null
|
|
199
|
+
? null
|
|
200
|
+
: selectedRow.kind === "session"
|
|
201
|
+
? selectedRow.item
|
|
202
|
+
: (selectedRow.group.sessions.find((item) => LIVE_STATUSES.has(item.session.status)) ??
|
|
203
|
+
selectedRow.group.sessions[0] ??
|
|
204
|
+
null);
|
|
257
205
|
const pickerItems = picker === null ? [] : deriveHarnesses(picker.session);
|
|
206
|
+
const selectSession = (id) => setSessionKey(`s:${id}`);
|
|
258
207
|
const say = (text) => setStatus({ text, at: Date.now() });
|
|
259
208
|
const refetch = () => void queryClient.invalidateQueries({ queryKey: WORKBENCH_KEY });
|
|
209
|
+
// A repo underfoot that the store has never met: raise the adopt offer the
|
|
210
|
+
// first time the workbench answers and no project matches the cwd.
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
if (data === undefined || adoptOfferDecided.current)
|
|
213
|
+
return;
|
|
214
|
+
if (homeProject !== undefined)
|
|
215
|
+
return;
|
|
216
|
+
const facts = cwdFacts(ctx.cwd);
|
|
217
|
+
if (facts.repoRoot === null)
|
|
218
|
+
return;
|
|
219
|
+
adoptOfferDecided.current = true;
|
|
220
|
+
setAdoptOffer({
|
|
221
|
+
root: facts.repoRoot,
|
|
222
|
+
source: facts.originUrl ?? facts.repoRoot,
|
|
223
|
+
name: normalizeProjectName(facts.repoRoot.split("/").at(-1) ?? "project"),
|
|
224
|
+
modeIndex: 0,
|
|
225
|
+
});
|
|
226
|
+
// homeProject/data identity is enough; ctx.cwd never changes in a run.
|
|
227
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
228
|
+
}, [data, homeProject]);
|
|
260
229
|
// Keep each pane's selection on screen — the one imperative escape hatch.
|
|
261
230
|
const projectScrollRef = useRef(null);
|
|
262
231
|
const sessionScrollRef = useRef(null);
|
|
@@ -264,17 +233,19 @@ const App = ({ ctx, onQuit }) => {
|
|
|
264
233
|
keepRowVisible(projectScrollRef.current, projectIndex);
|
|
265
234
|
}, [projectIndex]);
|
|
266
235
|
useEffect(() => {
|
|
267
|
-
// Rows
|
|
268
|
-
// the scroll target is the
|
|
236
|
+
// Rows vary in height (header = 1; a session row carries its process and
|
|
237
|
+
// service fact lines), so the scroll target is the row's y offset.
|
|
238
|
+
const rowHeight = (row) => row === undefined || row.kind === "worktree"
|
|
239
|
+
? 1
|
|
240
|
+
: 1 + row.item.processes.length + row.item.services.length;
|
|
269
241
|
let top = 0;
|
|
270
|
-
for (const [index,
|
|
271
|
-
if (index ===
|
|
242
|
+
for (const [index, row] of rows.entries()) {
|
|
243
|
+
if (index === rowIndex)
|
|
272
244
|
break;
|
|
273
|
-
top +=
|
|
245
|
+
top += rowHeight(row);
|
|
274
246
|
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
}, [sessionIndex, sessionItems]);
|
|
247
|
+
keepSpanVisible(sessionScrollRef.current, top, rowHeight(rows[rowIndex]));
|
|
248
|
+
}, [rowIndex, rows]);
|
|
278
249
|
const attachFlow = async (session) => {
|
|
279
250
|
const short = session.id.slice(0, 8);
|
|
280
251
|
lockRef.current = true;
|
|
@@ -285,11 +256,21 @@ const App = ({ ctx, onQuit }) => {
|
|
|
285
256
|
outcome = await ctx.attachTty(session.id, session.harness);
|
|
286
257
|
if (outcome === "unavailable") {
|
|
287
258
|
// A live session whose terminal ended (idle: a workspace held open,
|
|
288
|
-
// no PTY behind it). Enter still means "get me in" —
|
|
289
|
-
//
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
259
|
+
// no PTY behind it). Enter still means "get me in" — REJOIN the shell
|
|
260
|
+
// already holding the workspace when one is live; only open a fresh
|
|
261
|
+
// one when nothing is attachable (stacking a new bash per attempt is
|
|
262
|
+
// how a session ends up held open by orphan shells).
|
|
263
|
+
const detail = await ctx.api("GET", `/sessions/${session.id}`);
|
|
264
|
+
const existing = liveShellOf(detail.processes);
|
|
265
|
+
if (existing !== null) {
|
|
266
|
+
process.stdout.write(`no live terminal — rejoining the open shell\n\n`);
|
|
267
|
+
outcome = await ctx.attachTty(session.id, "shell", existing.id);
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
const shell = await ctx.api("POST", `/sessions/${session.id}/shell`);
|
|
271
|
+
process.stdout.write(`no live terminal — opened a shell in the workspace\n\n`);
|
|
272
|
+
outcome = await ctx.attachTty(session.id, "shell", shell.id);
|
|
273
|
+
}
|
|
293
274
|
}
|
|
294
275
|
}
|
|
295
276
|
catch (error) {
|
|
@@ -341,7 +322,7 @@ const App = ({ ctx, onQuit }) => {
|
|
|
341
322
|
harness: vars.harness,
|
|
342
323
|
label: null,
|
|
343
324
|
name: vars.name,
|
|
344
|
-
base:
|
|
325
|
+
base: vars.base,
|
|
345
326
|
});
|
|
346
327
|
await ctx.api("POST", `/sessions/${session.id}/launch`, { argv });
|
|
347
328
|
return session;
|
|
@@ -354,12 +335,12 @@ const App = ({ ctx, onQuit }) => {
|
|
|
354
335
|
label: null,
|
|
355
336
|
branch: vars.name === null ? "provisioning…" : `mend/${vars.name}`,
|
|
356
337
|
baseSha: "",
|
|
357
|
-
baseRef:
|
|
338
|
+
baseRef: vars.base,
|
|
358
339
|
status: "starting",
|
|
359
340
|
summary: null,
|
|
360
341
|
createdAt: new Date().toISOString(),
|
|
361
342
|
}));
|
|
362
|
-
|
|
343
|
+
selectSession(vars.pendingKey);
|
|
363
344
|
setBusy(`provisioning ${vars.harness} workspace — a first launch builds the harness image ·`);
|
|
364
345
|
setBusyStarted(Date.now());
|
|
365
346
|
},
|
|
@@ -370,7 +351,7 @@ const App = ({ ctx, onQuit }) => {
|
|
|
370
351
|
},
|
|
371
352
|
onSuccess: async (session, vars) => {
|
|
372
353
|
patchWorkbench((current) => replaceSession(current, vars.projectId, vars.pendingKey, session));
|
|
373
|
-
|
|
354
|
+
selectSession(session.id);
|
|
374
355
|
setBusy(null);
|
|
375
356
|
await attachIfIdle(session);
|
|
376
357
|
},
|
|
@@ -383,7 +364,7 @@ const App = ({ ctx, onQuit }) => {
|
|
|
383
364
|
onMutate: async (vars) => {
|
|
384
365
|
await queryClient.cancelQueries({ queryKey: WORKBENCH_KEY });
|
|
385
366
|
patchWorkbench((current) => mapWorkbenchSessions(current, (session) => session.id === vars.session.id ? { ...session, status: "starting" } : session));
|
|
386
|
-
|
|
367
|
+
selectSession(vars.session.id);
|
|
387
368
|
setBusy(`resuming ${vars.session.id.slice(0, 8)} — a fresh workspace restores the saved state ·`);
|
|
388
369
|
setBusyStarted(Date.now());
|
|
389
370
|
},
|
|
@@ -394,12 +375,52 @@ const App = ({ ctx, onQuit }) => {
|
|
|
394
375
|
},
|
|
395
376
|
onSuccess: async (resumed, vars) => {
|
|
396
377
|
patchWorkbench((current) => replaceSession(current, vars.projectId, vars.session.id, resumed));
|
|
397
|
-
|
|
378
|
+
selectSession(resumed.id);
|
|
398
379
|
setBusy(null);
|
|
399
380
|
await attachIfIdle(resumed);
|
|
400
381
|
},
|
|
401
382
|
onSettled: settleRefetch,
|
|
402
383
|
});
|
|
384
|
+
const ADOPT_AUTH_MODES = [
|
|
385
|
+
{
|
|
386
|
+
mode: "ambient",
|
|
387
|
+
label: "ambient",
|
|
388
|
+
hint: "the server machine's own git/ssh setup",
|
|
389
|
+
},
|
|
390
|
+
{
|
|
391
|
+
mode: "mend-key",
|
|
392
|
+
label: "mend-key",
|
|
393
|
+
hint: "the machine's Mend deploy key — add its public half on the git host",
|
|
394
|
+
},
|
|
395
|
+
{
|
|
396
|
+
mode: "bridge",
|
|
397
|
+
label: "bridge",
|
|
398
|
+
hint: "your OWN ssh-agent, relayed while `mend keys share` runs here",
|
|
399
|
+
},
|
|
400
|
+
];
|
|
401
|
+
const adoptMutation = useMutation({
|
|
402
|
+
mutationFn: (offer) => ctx.api("POST", "/projects", {
|
|
403
|
+
name: offer.name,
|
|
404
|
+
source: offer.source,
|
|
405
|
+
gitAuthMode: ADOPT_AUTH_MODES[offer.modeIndex]?.mode ?? "ambient",
|
|
406
|
+
}),
|
|
407
|
+
onMutate: (offer) => {
|
|
408
|
+
setAdoptOffer(null);
|
|
409
|
+
setBusy(`adopting ${offer.name} — cloning into the store ·`);
|
|
410
|
+
setBusyStarted(Date.now());
|
|
411
|
+
},
|
|
412
|
+
onError: (error) => {
|
|
413
|
+
setBusy(null);
|
|
414
|
+
say(errorText(error));
|
|
415
|
+
},
|
|
416
|
+
onSuccess: (project) => {
|
|
417
|
+
setBusy(null);
|
|
418
|
+
say(`adopted · ${project.name} — n starts a worktree`);
|
|
419
|
+
setProjectKey(project.id);
|
|
420
|
+
setSessionKey(null);
|
|
421
|
+
},
|
|
422
|
+
onSettled: settleRefetch,
|
|
423
|
+
});
|
|
403
424
|
// The label lands in the row before the server answers; an error puts the
|
|
404
425
|
// truth back on the next refetch.
|
|
405
426
|
const renameMutation = useMutation({
|
|
@@ -423,16 +444,125 @@ const App = ({ ctx, onQuit }) => {
|
|
|
423
444
|
mutationFn: (session) => ctx.api("POST", `/sessions/${session.id}/stop`),
|
|
424
445
|
onMutate: async (session) => {
|
|
425
446
|
await queryClient.cancelQueries({ queryKey: WORKBENCH_KEY });
|
|
426
|
-
|
|
427
|
-
|
|
447
|
+
// The row settles and its live process/service facts drop in one paint.
|
|
448
|
+
patchWorkbench((current) => markSessionStopped(current, session.id));
|
|
449
|
+
say(`stopped · ${worktreeDisplayName(session)} — the record and review remain`);
|
|
450
|
+
},
|
|
451
|
+
onError: (error) => {
|
|
452
|
+
say(errorText(error));
|
|
453
|
+
refetch();
|
|
454
|
+
},
|
|
455
|
+
onSettled: settleRefetch,
|
|
456
|
+
});
|
|
457
|
+
// A new conversation inside an existing worktree — the `s` key's flow.
|
|
458
|
+
const launchInWorktreeMutation = useMutation({
|
|
459
|
+
mutationFn: async (vars) => {
|
|
460
|
+
const argv = HARNESS_COMMANDS[vars.harness];
|
|
461
|
+
if (argv === undefined)
|
|
462
|
+
throw new Error(`unknown harness "${vars.harness}"`);
|
|
463
|
+
const session = await ctx.api("POST", `/worktrees/${vars.worktreeId}/sessions`, {
|
|
464
|
+
harness: vars.harness,
|
|
465
|
+
label: null,
|
|
466
|
+
});
|
|
467
|
+
await ctx.api("POST", `/sessions/${session.id}/launch`, { argv });
|
|
468
|
+
return session;
|
|
469
|
+
},
|
|
470
|
+
onMutate: async (vars) => {
|
|
471
|
+
await queryClient.cancelQueries({ queryKey: WORKBENCH_KEY });
|
|
472
|
+
patchWorkbench((current) => prependSession(current, vars.projectId, {
|
|
473
|
+
id: vars.pendingKey,
|
|
474
|
+
worktreeId: vars.worktreeId,
|
|
475
|
+
harness: vars.harness,
|
|
476
|
+
label: null,
|
|
477
|
+
branch: "joining…",
|
|
478
|
+
baseSha: "",
|
|
479
|
+
baseRef: null,
|
|
480
|
+
status: "starting",
|
|
481
|
+
summary: null,
|
|
482
|
+
createdAt: new Date().toISOString(),
|
|
483
|
+
}));
|
|
484
|
+
selectSession(vars.pendingKey);
|
|
485
|
+
setBusy(`starting ${vars.harness} in the worktree ·`);
|
|
486
|
+
setBusyStarted(Date.now());
|
|
487
|
+
},
|
|
488
|
+
onError: (error, vars) => {
|
|
489
|
+
patchWorkbench((current) => removeSession(current, vars.projectId, vars.pendingKey));
|
|
490
|
+
setBusy(null);
|
|
491
|
+
say(errorText(error));
|
|
492
|
+
},
|
|
493
|
+
onSuccess: async (session, vars) => {
|
|
494
|
+
patchWorkbench((current) => replaceSession(current, vars.projectId, vars.pendingKey, session));
|
|
495
|
+
selectSession(session.id);
|
|
496
|
+
setBusy(null);
|
|
497
|
+
await attachIfIdle(session);
|
|
498
|
+
},
|
|
499
|
+
onSettled: settleRefetch,
|
|
500
|
+
});
|
|
501
|
+
// The one explicit destructive act. Against a pre-worktree server the
|
|
502
|
+
// session delete IS the old combined removal — same key, old semantics.
|
|
503
|
+
const removeWorktreeMutation = useMutation({
|
|
504
|
+
mutationFn: (group) => group.id !== null
|
|
505
|
+
? ctx.api("DELETE", `/worktrees/${group.id}`)
|
|
506
|
+
: ctx.api("DELETE", `/sessions/${group.sessions[0]?.session.id ?? ""}`),
|
|
507
|
+
onMutate: async (group) => {
|
|
508
|
+
await queryClient.cancelQueries({ queryKey: WORKBENCH_KEY });
|
|
509
|
+
const projectId = selectedProject?.project.id;
|
|
510
|
+
// The group leaves the list before the server answers; an error refetches truth.
|
|
511
|
+
if (projectId !== undefined) {
|
|
512
|
+
patchWorkbench((current) => removeWorktreeGroup(current, projectId, group));
|
|
513
|
+
}
|
|
514
|
+
say(`removing worktree · ${group.name}`);
|
|
428
515
|
},
|
|
429
516
|
onError: (error) => {
|
|
430
517
|
say(errorText(error));
|
|
431
518
|
refetch();
|
|
432
519
|
},
|
|
520
|
+
onSuccess: (_result, group) => {
|
|
521
|
+
say(`removed · ${group.name}`);
|
|
522
|
+
refetch();
|
|
523
|
+
},
|
|
433
524
|
onSettled: settleRefetch,
|
|
434
525
|
});
|
|
526
|
+
/** Worktree removal is armed like stops: the first press states the facts. */
|
|
527
|
+
const [removeArmed, setRemoveArmed] = useState(null);
|
|
528
|
+
const armRemove = () => {
|
|
529
|
+
const group = selectedGroup;
|
|
530
|
+
if (group === null || group.sessions.some((item) => isPendingId(item.session.id)))
|
|
531
|
+
return;
|
|
532
|
+
if (group.live > 0) {
|
|
533
|
+
say(`${group.live} session${group.live === 1 ? "" : "s"} live — stop them first · ${group.name}`);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
if (removeArmed === group.key) {
|
|
537
|
+
setRemoveArmed(null);
|
|
538
|
+
removeWorktreeMutation.mutate(group);
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
setRemoveArmed(group.key);
|
|
542
|
+
const facts = group.sessions.length === 1
|
|
543
|
+
? "its session and change go with it"
|
|
544
|
+
: `${group.sessions.length} sessions and the change go with it`;
|
|
545
|
+
say(`press again to remove worktree · ${group.name} — ${facts}`);
|
|
546
|
+
};
|
|
435
547
|
const armStop = () => {
|
|
548
|
+
// A worktree header arms a stop of EVERY live conversation in it.
|
|
549
|
+
if (selectedRow?.kind === "worktree") {
|
|
550
|
+
const group = selectedRow.group;
|
|
551
|
+
const live = group.sessions.filter((item) => LIVE_STATUSES.has(item.session.status));
|
|
552
|
+
if (live.length === 0) {
|
|
553
|
+
say("nothing to stop — the worktree is settled");
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
if (stopArmed === `wt:${group.key}`) {
|
|
557
|
+
setStopArmed(null);
|
|
558
|
+
for (const item of live)
|
|
559
|
+
stopMutation.mutate(item.session);
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
setStopArmed(`wt:${group.key}`);
|
|
563
|
+
say(`press again to stop ${live.length} live session${live.length === 1 ? "" : "s"} · ${group.name}`);
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
436
566
|
const item = selectedSession;
|
|
437
567
|
if (item === null || isPendingId(item.session.id))
|
|
438
568
|
return;
|
|
@@ -446,19 +576,81 @@ const App = ({ ctx, onQuit }) => {
|
|
|
446
576
|
return;
|
|
447
577
|
}
|
|
448
578
|
setStopArmed(item.session.id);
|
|
449
|
-
say(`press again to stop · ${
|
|
579
|
+
say(`press again to stop · ${worktreeDisplayName(item.session)}`);
|
|
450
580
|
};
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
581
|
+
/** Open the creation modal; the branch list starts loading immediately. */
|
|
582
|
+
const openCreateModal = (projectId) => {
|
|
583
|
+
setCreating({
|
|
584
|
+
projectId,
|
|
585
|
+
step: "name",
|
|
586
|
+
name: "",
|
|
587
|
+
branches: null,
|
|
588
|
+
query: "",
|
|
589
|
+
baseIndex: 0,
|
|
590
|
+
base: null,
|
|
591
|
+
joins: false,
|
|
592
|
+
harnessIndex: 0,
|
|
593
|
+
});
|
|
594
|
+
// The checkout mend ran in names its branch; creating in THAT project
|
|
595
|
+
// prefills the base with it — the list highlights it once loaded.
|
|
596
|
+
const prefill = projectId === homeProject?.id ? ctx.cwdBranch : null;
|
|
597
|
+
void ctx.api("GET", `/projects/${projectId}/branches`).then((branches) => setCreating((current) => {
|
|
598
|
+
if (current === null)
|
|
599
|
+
return current;
|
|
600
|
+
const match = prefill === null ? undefined : branches.find((candidate) => candidate.name === prefill);
|
|
601
|
+
const ordered = filterBranches(branches, "");
|
|
602
|
+
return {
|
|
603
|
+
...current,
|
|
604
|
+
branches,
|
|
605
|
+
base: match === undefined || match.isDefault ? null : match.name,
|
|
606
|
+
baseIndex: match === undefined
|
|
607
|
+
? 0
|
|
608
|
+
: Math.max(0, ordered.findIndex((candidate) => candidate.name === match.name)),
|
|
609
|
+
};
|
|
610
|
+
}), () =>
|
|
611
|
+
// An unreadable list falls back to the default base; the base step
|
|
612
|
+
// then shows only the default and enter moves on.
|
|
613
|
+
setCreating((current) => (current === null ? current : { ...current, branches: [] })));
|
|
455
614
|
};
|
|
456
|
-
/**
|
|
457
|
-
const
|
|
458
|
-
|
|
459
|
-
const
|
|
460
|
-
|
|
461
|
-
|
|
615
|
+
/** Commit the name: an existing name JOINS its worktree — base is fixed. */
|
|
616
|
+
const advanceFromName = (current, raw) => {
|
|
617
|
+
const slug = normalizeProjectName(raw.trim());
|
|
618
|
+
const name = raw.trim() === "" ? "" : slug;
|
|
619
|
+
const joins = name !== "" &&
|
|
620
|
+
(workbench()?.details.get(current.projectId)?.worktrees ?? []).some((candidate) => candidate.name === name);
|
|
621
|
+
// A join fixes the base; otherwise whatever is chosen (the checkout
|
|
622
|
+
// prefill included) rides along untouched.
|
|
623
|
+
return {
|
|
624
|
+
...current,
|
|
625
|
+
name,
|
|
626
|
+
joins,
|
|
627
|
+
...(joins ? { base: null } : {}),
|
|
628
|
+
step: joins ? "harness" : "base",
|
|
629
|
+
};
|
|
630
|
+
};
|
|
631
|
+
const submitCreateName = (value) => {
|
|
632
|
+
setCreating((current) => (current === null ? current : advanceFromName(current, value)));
|
|
633
|
+
};
|
|
634
|
+
const submitCreateBase = () => {
|
|
635
|
+
setCreating((current) => (current === null ? current : advanceFromBase(current)));
|
|
636
|
+
};
|
|
637
|
+
/** Enter on the harness: everything is chosen — launch. */
|
|
638
|
+
const submitCreateHarness = () => {
|
|
639
|
+
const current = creating;
|
|
640
|
+
if (current === null)
|
|
641
|
+
return;
|
|
642
|
+
const choice = deriveHarnesses(null)[current.harnessIndex];
|
|
643
|
+
if (choice?.harness == null)
|
|
644
|
+
return;
|
|
645
|
+
setCreating(null);
|
|
646
|
+
setFocus("sessions");
|
|
647
|
+
launchMutation.mutate({
|
|
648
|
+
projectId: current.projectId,
|
|
649
|
+
harness: choice.harness,
|
|
650
|
+
name: current.name === "" ? null : current.name,
|
|
651
|
+
base: current.base,
|
|
652
|
+
pendingKey: pendingId(),
|
|
653
|
+
});
|
|
462
654
|
};
|
|
463
655
|
const resumeSession = (projectId, session, harness) => {
|
|
464
656
|
setFocus("sessions");
|
|
@@ -469,8 +661,8 @@ const App = ({ ctx, onQuit }) => {
|
|
|
469
661
|
const label = value.trim() === "" ? null : value.trim();
|
|
470
662
|
renameMutation.mutate({ session, label });
|
|
471
663
|
};
|
|
472
|
-
const openPicker = (session) => {
|
|
473
|
-
setPicker({ session });
|
|
664
|
+
const openPicker = (session, worktree) => {
|
|
665
|
+
setPicker(worktree === undefined ? { session } : { session, worktree });
|
|
474
666
|
setPickerIndex(0);
|
|
475
667
|
};
|
|
476
668
|
const moveSelection = (delta) => {
|
|
@@ -489,12 +681,12 @@ const App = ({ ctx, onQuit }) => {
|
|
|
489
681
|
}
|
|
490
682
|
return;
|
|
491
683
|
}
|
|
492
|
-
if (
|
|
684
|
+
if (rows.length === 0)
|
|
493
685
|
return;
|
|
494
|
-
const next = Math.max(0, Math.min(
|
|
495
|
-
const
|
|
496
|
-
if (
|
|
497
|
-
setSessionKey(
|
|
686
|
+
const next = Math.max(0, Math.min(rows.length - 1, rowIndex + delta));
|
|
687
|
+
const row = rows[next];
|
|
688
|
+
if (row !== undefined)
|
|
689
|
+
setSessionKey(rowKeyOf(row));
|
|
498
690
|
};
|
|
499
691
|
const activate = () => {
|
|
500
692
|
if (picker !== null) {
|
|
@@ -503,11 +695,18 @@ const App = ({ ctx, onQuit }) => {
|
|
|
503
695
|
if (choice === undefined || projectId === undefined)
|
|
504
696
|
return;
|
|
505
697
|
setPicker(null);
|
|
506
|
-
if (picker.
|
|
507
|
-
if (choice.harness !== null)
|
|
508
|
-
|
|
698
|
+
if (picker.worktree !== undefined && picker.worktree.id !== null) {
|
|
699
|
+
if (choice.harness !== null) {
|
|
700
|
+
setFocus("sessions");
|
|
701
|
+
launchInWorktreeMutation.mutate({
|
|
702
|
+
projectId,
|
|
703
|
+
worktreeId: picker.worktree.id,
|
|
704
|
+
harness: choice.harness,
|
|
705
|
+
pendingKey: pendingId(),
|
|
706
|
+
});
|
|
707
|
+
}
|
|
509
708
|
}
|
|
510
|
-
else {
|
|
709
|
+
else if (picker.session !== null) {
|
|
511
710
|
resumeSession(projectId, picker.session, choice.harness);
|
|
512
711
|
}
|
|
513
712
|
return;
|
|
@@ -516,6 +715,18 @@ const App = ({ ctx, onQuit }) => {
|
|
|
516
715
|
setFocus("sessions");
|
|
517
716
|
return;
|
|
518
717
|
}
|
|
718
|
+
if (selectedRow?.kind === "worktree") {
|
|
719
|
+
// Enter on the place: attach its newest live conversation, or open a
|
|
720
|
+
// new one when nothing is live.
|
|
721
|
+
const live = selectedRow.group.sessions.find((item) => LIVE_STATUSES.has(item.session.status));
|
|
722
|
+
if (live !== undefined) {
|
|
723
|
+
void attachFlow(live.session);
|
|
724
|
+
}
|
|
725
|
+
else if (selectedRow.group.id !== null) {
|
|
726
|
+
openPicker(null, selectedRow.group);
|
|
727
|
+
}
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
519
730
|
const item = selectedSession;
|
|
520
731
|
if (item === null)
|
|
521
732
|
return;
|
|
@@ -536,15 +747,109 @@ const App = ({ ctx, onQuit }) => {
|
|
|
536
747
|
return;
|
|
537
748
|
if (key.ctrl && key.name === "c")
|
|
538
749
|
return onQuit();
|
|
750
|
+
if (adoptOffer !== null) {
|
|
751
|
+
if (key.name === "return" || key.name === "linefeed" || key.name === "y") {
|
|
752
|
+
adoptMutation.mutate(adoptOffer);
|
|
753
|
+
}
|
|
754
|
+
else if (key.name === "escape" || key.name === "q") {
|
|
755
|
+
setAdoptOffer(null);
|
|
756
|
+
say("not adopted — mend adopt does it any time");
|
|
757
|
+
}
|
|
758
|
+
else if (key.name === "right" ||
|
|
759
|
+
key.name === "l" ||
|
|
760
|
+
key.name === "tab" ||
|
|
761
|
+
key.name === "left" ||
|
|
762
|
+
key.name === "h") {
|
|
763
|
+
const delta = key.name === "left" || key.name === "h" ? -1 : 1;
|
|
764
|
+
setAdoptOffer((current) => current === null
|
|
765
|
+
? current
|
|
766
|
+
: {
|
|
767
|
+
...current,
|
|
768
|
+
modeIndex: (current.modeIndex + delta + ADOPT_AUTH_MODES.length) % ADOPT_AUTH_MODES.length,
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
539
773
|
if (editing !== null) {
|
|
540
774
|
// The input owns the keyboard; only escape leaves without saving.
|
|
541
775
|
if (key.name === "escape")
|
|
542
776
|
setEditing(null);
|
|
543
777
|
return;
|
|
544
778
|
}
|
|
545
|
-
if (
|
|
546
|
-
|
|
547
|
-
|
|
779
|
+
if (creating !== null) {
|
|
780
|
+
// Inputs own the characters (name text, base filter); this handler owns
|
|
781
|
+
// the arrows, esc (a step back; cancel from the name), and the harness
|
|
782
|
+
// step's enter. The name and base enters land through their input's
|
|
783
|
+
// submit so typed-but-unflushed text never races.
|
|
784
|
+
if (key.name === "escape") {
|
|
785
|
+
setCreating((current) => {
|
|
786
|
+
if (current === null)
|
|
787
|
+
return current;
|
|
788
|
+
if (current.step === "harness") {
|
|
789
|
+
return { ...current, step: current.joins ? "name" : "base" };
|
|
790
|
+
}
|
|
791
|
+
if (current.step === "base")
|
|
792
|
+
return { ...current, step: "name" };
|
|
793
|
+
say("new worktree cancelled");
|
|
794
|
+
return null;
|
|
795
|
+
});
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
// Tab walks the steps without the enter ceremony: forward commits the
|
|
799
|
+
// active field (the mirrored name, the highlighted branch), shift+tab
|
|
800
|
+
// steps back, and from the harness it wraps around to the name.
|
|
801
|
+
if (key.name === "tab" || key.name === "backtab") {
|
|
802
|
+
const backward = key.name === "backtab" || key.shift === true;
|
|
803
|
+
setCreating((current) => {
|
|
804
|
+
if (current === null)
|
|
805
|
+
return current;
|
|
806
|
+
if (backward) {
|
|
807
|
+
if (current.step === "harness") {
|
|
808
|
+
return { ...current, step: current.joins ? "name" : "base" };
|
|
809
|
+
}
|
|
810
|
+
if (current.step === "base")
|
|
811
|
+
return { ...current, step: "name" };
|
|
812
|
+
return current;
|
|
813
|
+
}
|
|
814
|
+
if (current.step === "name")
|
|
815
|
+
return advanceFromName(current, current.name);
|
|
816
|
+
if (current.step === "base")
|
|
817
|
+
return advanceFromBase(current);
|
|
818
|
+
return { ...current, step: "name" };
|
|
819
|
+
});
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
const delta = key.name === "down" || (key.ctrl === true && key.name === "n")
|
|
823
|
+
? 1
|
|
824
|
+
: key.name === "up" || (key.ctrl === true && key.name === "p")
|
|
825
|
+
? -1
|
|
826
|
+
: 0;
|
|
827
|
+
if (delta !== 0) {
|
|
828
|
+
setCreating((current) => {
|
|
829
|
+
if (current === null)
|
|
830
|
+
return current;
|
|
831
|
+
if (current.step === "base") {
|
|
832
|
+
const matches = filterBranches(current.branches ?? [], current.query).length;
|
|
833
|
+
return {
|
|
834
|
+
...current,
|
|
835
|
+
baseIndex: Math.max(0, Math.min(matches - 1, current.baseIndex + delta)),
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
if (current.step === "harness") {
|
|
839
|
+
const count = deriveHarnesses(null).length;
|
|
840
|
+
return {
|
|
841
|
+
...current,
|
|
842
|
+
harnessIndex: Math.max(0, Math.min(count - 1, current.harnessIndex + delta)),
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
return current;
|
|
846
|
+
});
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
if (creating.step === "harness" &&
|
|
850
|
+
(key.name === "return" || key.name === "linefeed" || key.name === "l")) {
|
|
851
|
+
submitCreateHarness();
|
|
852
|
+
}
|
|
548
853
|
return;
|
|
549
854
|
}
|
|
550
855
|
if (picker !== null) {
|
|
@@ -571,6 +876,9 @@ const App = ({ ctx, onQuit }) => {
|
|
|
571
876
|
// vim-up; the shift is the deliberateness the arm-confirm then doubles.
|
|
572
877
|
if (key.shift === true && key.name === "k")
|
|
573
878
|
return armStop();
|
|
879
|
+
// Shift+D: remove the selected worktree — the one explicit destructive act.
|
|
880
|
+
if (key.shift === true && key.name === "d")
|
|
881
|
+
return armRemove();
|
|
574
882
|
switch (key.name) {
|
|
575
883
|
case "q":
|
|
576
884
|
return onQuit();
|
|
@@ -602,10 +910,22 @@ const App = ({ ctx, onQuit }) => {
|
|
|
602
910
|
setFocus(focus === "projects" ? "sessions" : "projects");
|
|
603
911
|
return;
|
|
604
912
|
case "n":
|
|
605
|
-
// The
|
|
913
|
+
// The whole creation is one modal: name, base, harness.
|
|
606
914
|
if (selectedProject !== null)
|
|
607
|
-
|
|
915
|
+
openCreateModal(selectedProject.project.id);
|
|
916
|
+
return;
|
|
917
|
+
case "s": {
|
|
918
|
+
// A new conversation inside the selected worktree.
|
|
919
|
+
const group = selectedGroup;
|
|
920
|
+
if (group === null)
|
|
921
|
+
return;
|
|
922
|
+
if (group.id === null) {
|
|
923
|
+
say("this server predates shared worktrees — n starts a new one");
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
openPicker(null, group);
|
|
608
927
|
return;
|
|
928
|
+
}
|
|
609
929
|
case "e": {
|
|
610
930
|
const item = selectedSession;
|
|
611
931
|
if (item !== null && !isPendingId(item.session.id))
|
|
@@ -628,7 +948,7 @@ const App = ({ ctx, onQuit }) => {
|
|
|
628
948
|
say("still provisioning — nothing to review yet");
|
|
629
949
|
return;
|
|
630
950
|
}
|
|
631
|
-
const target = reviewTargetForSession(item.session, item.annotation, selectedProject?.project.name ?? "project");
|
|
951
|
+
const target = reviewTargetForSession(item.session, selectedGroup?.annotation ?? item.annotation, selectedProject?.project.name ?? "project");
|
|
632
952
|
if (target === null) {
|
|
633
953
|
say("this session has no reviewable change yet");
|
|
634
954
|
return;
|
|
@@ -650,23 +970,34 @@ const App = ({ ctx, onQuit }) => {
|
|
|
650
970
|
const sessionsTitle = selectedProject === null
|
|
651
971
|
? "sessions"
|
|
652
972
|
: `sessions — ${selectedProject.project.name}${selectedProject.live > 0 ? ` · ${selectedProject.live} live` : ""}`;
|
|
653
|
-
const detailTitle = selectedSession === null
|
|
973
|
+
const detailTitle = selectedSession === null || selectedGroup === null
|
|
654
974
|
? "session"
|
|
655
|
-
: `worktree — ${
|
|
975
|
+
: `worktree — ${selectedGroup.name} · ${selectedSession.session.harness} ${selectedSession.session.id.slice(0, 8)}`;
|
|
656
976
|
const pickerTitle = picker === null
|
|
657
977
|
? ""
|
|
658
|
-
: picker.
|
|
659
|
-
?
|
|
660
|
-
:
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
978
|
+
: picker.worktree !== undefined
|
|
979
|
+
? `new session in ${picker.worktree.name} — pick a harness`
|
|
980
|
+
: picker.session === null
|
|
981
|
+
? "new session — pick a harness"
|
|
982
|
+
: `resume ${picker.session.id.slice(0, 8)} — pick a harness`;
|
|
983
|
+
// One big fixed-size modal: every step visible at once, nothing shifts as
|
|
984
|
+
// focus moves through name → base → harness.
|
|
985
|
+
const creatingHeight = 2 + 1 + 1 + 6 + 1 + deriveHarnesses(null).length;
|
|
986
|
+
const footerText = adoptOffer !== null
|
|
987
|
+
? " enter adopt · ←→ auth mode · esc not now"
|
|
988
|
+
: editing !== null
|
|
989
|
+
? " enter save · esc cancel"
|
|
990
|
+
: creating !== null
|
|
991
|
+
? creating.step === "name"
|
|
992
|
+
? " enter continue · esc cancel"
|
|
993
|
+
: creating.step === "base"
|
|
994
|
+
? " type to filter · ↑↓ move · enter choose base · esc back"
|
|
995
|
+
: " ↑↓ move · enter launch · esc back"
|
|
996
|
+
: picker !== null
|
|
997
|
+
? " ↑↓ move · enter start · esc cancel"
|
|
998
|
+
: focus === "projects"
|
|
999
|
+
? " ↑↓ move · enter/l sessions · ⇥ panes · r refresh · q quit"
|
|
1000
|
+
: " ↑↓ move · enter attach/resume · n new worktree · s session here · x stop · ⇧D remove · v review · e rename · o web · q quit";
|
|
670
1001
|
const loadFailure = data === undefined && failureReason !== null
|
|
671
1002
|
? failureReason instanceof Error
|
|
672
1003
|
? failureReason.message
|
|
@@ -678,13 +1009,39 @@ const App = ({ ctx, onQuit }) => {
|
|
|
678
1009
|
refetch();
|
|
679
1010
|
}, onQuit: onQuit }));
|
|
680
1011
|
}
|
|
681
|
-
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: [
|
|
682
|
-
if (typeof value === "string")
|
|
683
|
-
submitWorktreeName(value);
|
|
684
|
-
} }) })) : 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) => {
|
|
1012
|
+
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: [rows.map((row, index) => row.kind === "worktree" ? (_jsx(WorktreeHeaderRow, { group: row.group, selected: index === rowIndex }, rowKeyOf(row))) : row.group.sessions.length === 1 ? (_jsx(CombinedRow, { group: row.group, item: row.item, selected: index === rowIndex }, rowKeyOf(row))) : (_jsx(SessionChildRow, { item: row.item, selected: index === rowIndex }, rowKeyOf(row)))), data !== undefined && rows.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) => {
|
|
685
1013
|
if (typeof value === "string")
|
|
686
1014
|
submitRename(editing, value);
|
|
687
|
-
} }) })) : showDetail ? (_jsx(Pane, { title: detailTitle, focused: false, height: 6, children: _jsx(SessionDetail, { item: selectedSession }) })) : null, _jsx(
|
|
1015
|
+
} }) })) : showDetail ? (_jsx(Pane, { title: detailTitle, focused: false, height: 6, children: _jsx(SessionDetail, { item: selectedSession }) })) : null, adoptOffer !== null ? (_jsxs("box", { position: "absolute", zIndex: 11, left: Math.max(1, Math.floor((terminalCols - Math.min(70, terminalCols - 4)) / 2)), top: Math.max(1, Math.floor((terminalRows - 7) / 2)), width: Math.min(70, terminalCols - 4), height: 7, border: true, borderStyle: "rounded", borderColor: COBALT, title: " adopt this repository? ", titleAlignment: "left", backgroundColor: SURFACE, flexDirection: "column", children: [_jsxs("text", { height: 1, bg: SURFACE, children: [_jsx("span", { children: " " }), _jsx("span", { fg: INK, children: adoptOffer.name }), _jsx("span", { fg: FAINT, children: " · not in the store yet" })] }), _jsx("text", { height: 1, bg: SURFACE, fg: MUTED, children: ` ${adoptOffer.source}` }), _jsxs("text", { height: 1, bg: SURFACE, children: [_jsx("span", { fg: FAINT, children: " auth " }), ADOPT_AUTH_MODES.map((candidate, index) => (_jsxs("span", { children: [index > 0 ? _jsx("span", { fg: FAINT, children: " · " }) : null, _jsx("span", { fg: index === adoptOffer.modeIndex ? COBALT : FAINT, children: index === adoptOffer.modeIndex ? `▸ ${candidate.label}` : candidate.label })] }, candidate.mode)))] }), _jsx("text", { height: 1, bg: SURFACE, fg: FAINT, children: ` ${ADOPT_AUTH_MODES[adoptOffer.modeIndex]?.hint ?? ""}` }), _jsx("text", { height: 1, bg: SURFACE, fg: FAINT, children: adoptOffer.source === adoptOffer.root
|
|
1016
|
+
? " enter adopt from this path — needs the server on THIS machine · esc not now"
|
|
1017
|
+
: " enter adopt · ←→ auth mode · esc not now" })] })) : null, creating !== null ? (_jsxs("box", { position: "absolute", zIndex: 10, left: Math.max(1, Math.floor((terminalCols - Math.min(74, terminalCols - 4)) / 2)), top: Math.max(1, Math.floor((terminalRows - creatingHeight) / 2)), width: Math.min(74, terminalCols - 4), height: creatingHeight, border: true, borderStyle: "rounded", borderColor: COBALT, title: ` new worktree — ${selectedProject?.project.name ?? "project"} `, titleAlignment: "left", backgroundColor: SURFACE, flexDirection: "column", children: [_jsxs("box", { height: 1, flexShrink: 0, flexDirection: "row", backgroundColor: SURFACE, children: [_jsxs("text", { height: 1, bg: SURFACE, children: [_jsx(Gutter, { selected: creating.step === "name" }), _jsx("span", { fg: FAINT, children: "name " }), creating.step !== "name" ? (_jsx("span", { fg: INK, children: creating.name === "" ? "auto" : creating.name })) : null, creating.joins ? _jsx("span", { fg: FAINT, children: " · joins the existing worktree" }) : null] }), creating.step === "name" ? (_jsx("input", { focused: true, value: creating.name, placeholder: "e.g. fix-auth (empty = auto \u00B7 an existing name joins it)", backgroundColor: SURFACE, focusedBackgroundColor: SURFACE, textColor: INK, focusedTextColor: INK, placeholderColor: FAINT, cursorColor: INK, flexGrow: 1, onInput: (value) => {
|
|
1018
|
+
const clean = value.replace(/\t/g, "");
|
|
1019
|
+
setCreating((current) => current === null ? current : { ...current, name: clean });
|
|
1020
|
+
}, onSubmit: (value) => {
|
|
1021
|
+
if (typeof value === "string")
|
|
1022
|
+
submitCreateName(value);
|
|
1023
|
+
} })) : null] }), _jsxs("box", { height: 1, flexShrink: 0, flexDirection: "row", backgroundColor: SURFACE, children: [_jsxs("text", { height: 1, bg: SURFACE, children: [_jsx(Gutter, { selected: creating.step === "base" }), _jsx("span", { fg: FAINT, children: "base " }), creating.step !== "base" ? (_jsx("span", { fg: creating.step === "name" && !creating.joins ? FAINT : INK, children: creating.joins
|
|
1024
|
+
? "fixed by the existing worktree"
|
|
1025
|
+
: (creating.base ?? "default branch") })) : null] }), creating.step === "base" ? (creating.branches === null ? (_jsx("text", { height: 1, bg: SURFACE, fg: FAINT, children: "reading branches\u2026" })) : (_jsx("input", { focused: true, value: "", placeholder: "type to filter (enter = highlighted; empty = default)", backgroundColor: SURFACE, focusedBackgroundColor: SURFACE, textColor: INK, focusedTextColor: INK, placeholderColor: FAINT, cursorColor: INK, flexGrow: 1, onInput: (value) => {
|
|
1026
|
+
const clean = value.replace(/\t/g, "");
|
|
1027
|
+
setCreating((current) => current === null ? current : { ...current, query: clean, baseIndex: 0 });
|
|
1028
|
+
}, onSubmit: () => submitCreateBase() }))) : null] }), Array.from({ length: 6 }, (_, index) => {
|
|
1029
|
+
const branch = creating.joins || creating.branches === null
|
|
1030
|
+
? undefined
|
|
1031
|
+
: filterBranches(creating.branches, creating.query)[index];
|
|
1032
|
+
const active = creating.step === "base";
|
|
1033
|
+
if (branch === undefined) {
|
|
1034
|
+
return (_jsx("text", { height: 1, bg: SURFACE, fg: FAINT, children: index === 0 && active && creating.branches !== null
|
|
1035
|
+
? " no branch matches — enter uses the default"
|
|
1036
|
+
: " " }, `slot-${index}`));
|
|
1037
|
+
}
|
|
1038
|
+
const highlighted = active && index === creating.baseIndex;
|
|
1039
|
+
return (_jsx("box", { height: 1, flexShrink: 0, backgroundColor: highlighted ? WASH : SURFACE, children: _jsxs("text", { height: 1, bg: highlighted ? WASH : SURFACE, children: [_jsx("span", { fg: FAINT, children: " " }), _jsx(Gutter, { selected: highlighted }), _jsx("span", { fg: active ? INK : FAINT, children: branch.name.slice(0, 40).padEnd(41) }), _jsx("span", { fg: FAINT, children: branch.sha.slice(0, 8).padEnd(10) }), _jsx("span", { fg: active ? MUTED : FAINT, children: timeAgo(branch.committedAt).replace(" ago", "").padEnd(6) }), branch.isDefault ? _jsx("span", { fg: FAINT, children: "default" }) : null] }) }, branch.name));
|
|
1040
|
+
}), _jsxs("text", { height: 1, bg: SURFACE, children: [_jsx(Gutter, { selected: creating.step === "harness" }), _jsx("span", { fg: FAINT, children: "harness" })] }), deriveHarnesses(null).map((item, index) => {
|
|
1041
|
+
const active = creating.step === "harness";
|
|
1042
|
+
const highlighted = active && index === creating.harnessIndex;
|
|
1043
|
+
return (_jsx("box", { height: 1, flexShrink: 0, backgroundColor: highlighted ? WASH : SURFACE, children: _jsxs("text", { height: 1, bg: highlighted ? WASH : SURFACE, children: [_jsx("span", { fg: FAINT, children: " " }), _jsx(Gutter, { selected: highlighted }), _jsx("span", { fg: active ? INK : FAINT, children: item.label.padEnd(12) }), _jsx("span", { fg: FAINT, children: active ? item.hint : "" })] }) }, String(item.harness)));
|
|
1044
|
+
})] })) : null, _jsx(StatusLine, { busy: busy, busyStarted: busyStarted, status: status }), _jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: footerText })] }));
|
|
688
1045
|
};
|
|
689
1046
|
// ─── entry ──────────────────────────────────────────────────────────────────
|
|
690
1047
|
export const runDashboard = async (ctx) => {
|