@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.
- package/dist/dashboard-model.js +202 -0
- package/dist/dashboard.js +222 -138
- package/dist/main.js +165 -8
- package/dist/shared.js +26 -1
- package/dist/workbench-events.js +1 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
38
|
-
const fetchWorkbench = async (ctx) => {
|
|
39
|
-
const projects = await ctx.api("GET", "/projects");
|
|
40
|
-
const [fetched, services] = await Promise.all([
|
|
41
|
-
Promise.all(projects.map((project) => ctx.api("GET", `/projects/${project.id}`))),
|
|
42
|
-
ctx.api("GET", "/services"),
|
|
43
|
-
]);
|
|
44
|
-
const servicesBySession = new Map();
|
|
45
|
-
for (const service of services) {
|
|
46
|
-
const bucket = servicesBySession.get(service.sessionId) ?? [];
|
|
47
|
-
bucket.push(service);
|
|
48
|
-
servicesBySession.set(service.sessionId, bucket);
|
|
49
|
-
}
|
|
50
|
-
return {
|
|
51
|
-
projects,
|
|
52
|
-
details: new Map(fetched.map((detail) => [detail.project.id, detail])),
|
|
53
|
-
servicesBySession,
|
|
54
|
-
};
|
|
55
|
-
};
|
|
56
|
-
// ─── optimistic cache surgery: pure Workbench → Workbench ───────────────────
|
|
57
|
-
/** Apply `f` to every session row in every project detail. */
|
|
58
|
-
const mapWorkbenchSessions = (data, f) => ({
|
|
59
|
-
...data,
|
|
60
|
-
details: new Map([...data.details].map(([id, detail]) => [id, { ...detail, sessions: detail.sessions.map(f) }])),
|
|
61
|
-
});
|
|
62
|
-
const mapProjectSessions = (data, projectId, f) => {
|
|
63
|
-
const detail = data.details.get(projectId);
|
|
64
|
-
if (detail === undefined)
|
|
65
|
-
return data;
|
|
66
|
-
const details = new Map(data.details);
|
|
67
|
-
details.set(projectId, { ...detail, sessions: f(detail.sessions) });
|
|
68
|
-
return { ...data, details };
|
|
69
|
-
};
|
|
70
|
-
const prependSession = (data, projectId, session) => mapProjectSessions(data, projectId, (sessions) => [session, ...sessions]);
|
|
71
|
-
const replaceSession = (data, projectId, oldId, session) => mapProjectSessions(data, projectId, (sessions) => sessions.map((candidate) => (candidate.id === oldId ? session : candidate)));
|
|
72
|
-
const removeSession = (data, projectId, sessionId) => mapProjectSessions(data, projectId, (sessions) => sessions.filter((candidate) => candidate.id !== sessionId));
|
|
73
|
-
const deriveProjects = (data) => (data?.projects ?? []).map((project) => {
|
|
74
|
-
const detail = data?.details.get(project.id);
|
|
75
|
-
const sessions = detail?.sessions ?? [];
|
|
76
|
-
const live = sessions.filter((s) => LIVE_STATUSES.has(s.status)).length;
|
|
77
|
-
const open = (detail?.annotations ?? []).reduce((sum, a) => sum + a.openComments, 0);
|
|
78
|
-
return { project, total: sessions.length, live, open };
|
|
79
|
-
});
|
|
80
|
-
const deriveSessions = (data, projectId) => {
|
|
81
|
-
if (data === undefined || projectId === null)
|
|
82
|
-
return [];
|
|
83
|
-
const detail = data.details.get(projectId);
|
|
84
|
-
if (detail === undefined)
|
|
85
|
-
return [];
|
|
86
|
-
return detail.sessions
|
|
87
|
-
.toSorted((a, b) => {
|
|
88
|
-
const aLive = LIVE_STATUSES.has(a.status) ? 1 : 0;
|
|
89
|
-
const bLive = LIVE_STATUSES.has(b.status) ? 1 : 0;
|
|
90
|
-
if (aLive !== bLive)
|
|
91
|
-
return bLive - aLive;
|
|
92
|
-
return b.createdAt.localeCompare(a.createdAt);
|
|
93
|
-
})
|
|
94
|
-
.map((session) => ({
|
|
95
|
-
session,
|
|
96
|
-
annotation: detail.annotations.find((a) => a.sessionId === session.id),
|
|
97
|
-
services: data.servicesBySession.get(session.id) ?? [],
|
|
98
|
-
}));
|
|
99
|
-
};
|
|
100
|
-
/** The picker's rows: resume offers the same harness first, then the crossings. */
|
|
101
|
-
const deriveHarnesses = (resuming) => {
|
|
102
|
-
if (resuming !== null) {
|
|
103
|
-
const others = Object.keys(HARNESS_COMMANDS).filter((h) => h !== resuming.harness);
|
|
104
|
-
return [
|
|
105
|
-
{
|
|
106
|
-
harness: null,
|
|
107
|
-
label: resuming.harness,
|
|
108
|
-
hint: "same harness — native resume, conversation intact",
|
|
109
|
-
},
|
|
110
|
-
...others.map((harness) => ({
|
|
111
|
-
harness,
|
|
112
|
-
label: harness,
|
|
113
|
-
hint: harness === "shell"
|
|
114
|
-
? "a bash in the worktree — resume either agent from inside"
|
|
115
|
-
: "the conversation crosses as a distilled prompt",
|
|
116
|
-
})),
|
|
117
|
-
];
|
|
118
|
-
}
|
|
119
|
-
return Object.keys(HARNESS_COMMANDS).map((harness) => ({
|
|
120
|
-
harness,
|
|
121
|
-
label: harness,
|
|
122
|
-
hint: harness === "shell"
|
|
123
|
-
? "a plain bash session — new worktree, recorded"
|
|
124
|
-
: `mend ${harness} — new worktree, recorded session`,
|
|
125
|
-
}));
|
|
126
|
-
};
|
|
37
|
+
import { createSseParser, eventFamilies } from "./workbench-events.js";
|
|
127
38
|
// ─── panes and rows ─────────────────────────────────────────────────────────
|
|
128
39
|
const Gutter = ({ selected }) => (_jsx("span", { fg: selected ? COBALT : FAINT, children: selected ? "▌ " : " " }));
|
|
129
40
|
/**
|
|
@@ -138,23 +49,39 @@ const paneScrollStyle = {
|
|
|
138
49
|
contentOptions: { backgroundColor: "transparent" },
|
|
139
50
|
};
|
|
140
51
|
const ProjectRow = ({ item, selected, nameWidth, }) => (_jsx("box", { height: 1, flexShrink: 0, backgroundColor: selected ? WASH : "transparent", children: _jsxs("text", { height: 1, bg: "transparent", children: [_jsx(Gutter, { selected: selected }), _jsx("span", { fg: INK, children: item.project.name.padEnd(nameWidth) }), _jsx("span", { fg: item.total === 0 ? FAINT : MUTED, children: ` ${item.total}` }), item.live > 0 ? _jsx("span", { fg: MUTED, children: ` · ${item.live} live` }) : null, item.open > 0 ? _jsx("span", { fg: MUTED, children: ` · ${item.open}` }) : null] }) }));
|
|
52
|
+
const ProcessLines = ({ processes, services, indent, }) => (_jsxs(_Fragment, { children: [processes.map((process) => (_jsx("box", { height: 1, flexShrink: 0, backgroundColor: "transparent", children: _jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { fg: FAINT, children: indent }), _jsx("span", { fg: INK_2, children: process.kind === "shell"
|
|
53
|
+
? (process.label ?? "shell")
|
|
54
|
+
: (process.harness ?? process.kind) }), _jsxs("span", { fg: FAINT, children: [process.kind === "agent-protocol"
|
|
55
|
+
? " · protocol"
|
|
56
|
+
: process.kind === "agent-external"
|
|
57
|
+
? " · external"
|
|
58
|
+
: "", ` ${process.status}`] })] }) }, process.id))), services.map((service) => (_jsx("box", { height: 1, flexShrink: 0, backgroundColor: "transparent", children: _jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { fg: FAINT, children: indent }), _jsx("span", { fg: service.status === "reachable" ? INK_2 : MUTED, children: `${service.label ?? service.id.slice(0, 6)} :${service.workspacePort ?? "?"}${service.protocol === "udp" ? "u" : ""}→${service.hostPort ?? "?"}` }), _jsx("span", { fg: FAINT, children: ` ${service.status}` })] }) }, service.id)))] }));
|
|
141
59
|
/**
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
* leads every row; the harness is a fact about it, not its name.
|
|
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
|
|
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
|
|
157
|
-
return (_jsxs("box", { flexShrink: 0, flexDirection: "column", backgroundColor: "transparent", children: [_jsx("box", { height: 1, flexShrink: 0, backgroundColor: selected ? WASH : "transparent", children: _jsxs("text", { height: 1, bg: "transparent", children: [_jsx(Gutter, { selected: selected }), _jsx("span", { fg:
|
|
83
|
+
const name = session.label ?? `session ${session.id.slice(0, 8)}`;
|
|
84
|
+
return (_jsxs("box", { flexShrink: 0, flexDirection: "column", backgroundColor: "transparent", children: [_jsx("box", { height: 1, flexShrink: 0, backgroundColor: selected ? WASH : "transparent", children: _jsxs("text", { height: 1, bg: "transparent", children: [_jsx(Gutter, { selected: selected }), _jsx("span", { fg: FAINT, children: " └ " }), _jsx("span", { fg: INK, children: name.slice(0, 26).padEnd(27) }), _jsx("span", { fg: color, children: session.status.padEnd(10) }), _jsx("span", { fg: FAINT, children: age.padEnd(9) }), _jsx("span", { fg: MUTED, children: session.harness.padEnd(10) })] }) }), _jsx(ProcessLines, { processes: processes, services: services, indent: " └ " })] }));
|
|
158
85
|
};
|
|
159
86
|
const HarnessRow = ({ item, selected, }) => (_jsx("box", { height: 1, flexShrink: 0, backgroundColor: selected ? WASH : "transparent", children: _jsxs("text", { height: 1, bg: "transparent", children: [_jsx(Gutter, { selected: selected }), _jsx("span", { fg: INK, children: item.label.padEnd(12) }), _jsx("span", { fg: FAINT, children: item.hint })] }) }));
|
|
160
87
|
/**
|
|
@@ -218,6 +145,7 @@ const App = ({ ctx, onQuit }) => {
|
|
|
218
145
|
});
|
|
219
146
|
const [focusState, setFocus] = useState("sessions");
|
|
220
147
|
const [projectKey, setProjectKey] = useState(null);
|
|
148
|
+
/** A row key (`wt:<id>` | `s:<id>`), so selection survives regrouping. */
|
|
221
149
|
const [sessionKey, setSessionKey] = useState(null);
|
|
222
150
|
const [picker, setPicker] = useState(null);
|
|
223
151
|
const [pickerIndex, setPickerIndex] = useState(0);
|
|
@@ -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
|
|
254
|
-
const
|
|
255
|
-
const
|
|
256
|
-
const
|
|
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
|
|
268
|
-
// the scroll target is the
|
|
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,
|
|
271
|
-
if (index ===
|
|
213
|
+
for (const [index, row] of rows.entries()) {
|
|
214
|
+
if (index === rowIndex)
|
|
272
215
|
break;
|
|
273
|
-
top +=
|
|
216
|
+
top += rowHeight(row);
|
|
274
217
|
}
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 · ${
|
|
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 · ${
|
|
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 (
|
|
536
|
+
if (rows.length === 0)
|
|
493
537
|
return;
|
|
494
|
-
const next = Math.max(0, Math.min(
|
|
495
|
-
const
|
|
496
|
-
if (
|
|
497
|
-
setSessionKey(
|
|
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.
|
|
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 — ${
|
|
737
|
+
: `worktree — ${selectedGroup.name} · ${selectedSession.session.harness} ${selectedSession.session.id.slice(0, 8)}`;
|
|
656
738
|
const pickerTitle = picker === null
|
|
657
739
|
? ""
|
|
658
|
-
: picker.
|
|
659
|
-
?
|
|
660
|
-
:
|
|
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 ·
|
|
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: [
|
|
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
|
-
|
|
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
|
|
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
|
|
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>] [--
|
|
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
|
-
|
|
2302
|
-
|
|
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",
|
package/dist/workbench-events.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sealant/mend",
|
|
3
|
-
"version": "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",
|