@sealant/mend 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,202 @@
1
+ import { HARNESS_COMMANDS, LIVE_STATUSES } from "./shared.js";
2
+ /**
3
+ * The worktree IS the session's identity (one worktree per session): its
4
+ * branch, with the noisy default prefix receded. What you are working on
5
+ * leads every row; the harness is a fact about it, not its name.
6
+ */
7
+ export const worktreeName = (branch) => branch.startsWith("mend/") ? branch.slice("mend/".length) : branch;
8
+ /**
9
+ * What to call an UNNAMED worktree: its branch is `mend/session/<uuid>` —
10
+ * noise nobody recognizes — so the auto-name label stands in, and before one
11
+ * lands, the short session id. A named worktree is always its own name.
12
+ */
13
+ export const worktreeDisplayName = (session) => {
14
+ const name = worktreeName(session.branch);
15
+ if (!name.startsWith("session/"))
16
+ return name;
17
+ return session.label ?? `session ${session.id.slice(0, 8)}`;
18
+ };
19
+ /** True when the label already IS the row's identity — don't repeat it. */
20
+ export const labelIsIdentity = (session) => worktreeName(session.branch).startsWith("session/") && session.label !== null;
21
+ export const WORKBENCH_KEY = ["workbench"];
22
+ export const fetchWorkbench = async (ctx) => {
23
+ const projects = await ctx.api("GET", "/projects");
24
+ const [fetched, services] = await Promise.all([
25
+ Promise.all(projects.map((project) => ctx.api("GET", `/projects/${project.id}`))),
26
+ ctx.api("GET", "/services"),
27
+ ]);
28
+ const servicesBySession = new Map();
29
+ for (const service of services) {
30
+ const bucket = servicesBySession.get(service.sessionId) ?? [];
31
+ bucket.push(service);
32
+ servicesBySession.set(service.sessionId, bucket);
33
+ }
34
+ // What lives in each LIVE worktree: the agent and shells come from the
35
+ // session detail (settled sessions have nothing live — no fetch for them).
36
+ const liveSessions = fetched.flatMap((detail) => detail.sessions.filter((session) => LIVE_STATUSES.has(session.status)));
37
+ const detailed = await Promise.all(liveSessions.map(async (session) => {
38
+ try {
39
+ return await ctx.api("GET", `/sessions/${session.id}`);
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ }));
45
+ const processesBySession = new Map();
46
+ for (const detail of detailed) {
47
+ if (detail === null)
48
+ continue;
49
+ processesBySession.set(detail.session.id, detail.processes.filter((process) => process.exitedAt === null && process.kind !== "service"));
50
+ }
51
+ return {
52
+ projects,
53
+ details: new Map(fetched.map((detail) => [detail.project.id, detail])),
54
+ servicesBySession,
55
+ processesBySession,
56
+ };
57
+ };
58
+ // ─── optimistic cache surgery: pure Workbench → Workbench ───────────────────
59
+ /** Apply `f` to every session row in every project detail. */
60
+ export const mapWorkbenchSessions = (data, f) => ({
61
+ ...data,
62
+ details: new Map([...data.details].map(([id, detail]) => [id, { ...detail, sessions: detail.sessions.map(f) }])),
63
+ });
64
+ export const mapProjectSessions = (data, projectId, f) => {
65
+ const detail = data.details.get(projectId);
66
+ if (detail === undefined)
67
+ return data;
68
+ const details = new Map(data.details);
69
+ details.set(projectId, { ...detail, sessions: f(detail.sessions) });
70
+ return { ...data, details };
71
+ };
72
+ export const prependSession = (data, projectId, session) => mapProjectSessions(data, projectId, (sessions) => [session, ...sessions]);
73
+ export const replaceSession = (data, projectId, oldId, session) => mapProjectSessions(data, projectId, (sessions) => sessions.map((candidate) => (candidate.id === oldId ? session : candidate)));
74
+ export const removeSession = (data, projectId, sessionId) => mapProjectSessions(data, projectId, (sessions) => sessions.filter((candidate) => candidate.id !== sessionId));
75
+ export const deriveProjects = (data) => (data?.projects ?? []).map((project) => {
76
+ const detail = data?.details.get(project.id);
77
+ const sessions = detail?.sessions ?? [];
78
+ const live = sessions.filter((s) => LIVE_STATUSES.has(s.status)).length;
79
+ const open = (detail?.annotations ?? []).reduce((sum, a) => sum + a.openComments, 0);
80
+ return { project, total: sessions.length, live, open };
81
+ });
82
+ export const bySessionRecency = (a, b) => {
83
+ const aLive = LIVE_STATUSES.has(a.status) ? 1 : 0;
84
+ const bLive = LIVE_STATUSES.has(b.status) ? 1 : 0;
85
+ if (aLive !== bLive)
86
+ return bLive - aLive;
87
+ return b.createdAt.localeCompare(a.createdAt);
88
+ };
89
+ export const toSessionItem = (data, detail) => (session) => ({
90
+ session,
91
+ annotation: detail.annotations.find((a) => a.sessionId === session.id),
92
+ services: data.servicesBySession.get(session.id) ?? [],
93
+ processes: data.processesBySession.get(session.id) ?? [],
94
+ });
95
+ export const deriveWorktrees = (data, projectId) => {
96
+ if (data === undefined || projectId === null)
97
+ return [];
98
+ const detail = data.details.get(projectId);
99
+ if (detail === undefined)
100
+ return [];
101
+ const item = toSessionItem(data, detail);
102
+ const sorted = detail.sessions.toSorted(bySessionRecency);
103
+ const groups = [];
104
+ if (detail.worktrees !== undefined) {
105
+ const claimed = new Set();
106
+ for (const worktree of detail.worktrees) {
107
+ const members = sorted.filter((session) => session.worktreeId === worktree.id);
108
+ for (const member of members)
109
+ claimed.add(member.id);
110
+ groups.push({
111
+ key: worktree.id,
112
+ id: worktree.id,
113
+ name: worktree.name.startsWith("wt-")
114
+ ? (members.find((m) => m.label !== null)?.label ??
115
+ (members[0] === undefined ? worktree.name : `session ${members[0].id.slice(0, 8)}`))
116
+ : worktree.name,
117
+ branch: worktree.branch,
118
+ baseRef: worktree.baseRef,
119
+ createdAt: worktree.createdAt,
120
+ sessions: members.map(item),
121
+ live: members.filter((session) => LIVE_STATUSES.has(session.status)).length,
122
+ annotation: detail.annotations.find((a) => members.some((m) => m.id === a.sessionId)),
123
+ });
124
+ }
125
+ // Optimistic pending rows have no worktree yet — each is its own group.
126
+ for (const session of sorted) {
127
+ if (claimed.has(session.id))
128
+ continue;
129
+ groups.push(pseudoGroup(session, item));
130
+ }
131
+ }
132
+ else {
133
+ for (const session of sorted)
134
+ groups.push(pseudoGroup(session, item));
135
+ }
136
+ return groups.toSorted((a, b) => {
137
+ if (a.live > 0 !== b.live > 0)
138
+ return a.live > 0 ? -1 : 1;
139
+ const aNewest = a.sessions[0]?.session.createdAt ?? a.createdAt;
140
+ const bNewest = b.sessions[0]?.session.createdAt ?? b.createdAt;
141
+ return bNewest.localeCompare(aNewest);
142
+ });
143
+ };
144
+ export const pseudoGroup = (session, item) => ({
145
+ key: session.id,
146
+ id: null,
147
+ name: worktreeDisplayName(session),
148
+ branch: session.branch,
149
+ baseRef: session.baseRef,
150
+ createdAt: session.createdAt,
151
+ sessions: [item(session)],
152
+ live: LIVE_STATUSES.has(session.status) ? 1 : 0,
153
+ annotation: item(session).annotation,
154
+ });
155
+ export const deriveRows = (groups) => groups.flatMap((group) => {
156
+ const only = group.sessions.length === 1 ? group.sessions[0] : undefined;
157
+ if (only !== undefined)
158
+ return [{ kind: "session", group, item: only }];
159
+ return [
160
+ { kind: "worktree", group },
161
+ ...group.sessions.map((item) => ({ kind: "session", group, item })),
162
+ ];
163
+ });
164
+ export const rowKeyOf = (row) => row.kind === "worktree" ? `wt:${row.group.key}` : `s:${row.item.session.id}`;
165
+ /** The picker's rows: resume offers the same harness first, then the crossings. */
166
+ export const deriveHarnesses = (resuming) => {
167
+ if (resuming !== null) {
168
+ const others = Object.keys(HARNESS_COMMANDS).filter((h) => h !== resuming.harness);
169
+ return [
170
+ {
171
+ harness: null,
172
+ label: resuming.harness,
173
+ hint: "same harness — native resume, conversation intact",
174
+ },
175
+ ...others.map((harness) => ({
176
+ harness,
177
+ label: harness,
178
+ hint: harness === "shell"
179
+ ? "a bash in the worktree — resume either agent from inside"
180
+ : "the conversation crosses as a distilled prompt",
181
+ })),
182
+ ];
183
+ }
184
+ return Object.keys(HARNESS_COMMANDS).map((harness) => ({
185
+ harness,
186
+ label: harness,
187
+ hint: harness === "shell"
188
+ ? "a plain bash session — new worktree, recorded"
189
+ : `mend ${harness} — new worktree, recorded session`,
190
+ }));
191
+ };
192
+ /** The worktree's folded status word: waiting wins, then running, then idle. */
193
+ export const foldGroupStatus = (group) => {
194
+ const statuses = group.sessions.map((item) => item.session.status);
195
+ if (statuses.includes("waiting"))
196
+ return "waiting";
197
+ if (statuses.includes("running") || statuses.includes("starting"))
198
+ return "running";
199
+ if (statuses.includes("idle"))
200
+ return "idle";
201
+ return statuses[0] ?? "idle";
202
+ };
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, deriveRows, deriveWorktrees, foldGroupStatus, labelIsIdentity, rowKeyOf, worktreeDisplayName, fetchWorkbench, mapWorkbenchSessions, 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
11
  import { COBALT, FAINT, INK, INK_2, MUTED, RED, RULE, WASH } from "./tui-theme.js";
11
- import { createSseParser, eventFamilies } from "./workbench-events.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.
145
- */
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.
60
+ * A lone-session worktree, combined into one row (today's density): the
61
+ * worktree name leads, everything live inside hangs underneath as facts.
151
62
  */
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);
@@ -250,11 +178,23 @@ const App = ({ ctx, onQuit }) => {
250
178
  ? 0
251
179
  : projectItems.findIndex((p) => p.project.id === homeProject.id));
252
180
  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;
181
+ const worktreeGroups = deriveWorktrees(data, selectedProject?.project.id ?? null);
182
+ const rows = deriveRows(worktreeGroups);
183
+ const rowIndexRaw = sessionKey === null ? -1 : rows.findIndex((row) => rowKeyOf(row) === sessionKey);
184
+ const rowIndex = rowIndexRaw === -1 ? 0 : rowIndexRaw;
185
+ const selectedRow = rows[rowIndex] ?? null;
186
+ const selectedGroup = selectedRow?.group ?? null;
187
+ // A worktree header still names a concrete conversation for session verbs:
188
+ // the newest live member, else the newest at all.
189
+ const selectedSession = selectedRow === null
190
+ ? null
191
+ : selectedRow.kind === "session"
192
+ ? selectedRow.item
193
+ : (selectedRow.group.sessions.find((item) => LIVE_STATUSES.has(item.session.status)) ??
194
+ selectedRow.group.sessions[0] ??
195
+ null);
257
196
  const pickerItems = picker === null ? [] : deriveHarnesses(picker.session);
197
+ const selectSession = (id) => setSessionKey(`s:${id}`);
258
198
  const say = (text) => setStatus({ text, at: Date.now() });
259
199
  const refetch = () => void queryClient.invalidateQueries({ queryKey: WORKBENCH_KEY });
260
200
  // Keep each pane's selection on screen — the one imperative escape hatch.
@@ -264,17 +204,19 @@ const App = ({ ctx, onQuit }) => {
264
204
  keepRowVisible(projectScrollRef.current, projectIndex);
265
205
  }, [projectIndex]);
266
206
  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.
207
+ // Rows vary in height (header = 1; a session row carries its process and
208
+ // service fact lines), so the scroll target is the row's y offset.
209
+ const rowHeight = (row) => row === undefined || row.kind === "worktree"
210
+ ? 1
211
+ : 1 + row.item.processes.length + row.item.services.length;
269
212
  let top = 0;
270
- for (const [index, item] of sessionItems.entries()) {
271
- if (index === sessionIndex)
213
+ for (const [index, row] of rows.entries()) {
214
+ if (index === rowIndex)
272
215
  break;
273
- top += 1 + item.services.length;
216
+ top += rowHeight(row);
274
217
  }
275
- const selectedHeight = 1 + (sessionItems[sessionIndex]?.services.length ?? 0);
276
- keepSpanVisible(sessionScrollRef.current, top, selectedHeight);
277
- }, [sessionIndex, sessionItems]);
218
+ keepSpanVisible(sessionScrollRef.current, top, rowHeight(rows[rowIndex]));
219
+ }, [rowIndex, rows]);
278
220
  const attachFlow = async (session) => {
279
221
  const short = session.id.slice(0, 8);
280
222
  lockRef.current = true;
@@ -359,7 +301,7 @@ const App = ({ ctx, onQuit }) => {
359
301
  summary: null,
360
302
  createdAt: new Date().toISOString(),
361
303
  }));
362
- setSessionKey(vars.pendingKey);
304
+ selectSession(vars.pendingKey);
363
305
  setBusy(`provisioning ${vars.harness} workspace — a first launch builds the harness image ·`);
364
306
  setBusyStarted(Date.now());
365
307
  },
@@ -370,7 +312,7 @@ const App = ({ ctx, onQuit }) => {
370
312
  },
371
313
  onSuccess: async (session, vars) => {
372
314
  patchWorkbench((current) => replaceSession(current, vars.projectId, vars.pendingKey, session));
373
- setSessionKey(session.id);
315
+ selectSession(session.id);
374
316
  setBusy(null);
375
317
  await attachIfIdle(session);
376
318
  },
@@ -383,7 +325,7 @@ const App = ({ ctx, onQuit }) => {
383
325
  onMutate: async (vars) => {
384
326
  await queryClient.cancelQueries({ queryKey: WORKBENCH_KEY });
385
327
  patchWorkbench((current) => mapWorkbenchSessions(current, (session) => session.id === vars.session.id ? { ...session, status: "starting" } : session));
386
- setSessionKey(vars.session.id);
328
+ selectSession(vars.session.id);
387
329
  setBusy(`resuming ${vars.session.id.slice(0, 8)} — a fresh workspace restores the saved state ·`);
388
330
  setBusyStarted(Date.now());
389
331
  },
@@ -394,7 +336,7 @@ const App = ({ ctx, onQuit }) => {
394
336
  },
395
337
  onSuccess: async (resumed, vars) => {
396
338
  patchWorkbench((current) => replaceSession(current, vars.projectId, vars.session.id, resumed));
397
- setSessionKey(resumed.id);
339
+ selectSession(resumed.id);
398
340
  setBusy(null);
399
341
  await attachIfIdle(resumed);
400
342
  },
@@ -424,7 +366,7 @@ const App = ({ ctx, onQuit }) => {
424
366
  onMutate: async (session) => {
425
367
  await queryClient.cancelQueries({ queryKey: WORKBENCH_KEY });
426
368
  patchWorkbench((current) => mapWorkbenchSessions(current, (row) => row.id === session.id ? { ...row, status: "stopped" } : row));
427
- say(`stopped · ${worktreeName(session.branch)} — the record and review remain`);
369
+ say(`stopped · ${worktreeDisplayName(session)} — the record and review remain`);
428
370
  },
429
371
  onError: (error) => {
430
372
  say(errorText(error));
@@ -432,7 +374,109 @@ const App = ({ ctx, onQuit }) => {
432
374
  },
433
375
  onSettled: settleRefetch,
434
376
  });
377
+ // A new conversation inside an existing worktree — the `s` key's flow.
378
+ const launchInWorktreeMutation = useMutation({
379
+ mutationFn: async (vars) => {
380
+ const argv = HARNESS_COMMANDS[vars.harness];
381
+ if (argv === undefined)
382
+ throw new Error(`unknown harness "${vars.harness}"`);
383
+ const session = await ctx.api("POST", `/worktrees/${vars.worktreeId}/sessions`, {
384
+ harness: vars.harness,
385
+ label: null,
386
+ });
387
+ await ctx.api("POST", `/sessions/${session.id}/launch`, { argv });
388
+ return session;
389
+ },
390
+ onMutate: async (vars) => {
391
+ await queryClient.cancelQueries({ queryKey: WORKBENCH_KEY });
392
+ patchWorkbench((current) => prependSession(current, vars.projectId, {
393
+ id: vars.pendingKey,
394
+ worktreeId: vars.worktreeId,
395
+ harness: vars.harness,
396
+ label: null,
397
+ branch: "joining…",
398
+ baseSha: "",
399
+ baseRef: null,
400
+ status: "starting",
401
+ summary: null,
402
+ createdAt: new Date().toISOString(),
403
+ }));
404
+ selectSession(vars.pendingKey);
405
+ setBusy(`starting ${vars.harness} in the worktree ·`);
406
+ setBusyStarted(Date.now());
407
+ },
408
+ onError: (error, vars) => {
409
+ patchWorkbench((current) => removeSession(current, vars.projectId, vars.pendingKey));
410
+ setBusy(null);
411
+ say(errorText(error));
412
+ },
413
+ onSuccess: async (session, vars) => {
414
+ patchWorkbench((current) => replaceSession(current, vars.projectId, vars.pendingKey, session));
415
+ selectSession(session.id);
416
+ setBusy(null);
417
+ await attachIfIdle(session);
418
+ },
419
+ onSettled: settleRefetch,
420
+ });
421
+ // The one explicit destructive act. Against a pre-worktree server the
422
+ // session delete IS the old combined removal — same key, old semantics.
423
+ const removeWorktreeMutation = useMutation({
424
+ mutationFn: (group) => group.id !== null
425
+ ? ctx.api("DELETE", `/worktrees/${group.id}`)
426
+ : ctx.api("DELETE", `/sessions/${group.sessions[0]?.session.id ?? ""}`),
427
+ onMutate: (group) => {
428
+ say(`removing worktree · ${group.name}`);
429
+ },
430
+ onError: (error) => {
431
+ say(errorText(error));
432
+ refetch();
433
+ },
434
+ onSuccess: (_result, group) => {
435
+ say(`removed · ${group.name}`);
436
+ refetch();
437
+ },
438
+ onSettled: settleRefetch,
439
+ });
440
+ /** Worktree removal is armed like stops: the first press states the facts. */
441
+ const [removeArmed, setRemoveArmed] = useState(null);
442
+ const armRemove = () => {
443
+ const group = selectedGroup;
444
+ if (group === null || group.sessions.some((item) => isPendingId(item.session.id)))
445
+ return;
446
+ if (group.live > 0) {
447
+ say(`${group.live} session${group.live === 1 ? "" : "s"} live — stop them first · ${group.name}`);
448
+ return;
449
+ }
450
+ if (removeArmed === group.key) {
451
+ setRemoveArmed(null);
452
+ removeWorktreeMutation.mutate(group);
453
+ return;
454
+ }
455
+ setRemoveArmed(group.key);
456
+ const facts = group.sessions.length === 1
457
+ ? "its session and change go with it"
458
+ : `${group.sessions.length} sessions and the change go with it`;
459
+ say(`press again to remove worktree · ${group.name} — ${facts}`);
460
+ };
435
461
  const armStop = () => {
462
+ // A worktree header arms a stop of EVERY live conversation in it.
463
+ if (selectedRow?.kind === "worktree") {
464
+ const group = selectedRow.group;
465
+ const live = group.sessions.filter((item) => LIVE_STATUSES.has(item.session.status));
466
+ if (live.length === 0) {
467
+ say("nothing to stop — the worktree is settled");
468
+ return;
469
+ }
470
+ if (stopArmed === `wt:${group.key}`) {
471
+ setStopArmed(null);
472
+ for (const item of live)
473
+ stopMutation.mutate(item.session);
474
+ return;
475
+ }
476
+ setStopArmed(`wt:${group.key}`);
477
+ say(`press again to stop ${live.length} live session${live.length === 1 ? "" : "s"} · ${group.name}`);
478
+ return;
479
+ }
436
480
  const item = selectedSession;
437
481
  if (item === null || isPendingId(item.session.id))
438
482
  return;
@@ -446,7 +490,7 @@ const App = ({ ctx, onQuit }) => {
446
490
  return;
447
491
  }
448
492
  setStopArmed(item.session.id);
449
- say(`press again to stop · ${worktreeName(item.session.branch)}`);
493
+ say(`press again to stop · ${worktreeDisplayName(item.session)}`);
450
494
  };
451
495
  const startSession = (projectId, harness) => {
452
496
  setFocus("sessions");
@@ -469,8 +513,8 @@ const App = ({ ctx, onQuit }) => {
469
513
  const label = value.trim() === "" ? null : value.trim();
470
514
  renameMutation.mutate({ session, label });
471
515
  };
472
- const openPicker = (session) => {
473
- setPicker({ session });
516
+ const openPicker = (session, worktree) => {
517
+ setPicker(worktree === undefined ? { session } : { session, worktree });
474
518
  setPickerIndex(0);
475
519
  };
476
520
  const moveSelection = (delta) => {
@@ -489,12 +533,12 @@ const App = ({ ctx, onQuit }) => {
489
533
  }
490
534
  return;
491
535
  }
492
- if (sessionItems.length === 0)
536
+ if (rows.length === 0)
493
537
  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);
538
+ const next = Math.max(0, Math.min(rows.length - 1, rowIndex + delta));
539
+ const row = rows[next];
540
+ if (row !== undefined)
541
+ setSessionKey(rowKeyOf(row));
498
542
  };
499
543
  const activate = () => {
500
544
  if (picker !== null) {
@@ -503,7 +547,18 @@ const App = ({ ctx, onQuit }) => {
503
547
  if (choice === undefined || projectId === undefined)
504
548
  return;
505
549
  setPicker(null);
506
- if (picker.session === null) {
550
+ if (picker.worktree !== undefined && picker.worktree.id !== null) {
551
+ if (choice.harness !== null) {
552
+ setFocus("sessions");
553
+ launchInWorktreeMutation.mutate({
554
+ projectId,
555
+ worktreeId: picker.worktree.id,
556
+ harness: choice.harness,
557
+ pendingKey: pendingId(),
558
+ });
559
+ }
560
+ }
561
+ else if (picker.session === null) {
507
562
  if (choice.harness !== null)
508
563
  startSession(projectId, choice.harness);
509
564
  }
@@ -516,6 +571,18 @@ const App = ({ ctx, onQuit }) => {
516
571
  setFocus("sessions");
517
572
  return;
518
573
  }
574
+ if (selectedRow?.kind === "worktree") {
575
+ // Enter on the place: attach its newest live conversation, or open a
576
+ // new one when nothing is live.
577
+ const live = selectedRow.group.sessions.find((item) => LIVE_STATUSES.has(item.session.status));
578
+ if (live !== undefined) {
579
+ void attachFlow(live.session);
580
+ }
581
+ else if (selectedRow.group.id !== null) {
582
+ openPicker(null, selectedRow.group);
583
+ }
584
+ return;
585
+ }
519
586
  const item = selectedSession;
520
587
  if (item === null)
521
588
  return;
@@ -571,6 +638,9 @@ const App = ({ ctx, onQuit }) => {
571
638
  // vim-up; the shift is the deliberateness the arm-confirm then doubles.
572
639
  if (key.shift === true && key.name === "k")
573
640
  return armStop();
641
+ // Shift+D: remove the selected worktree — the one explicit destructive act.
642
+ if (key.shift === true && key.name === "d")
643
+ return armRemove();
574
644
  switch (key.name) {
575
645
  case "q":
576
646
  return onQuit();
@@ -606,6 +676,18 @@ const App = ({ ctx, onQuit }) => {
606
676
  if (selectedProject !== null)
607
677
  setNaming({ projectId: selectedProject.project.id });
608
678
  return;
679
+ case "s": {
680
+ // A new conversation inside the selected worktree.
681
+ const group = selectedGroup;
682
+ if (group === null)
683
+ return;
684
+ if (group.id === null) {
685
+ say("this server predates shared worktrees — n starts a new one");
686
+ return;
687
+ }
688
+ openPicker(null, group);
689
+ return;
690
+ }
609
691
  case "e": {
610
692
  const item = selectedSession;
611
693
  if (item !== null && !isPendingId(item.session.id))
@@ -628,7 +710,7 @@ const App = ({ ctx, onQuit }) => {
628
710
  say("still provisioning — nothing to review yet");
629
711
  return;
630
712
  }
631
- const target = reviewTargetForSession(item.session, item.annotation, selectedProject?.project.name ?? "project");
713
+ const target = reviewTargetForSession(item.session, selectedGroup?.annotation ?? item.annotation, selectedProject?.project.name ?? "project");
632
714
  if (target === null) {
633
715
  say("this session has no reviewable change yet");
634
716
  return;
@@ -650,14 +732,16 @@ const App = ({ ctx, onQuit }) => {
650
732
  const sessionsTitle = selectedProject === null
651
733
  ? "sessions"
652
734
  : `sessions — ${selectedProject.project.name}${selectedProject.live > 0 ? ` · ${selectedProject.live} live` : ""}`;
653
- const detailTitle = selectedSession === null
735
+ const detailTitle = selectedSession === null || selectedGroup === null
654
736
  ? "session"
655
- : `worktree — ${worktreeName(selectedSession.session.branch)} · ${selectedSession.session.harness} ${selectedSession.session.id.slice(0, 8)}`;
737
+ : `worktree — ${selectedGroup.name} · ${selectedSession.session.harness} ${selectedSession.session.id.slice(0, 8)}`;
656
738
  const pickerTitle = picker === null
657
739
  ? ""
658
- : picker.session === null
659
- ? "new session — pick a harness"
660
- : `resume ${picker.session.id.slice(0, 8)} — pick a harness`;
740
+ : picker.worktree !== undefined
741
+ ? `new session in ${picker.worktree.name} — pick a harness`
742
+ : picker.session === null
743
+ ? "new session — pick a harness"
744
+ : `resume ${picker.session.id.slice(0, 8)} — pick a harness`;
661
745
  const footerText = editing !== null
662
746
  ? " enter save · esc cancel"
663
747
  : naming !== null
@@ -666,7 +750,7 @@ const App = ({ ctx, onQuit }) => {
666
750
  ? " ↑↓ move · enter start · esc cancel"
667
751
  : focus === "projects"
668
752
  ? " ↑↓ 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";
753
+ : " ↑↓ move · enter attach/resume · n new worktree · s session here · x stop · ⇧D remove · v review · e rename · o web · q quit";
670
754
  const loadFailure = data === undefined && failureReason !== null
671
755
  ? failureReason instanceof Error
672
756
  ? failureReason.message
@@ -678,7 +762,7 @@ const App = ({ ctx, onQuit }) => {
678
762
  refetch();
679
763
  }, onQuit: onQuit }));
680
764
  }
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) => {
765
+ 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)))) })) : 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
766
  if (typeof value === "string")
683
767
  submitWorktreeName(value);
684
768
  } }) })) : 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) => {
package/dist/main.js CHANGED
@@ -11,7 +11,7 @@ import { formatLoadReport } from "./env.js";
11
11
  import { loginCommand } from "./login.js";
12
12
  import { pairCommand, qrCommand } from "./pair.js";
13
13
  import { isComposeFile, proposeFromCompose, proposeFromPackageJson, proposeFromWorkspacePackage, renderMendToml, workspaceGlobs, } from "./service-init.js";
14
- import { agentIsLive, agentOutcome, cwdFacts, gitTopLevel, HARNESS_COMMANDS, isDetachChunk, LIVE_STATUSES, matchProjectByCwd, normalizeProjectName, parseLaunchArgs, } from "./shared.js";
14
+ import { agentIsLive, agentOutcome, cwdFacts, gitTopLevel, HARNESS_COMMANDS, isDetachChunk, LIVE_STATUSES, sessionDisplayName, matchProjectByCwd, normalizeProjectName, parseLaunchArgs, } from "./shared.js";
15
15
  import { sshCommand } from "./ssh-setup.js";
16
16
  // `$XDG_CONFIG_HOME/mend`, default `~/.config/mend`; a pre-XDG `~/.mend` stays authoritative
17
17
  // when it is the only one present (mirrors @mend/store's resolver — the CLI stays dependency-light).
@@ -284,7 +284,37 @@ const launch = async (config, harness, args) => {
284
284
  say(`${green("✓")} project ${project.name} ${dim(`· ${project.defaultBranch}${parsed.project === null ? " · from cwd" : ""}`)}`);
285
285
  // The worktree's name comes first, then the session details — it is the
286
286
  // identity every list leads with. `mend run` stays scriptable: flag only.
287
- const worktreeName = harness === "run" ? parsed.name : (parsed.name ?? (await askWorktreeName()));
287
+ // `--worktree` insists on joining: the name must already exist here.
288
+ let worktreeName;
289
+ if (parsed.worktree !== null) {
290
+ const detail = await api(config, "GET", `/projects/${project.id}`);
291
+ const existing = detail.worktrees ?? [];
292
+ if (detail.worktrees === undefined) {
293
+ return fail("this server predates shared worktrees — use --name instead");
294
+ }
295
+ const match = existing.find((worktree) => worktree.name === parsed.worktree) ??
296
+ (existing.filter((worktree) => worktree.name.startsWith(parsed.worktree ?? "")).length === 1
297
+ ? existing.find((worktree) => worktree.name.startsWith(parsed.worktree ?? ""))
298
+ : undefined);
299
+ if (match === undefined) {
300
+ const names = existing.map((worktree) => worktree.name).join(", ");
301
+ return fail(`no worktree matches "${parsed.worktree}" in ${project.name}${names === "" ? "" : ` — have: ${names}`}`);
302
+ }
303
+ worktreeName = match.name;
304
+ }
305
+ else {
306
+ worktreeName = harness === "run" ? parsed.name : (parsed.name ?? (await askWorktreeName()));
307
+ }
308
+ // Say when the name joins an existing worktree — a join is a fact worth
309
+ // stating before the session exists, not a surprise in the tree later.
310
+ if (worktreeName !== null) {
311
+ const detail = await api(config, "GET", `/projects/${project.id}`).catch(() => null);
312
+ const joined = detail?.worktrees?.find((worktree) => worktree.name === worktreeName);
313
+ if (joined !== undefined) {
314
+ const members = (detail?.sessions ?? []).filter((candidate) => candidate.worktreeId === joined.id).length;
315
+ say(`${green("✓")} joins worktree ${joined.name} ${dim(`· ${members} session${members === 1 ? "" : "s"} · branch ${joined.branch}`)}`);
316
+ }
317
+ }
288
318
  const lifecycle = harness === "run"
289
319
  ? "background"
290
320
  : parsed.detach
@@ -593,7 +623,7 @@ const attach = async (config, args) => {
593
623
  const match = sessions.find((s) => s.id.startsWith(prefix));
594
624
  if (match === undefined)
595
625
  return fail(`no active session matches "${prefix}"`);
596
- say(`${green("✓")} attaching to ${match.branch} · ${match.harness} ${dim(match.id.slice(0, 8))}${detachHint()}`);
626
+ say(`${green("✓")} attaching to ${sessionDisplayName(match)} · ${match.harness} ${dim(match.id.slice(0, 8))}${detachHint()}`);
597
627
  say("");
598
628
  await attachOrExit(config, match.id, match.harness);
599
629
  exitAfterSessionEnd(config, match.id);
@@ -2067,7 +2097,7 @@ const rejoinCommand = async (config, args) => {
2067
2097
  if (session === undefined)
2068
2098
  return fail("session selection failed");
2069
2099
  const alreadyLive = agentLiveIn(detail, session);
2070
- say(`${green("✓")} rejoining ${session.branch} · ${session.harness} ${dim(session.id.slice(0, 8))} · ${alreadyLive ? "already live" : "restoring"}`);
2100
+ say(`${green("✓")} rejoining ${sessionDisplayName(session)} · ${session.harness} ${dim(session.id.slice(0, 8))} · ${alreadyLive ? "already live" : "restoring"}`);
2071
2101
  say(`${cobalt(" watch")} · ${config.url}/sessions/${session.id}`);
2072
2102
  let restored = false;
2073
2103
  const currentAgent = detail.annotations.find((annotation) => annotation.sessionId === session.id)?.currentAgent ??
@@ -2168,6 +2198,15 @@ const printSessionRow = (row) => {
2168
2198
  */
2169
2199
  const sessionsCommand = async (config, args) => {
2170
2200
  const all = args.includes("--all");
2201
+ // v1 stays byte-stable for pinned integrations; v2 is the worktree envelope.
2202
+ if (args.includes("--json=v2")) {
2203
+ const projectFlag = args.indexOf("--project");
2204
+ const projectName = projectFlag !== -1 && args[projectFlag + 1] !== undefined
2205
+ ? String(args[projectFlag + 1])
2206
+ : null;
2207
+ say(JSON.stringify(await buildWorktreesJson(config, projectName), null, 2));
2208
+ return;
2209
+ }
2171
2210
  const json = args.includes("--json");
2172
2211
  const projectFlag = args.indexOf("--project");
2173
2212
  const projectName = projectFlag !== -1 && args[projectFlag + 1] !== undefined
@@ -2243,6 +2282,117 @@ const sessionsCommand = async (config, args) => {
2243
2282
  for (const row of rows)
2244
2283
  printSessionRow(row);
2245
2284
  };
2285
+ /**
2286
+ * The worktree-grouped view both `mend worktrees --json` and
2287
+ * `mend sessions --json=v2` emit. Against a pre-worktree server every session
2288
+ * becomes its own pseudo worktree (`id: null`) — the envelope shape is stable
2289
+ * either way, so integrations pin on `version`, not server age.
2290
+ */
2291
+ const toWorktreeJsonSession = (session) => ({
2292
+ id: session.id,
2293
+ harness: session.harness,
2294
+ label: session.label,
2295
+ status: session.status,
2296
+ summary: session.summary,
2297
+ createdAt: session.createdAt,
2298
+ });
2299
+ const buildWorktreesJson = async (config, projectName) => {
2300
+ const projects = await api(config, "GET", "/projects");
2301
+ const scope = projectName === null ? projects : projects.filter((p) => p.name === projectName);
2302
+ const details = await Promise.all(scope.map((p) => api(config, "GET", `/projects/${p.id}`)));
2303
+ const worktrees = [];
2304
+ for (const detail of details) {
2305
+ const reviewOf = (sessionIds) => {
2306
+ const facts = detail.annotations.find((a) => sessionIds.has(a.sessionId));
2307
+ return facts === undefined
2308
+ ? null
2309
+ : {
2310
+ openComments: facts.openComments,
2311
+ totalComments: facts.totalComments,
2312
+ pendingFollowUp: detail.annotations.some((a) => sessionIds.has(a.sessionId) && a.pendingFollowUp),
2313
+ };
2314
+ };
2315
+ const urlOf = (sessionIds) => {
2316
+ const first = [...sessionIds][0];
2317
+ return first === undefined ? null : `${config.url.replace(/\/$/, "")}/sessions/${first}`;
2318
+ };
2319
+ if (detail.worktrees !== undefined) {
2320
+ for (const worktree of detail.worktrees) {
2321
+ const members = detail.sessions.filter((session) => session.worktreeId === worktree.id);
2322
+ const ids = new Set(members.map((session) => session.id));
2323
+ worktrees.push({
2324
+ id: worktree.id,
2325
+ name: worktree.name,
2326
+ projectId: detail.project.id,
2327
+ projectName: detail.project.name,
2328
+ branch: worktree.branch,
2329
+ baseSha: worktree.baseSha,
2330
+ baseRef: worktree.baseRef,
2331
+ createdAt: worktree.createdAt,
2332
+ reviewUrl: urlOf(ids),
2333
+ review: reviewOf(ids),
2334
+ sessions: members
2335
+ .toSorted((a, b) => b.createdAt.localeCompare(a.createdAt))
2336
+ .map(toWorktreeJsonSession),
2337
+ });
2338
+ }
2339
+ }
2340
+ else {
2341
+ for (const session of detail.sessions) {
2342
+ const ids = new Set([session.id]);
2343
+ worktrees.push({
2344
+ id: null,
2345
+ name: session.worktree,
2346
+ projectId: detail.project.id,
2347
+ projectName: detail.project.name,
2348
+ branch: session.branch,
2349
+ baseSha: session.baseSha,
2350
+ baseRef: session.baseRef,
2351
+ createdAt: session.createdAt,
2352
+ reviewUrl: urlOf(ids),
2353
+ review: reviewOf(ids),
2354
+ sessions: [toWorktreeJsonSession(session)],
2355
+ });
2356
+ }
2357
+ }
2358
+ }
2359
+ return { version: 2, worktrees };
2360
+ };
2361
+ /** `mend worktrees [--project <p>] [--json]` — the container-first listing. */
2362
+ const worktreesCommand = async (config, args) => {
2363
+ const json = args.includes("--json");
2364
+ const projectFlag = args.indexOf("--project");
2365
+ const projectName = projectFlag !== -1 && args[projectFlag + 1] !== undefined
2366
+ ? String(args[projectFlag + 1])
2367
+ : null;
2368
+ const payload = await buildWorktreesJson(config, projectName);
2369
+ if (json) {
2370
+ say(JSON.stringify(payload, null, 2));
2371
+ return;
2372
+ }
2373
+ if (payload.worktrees.length === 0) {
2374
+ say(dim("no worktrees yet — mend claude --name <worktree> starts one"));
2375
+ return;
2376
+ }
2377
+ for (const worktree of payload.worktrees) {
2378
+ const live = worktree.sessions.filter((session) => ACTIVE_STATUSES.has(session.status)).length;
2379
+ const facts = [
2380
+ `${worktree.sessions.length} session${worktree.sessions.length === 1 ? "" : "s"}`,
2381
+ ];
2382
+ if (live > 0)
2383
+ facts.push(green(`${live} live`));
2384
+ if (worktree.review !== null && worktree.review.openComments > 0) {
2385
+ facts.push(amber(`${worktree.review.openComments} open`));
2386
+ }
2387
+ const base = worktree.baseRef ?? worktree.baseSha.slice(0, 12);
2388
+ say(`${worktree.name.padEnd(24)} ${worktree.projectName} ${dim(`${worktree.branch} · base ${base}`)} ${facts.join(dim(" · "))}`);
2389
+ for (const session of worktree.sessions) {
2390
+ const status = session.status.padEnd(9);
2391
+ const name = session.label ?? `session ${session.id.slice(0, 8)}`;
2392
+ say(` └ ${session.harness.padEnd(8)} ${dim(session.id.slice(0, 8))} ${ACTIVE_STATUSES.has(session.status) ? green(status) : dim(status)} ${name}`);
2393
+ }
2394
+ }
2395
+ };
2246
2396
  // ─── dashboard: the live TUI on bare `mend` ─────────────────────────────────
2247
2397
  const hasNodeFfi = () => {
2248
2398
  try {
@@ -2295,11 +2445,13 @@ start
2295
2445
  mend adopt [source] [--name <name>] [--auth ambient|mend-key|bridge]
2296
2446
  adopt a repository into the store (default: cwd; any git
2297
2447
  URL — GitHub, GitLab, self-hosted, ssh://, a local path)
2298
- mend codex|claude|opencode ["prompt"] [--name <worktree>] [--model <id>]
2299
- [--effort low|medium|high|xhigh|max]
2448
+ mend codex|claude|opencode ["prompt"] [--name <worktree>] [--worktree <existing>]
2449
+ [--model <id>] [--effort low|medium|high|xhigh|max]
2300
2450
  [--base <ref>] [--ask] [--fast] [--detach|-d] [--foreground]
2301
- new session worktree + launch the harness in it; the
2302
- worktree's name is asked first (--name skips the ask), a
2451
+ launch the harness in a worktree; the worktree's name is
2452
+ asked first (--name skips the ask — an EXISTING name joins
2453
+ that worktree as a new session; --worktree joins only,
2454
+ failing if absent), a
2303
2455
  quoted prompt becomes its first message,
2304
2456
  --ask restores the harness's permission prompts, --fast
2305
2457
  requests priority processing (codex service tier),
@@ -2325,6 +2477,9 @@ everything else
2325
2477
  mend keys share relay THIS machine's ssh-agent to the server (bridge mode:
2326
2478
  hardware keys sign here; Ctrl-C stops sharing)
2327
2479
  mend run -- <command...> same, with an arbitrary command
2480
+ mend worktrees [--project <p>] [--json]
2481
+ every worktree and the sessions inside it (mend sessions
2482
+ --json stays the v1 flat list; --json=v2 groups like this)
2328
2483
  mend attach <session-id-prefix> reattach this terminal to a running session
2329
2484
  mend stop <session-id-prefix> | --all [--project <p>]
2330
2485
  stop the agent — the workspace harvests and closes; the
@@ -2421,6 +2576,8 @@ const main = async () => {
2421
2576
  case "sessions":
2422
2577
  case "status":
2423
2578
  return sessionsCommand(config, rest);
2579
+ case "worktrees":
2580
+ return worktreesCommand(config, rest);
2424
2581
  case undefined:
2425
2582
  case "ui":
2426
2583
  return dashboard(config);
package/dist/shared.js CHANGED
@@ -35,6 +35,7 @@ const LAUNCH_ERROR = {
35
35
  effort: null,
36
36
  base: null,
37
37
  name: null,
38
+ worktree: null,
38
39
  ask: false,
39
40
  fast: false,
40
41
  detach: false,
@@ -58,6 +59,7 @@ export const parseLaunchArgs = (args) => {
58
59
  let effort = null;
59
60
  let base = null;
60
61
  let workName = null;
62
+ let joinWorktree = null;
61
63
  let ask = false;
62
64
  let fast = false;
63
65
  let detach = false;
@@ -84,7 +86,8 @@ export const parseLaunchArgs = (args) => {
84
86
  arg === "--model" ||
85
87
  arg === "--effort" ||
86
88
  arg === "--base" ||
87
- arg === "--name") {
89
+ arg === "--name" ||
90
+ arg === "--worktree") {
88
91
  const value = flagArgs[index + 1];
89
92
  if (value === undefined)
90
93
  return { ...LAUNCH_ERROR, error: `${arg} needs a value` };
@@ -96,6 +99,8 @@ export const parseLaunchArgs = (args) => {
96
99
  effort = value;
97
100
  else if (arg === "--name")
98
101
  workName = normalizeProjectName(value);
102
+ else if (arg === "--worktree")
103
+ joinWorktree = value;
99
104
  else
100
105
  base = value;
101
106
  index += 1;
@@ -123,6 +128,12 @@ export const parseLaunchArgs = (args) => {
123
128
  if (detach && foreground) {
124
129
  return { ...LAUNCH_ERROR, error: "--detach and --foreground contradict — pick one" };
125
130
  }
131
+ if (workName !== null && joinWorktree !== null) {
132
+ return {
133
+ ...LAUNCH_ERROR,
134
+ error: "--name and --worktree contradict — --name names (or joins), --worktree only joins",
135
+ };
136
+ }
126
137
  return {
127
138
  project,
128
139
  prompt,
@@ -130,6 +141,7 @@ export const parseLaunchArgs = (args) => {
130
141
  effort,
131
142
  base,
132
143
  name: workName,
144
+ worktree: joinWorktree,
133
145
  ask,
134
146
  fast,
135
147
  detach,
@@ -161,6 +173,19 @@ export const isDetachChunk = (data) => {
161
173
  }
162
174
  return false;
163
175
  };
176
+ /**
177
+ * What to call a session's worktree in banners and rows: a NAMED worktree is
178
+ * its branch minus the `mend/` prefix; an unnamed one (`mend/session/<uuid>`)
179
+ * is called by its auto-name label, or its short id before one lands.
180
+ */
181
+ export const sessionDisplayName = (session) => {
182
+ if (session.branch.startsWith("mend/") && !session.branch.startsWith("mend/session/")) {
183
+ return session.branch.slice("mend/".length);
184
+ }
185
+ if (!session.branch.startsWith("mend/session/"))
186
+ return session.branch;
187
+ return session.label ?? `session ${session.id.slice(0, 8)}`;
188
+ };
164
189
  export const LIVE_STATUSES = new Set([
165
190
  "starting",
166
191
  "running",
@@ -55,6 +55,7 @@ export const eventFamilies = (payload) => {
55
55
  return [];
56
56
  switch (parsed.type) {
57
57
  case "project":
58
+ case "worktree":
58
59
  case "session-process":
59
60
  return ["workbench"];
60
61
  // Session facts feed both surfaces: rows and annotations on the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sealant/mend",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "The mend CLI: adopt a repository into Mend's store, launch your coding agent in a recorded session worktree, detach and reattach from any terminal",
5
5
  "keywords": [
6
6
  "claude-code",