@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.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
- const WORKBENCH_KEY = ["workbench"];
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
- * The worktree IS the session's identity (one worktree per session): its
143
- * branch, with the noisy default prefix receded. What you are working on
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 worktreeName = (branch) => branch.startsWith("mend/") ? branch.slice("mend/".length) : branch;
147
- /**
148
- * One worktree, grouped: the session line leads with the worktree name, and
149
- * everything live inside it — its Services — hangs underneath. The child
150
- * rows are facts, not targets: selection stays on the worktree line.
151
- */
152
- const SessionRow = ({ item, selected, }) => {
153
- const { session, annotation, services } = item;
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 worktree = worktreeName(session.branch);
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: INK, children: worktree.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: (session.label ?? "").slice(0, 40) })] }) }), services.map((service) => (_jsx("box", { height: 1, flexShrink: 0, backgroundColor: "transparent", children: _jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { fg: FAINT, children: " └ " }), _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)))] }));
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
- /** The new-session flow asks the worktree's name FIRST, then the harness. */
231
- const [naming, setNaming] = useState(null);
232
- const [pendingName, setPendingName] = useState(null);
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 sessionItems = deriveSessions(data, selectedProject?.project.id ?? null);
254
- const sessionIndexRaw = sessionKey === null ? -1 : sessionItems.findIndex((s) => s.session.id === sessionKey);
255
- const sessionIndex = sessionIndexRaw === -1 ? 0 : sessionIndexRaw;
256
- const selectedSession = sessionItems[sessionIndex] ?? null;
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 are worktree groups (a session line plus its service children), so
268
- // the scroll target is the group's y offset, not its index.
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, item] of sessionItems.entries()) {
271
- if (index === sessionIndex)
242
+ for (const [index, row] of rows.entries()) {
243
+ if (index === rowIndex)
272
244
  break;
273
- top += 1 + item.services.length;
245
+ top += rowHeight(row);
274
246
  }
275
- const selectedHeight = 1 + (sessionItems[sessionIndex]?.services.length ?? 0);
276
- keepSpanVisible(sessionScrollRef.current, top, selectedHeight);
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" — open a fresh
289
- // shell in the same workspace and attach to that.
290
- const shell = await ctx.api("POST", `/sessions/${session.id}/shell`);
291
- process.stdout.write(`no live terminal — opened a shell in the workspace\n\n`);
292
- outcome = await ctx.attachTty(session.id, "shell", shell.id);
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: null,
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: null,
338
+ baseRef: vars.base,
358
339
  status: "starting",
359
340
  summary: null,
360
341
  createdAt: new Date().toISOString(),
361
342
  }));
362
- setSessionKey(vars.pendingKey);
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
- setSessionKey(session.id);
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
- setSessionKey(vars.session.id);
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
- setSessionKey(resumed.id);
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
- patchWorkbench((current) => mapWorkbenchSessions(current, (row) => row.id === session.id ? { ...row, status: "stopped" } : row));
427
- say(`stopped · ${worktreeName(session.branch)} — the record and review remain`);
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 · ${worktreeName(item.session.branch)}`);
579
+ say(`press again to stop · ${worktreeDisplayName(item.session)}`);
450
580
  };
451
- const startSession = (projectId, harness) => {
452
- setFocus("sessions");
453
- launchMutation.mutate({ projectId, harness, name: pendingName, pendingKey: pendingId() });
454
- setPendingName(null);
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
- /** Name first, then the harness: the worktree is the identity being created. */
457
- const submitWorktreeName = (value) => {
458
- setNaming(null);
459
- const slug = normalizeProjectName(value.trim());
460
- setPendingName(value.trim() === "" ? null : slug);
461
- openPicker(null);
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 (sessionItems.length === 0)
684
+ if (rows.length === 0)
493
685
  return;
494
- const next = Math.max(0, Math.min(sessionItems.length - 1, sessionIndex + delta));
495
- const item = sessionItems[next];
496
- if (item !== undefined)
497
- setSessionKey(item.session.id);
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.session === null) {
507
- if (choice.harness !== null)
508
- startSession(projectId, choice.harness);
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 (naming !== null) {
546
- if (key.name === "escape")
547
- setNaming(null);
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 worktree's name comes first; the harness picker follows.
913
+ // The whole creation is one modal: name, base, harness.
606
914
  if (selectedProject !== null)
607
- setNaming({ projectId: selectedProject.project.id });
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 — ${worktreeName(selectedSession.session.branch)} · ${selectedSession.session.harness} ${selectedSession.session.id.slice(0, 8)}`;
975
+ : `worktree — ${selectedGroup.name} · ${selectedSession.session.harness} ${selectedSession.session.id.slice(0, 8)}`;
656
976
  const pickerTitle = picker === null
657
977
  ? ""
658
- : picker.session === null
659
- ? "new session — pick a harness"
660
- : `resume ${picker.session.id.slice(0, 8)} — pick a harness`;
661
- const footerText = editing !== null
662
- ? " enter save · esc cancel"
663
- : naming !== null
664
- ? " enter continue — the harness picker follows · esc cancel"
665
- : picker !== null
666
- ? " ↑↓ move · enter start · esc cancel"
667
- : focus === "projects"
668
- ? " ↑↓ move · enter/l sessions · ⇥ panes · r refresh · q quit"
669
- : " ↑↓ move · enter attach/resume · n new · x stop · v review · e rename · o web · h/⇥ projects · q quit";
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: [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)))) })) : naming !== null ? (_jsx("box", { border: true, borderStyle: "rounded", borderColor: COBALT, title: " new worktree \u2014 name ", titleAlignment: "left", backgroundColor: "transparent", height: 3, flexShrink: 0, children: _jsx("input", { focused: true, value: "", placeholder: "name the worktree, e.g. fix-auth (empty = auto)", backgroundColor: "transparent", focusedBackgroundColor: "transparent", textColor: INK, focusedTextColor: INK, placeholderColor: FAINT, cursorColor: INK, flexGrow: 1, onSubmit: (value) => {
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(StatusLine, { busy: busy, busyStarted: busyStarted, status: status }), _jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: footerText })] }));
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) => {