bosun 0.42.2 → 0.42.4
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/.env.example +9 -0
- package/agent/agent-event-bus.mjs +10 -0
- package/agent/agent-supervisor.mjs +20 -0
- package/bosun-tui.mjs +107 -105
- package/cli.mjs +10 -0
- package/config/config.mjs +25 -0
- package/config/executor-config.mjs +124 -1
- package/infra/container-runner.mjs +565 -1
- package/infra/monitor.mjs +18 -0
- package/infra/tracing.mjs +544 -240
- package/infra/tui-bridge.mjs +13 -1
- package/kanban/kanban-adapter.mjs +128 -4
- package/lib/repo-map.mjs +114 -3
- package/package.json +11 -4
- package/server/ui-server.mjs +3 -0
- package/task/task-archiver.mjs +18 -6
- package/task/task-attachments.mjs +14 -10
- package/task/task-cli.mjs +24 -4
- package/task/task-executor.mjs +19 -0
- package/task/task-store.mjs +194 -37
- package/telegram/telegram-bot.mjs +4 -1
- package/tui/app.mjs +131 -171
- package/tui/components/status-header.mjs +178 -75
- package/tui/lib/header-config.mjs +68 -0
- package/tui/lib/ws-bridge.mjs +61 -9
- package/tui/screens/agents.mjs +127 -0
- package/tui/screens/tasks.mjs +1 -48
- package/ui/app.js +8 -5
- package/ui/components/kanban-board.js +65 -3
- package/ui/components/session-list.js +18 -32
- package/ui/demo-defaults.js +52 -2
- package/ui/modules/session-api.js +100 -0
- package/ui/modules/state.js +71 -15
- package/ui/tabs/workflows.js +25 -1
- package/ui/tui/App.js +298 -0
- package/ui/tui/TasksScreen.js +564 -0
- package/ui/tui/constants.js +55 -0
- package/ui/tui/tasks-screen-helpers.js +301 -0
- package/ui/tui/useTasks.js +61 -0
- package/ui/tui/useWebSocket.js +166 -0
- package/ui/tui/useWorkflows.js +30 -0
- package/workflow/workflow-engine.mjs +412 -7
- package/workflow/workflow-nodes.mjs +616 -75
- package/workflow-templates/agents.mjs +3 -0
- package/workflow-templates/planning.mjs +7 -0
- package/workflow-templates/sub-workflows.mjs +5 -0
- package/workflow-templates/task-execution.mjs +3 -0
- package/workspace/command-diagnostics.mjs +1 -1
- package/workspace/context-cache.mjs +182 -9
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { loadConfig } from "../../config/config.mjs";
|
|
2
|
+
|
|
3
|
+
function isTruthy(value) {
|
|
4
|
+
return ["1", "true", "yes", "on"].includes(String(value || "").trim().toLowerCase());
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function hasValue(value) {
|
|
8
|
+
return String(value || "").trim().length > 0;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function resolveProjectLabel(config = {}) {
|
|
12
|
+
const candidates = [
|
|
13
|
+
config?.linearUrl,
|
|
14
|
+
config?.linear?.url,
|
|
15
|
+
config?.kanban?.url,
|
|
16
|
+
config?.kanban?.projectUrl,
|
|
17
|
+
config?.vkPublicUrl,
|
|
18
|
+
config?.vkEndpointUrl,
|
|
19
|
+
config?.projectUrl,
|
|
20
|
+
config?.kanban?.projectId,
|
|
21
|
+
config?.projectId,
|
|
22
|
+
];
|
|
23
|
+
const label = candidates.find((candidate) => hasValue(candidate));
|
|
24
|
+
return label ? String(label).trim() : "No project";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function resolveConfiguredProviders(config = {}) {
|
|
28
|
+
const primaryAgent = String(config?.primaryAgent || "").trim().toLowerCase();
|
|
29
|
+
return {
|
|
30
|
+
claude: primaryAgent === "claude-sdk" || hasValue(process.env.ANTHROPIC_API_KEY),
|
|
31
|
+
codex:
|
|
32
|
+
primaryAgent === "codex-sdk" ||
|
|
33
|
+
config?.codexEnabled === true ||
|
|
34
|
+
hasValue(process.env.OPENAI_API_KEY) ||
|
|
35
|
+
hasValue(process.env.AZURE_OPENAI_API_KEY),
|
|
36
|
+
gemini:
|
|
37
|
+
primaryAgent === "gemini-sdk" ||
|
|
38
|
+
hasValue(process.env.GEMINI_API_KEY) ||
|
|
39
|
+
hasValue(process.env.GOOGLE_API_KEY),
|
|
40
|
+
copilot:
|
|
41
|
+
primaryAgent === "copilot-sdk" ||
|
|
42
|
+
(!isTruthy(process.env.COPILOT_SDK_DISABLED) && (hasValue(process.env.COPILOT_CLI_TOKEN) || hasValue(process.env.GITHUB_PAT))),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function readTuiHeaderConfig(configDir) {
|
|
47
|
+
try {
|
|
48
|
+
const argv = ["node", "bosun-tui"];
|
|
49
|
+
if (configDir) {
|
|
50
|
+
argv.push("--config-dir", String(configDir));
|
|
51
|
+
}
|
|
52
|
+
const config = loadConfig(argv, { reloadEnv: false });
|
|
53
|
+
return {
|
|
54
|
+
configuredProviders: resolveConfiguredProviders(config),
|
|
55
|
+
projectLabel: resolveProjectLabel(config),
|
|
56
|
+
};
|
|
57
|
+
} catch {
|
|
58
|
+
return {
|
|
59
|
+
configuredProviders: {
|
|
60
|
+
claude: hasValue(process.env.ANTHROPIC_API_KEY),
|
|
61
|
+
codex: hasValue(process.env.OPENAI_API_KEY) || hasValue(process.env.AZURE_OPENAI_API_KEY),
|
|
62
|
+
gemini: hasValue(process.env.GEMINI_API_KEY) || hasValue(process.env.GOOGLE_API_KEY),
|
|
63
|
+
copilot: hasValue(process.env.COPILOT_CLI_TOKEN) || hasValue(process.env.GITHUB_PAT),
|
|
64
|
+
},
|
|
65
|
+
projectLabel: "No project",
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
}
|
package/tui/lib/ws-bridge.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
1
2
|
import { resolve } from "node:path";
|
|
2
3
|
|
|
3
4
|
import { resolveTuiAuthToken as resolveSharedTuiAuthToken } from "../../infra/tui-bridge.mjs";
|
|
@@ -17,6 +18,27 @@ function normalizeProtocol(protocol) {
|
|
|
17
18
|
return value === "wss" ? "wss" : "ws";
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
function readUiInstanceLock(configDir = defaultConfigDir()) {
|
|
22
|
+
try {
|
|
23
|
+
const lockPath = resolve(configDir, ".cache", "ui-server.instance.lock.json");
|
|
24
|
+
if (!existsSync(lockPath)) return null;
|
|
25
|
+
const parsed = JSON.parse(readFileSync(lockPath, "utf8"));
|
|
26
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
27
|
+
return parsed;
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function resolveWebSocketProtocol({ protocol, configDir } = {}) {
|
|
34
|
+
const explicit = String(protocol || "").trim().toLowerCase();
|
|
35
|
+
if (explicit === "ws" || explicit === "wss") {
|
|
36
|
+
return explicit;
|
|
37
|
+
}
|
|
38
|
+
const instance = readUiInstanceLock(configDir);
|
|
39
|
+
return String(instance?.protocol || "").trim().toLowerCase() === "https" ? "wss" : "ws";
|
|
40
|
+
}
|
|
41
|
+
|
|
20
42
|
function resolveTuiAuthToken(options = {}) {
|
|
21
43
|
return resolveSharedTuiAuthToken({
|
|
22
44
|
env: options.env || process.env,
|
|
@@ -54,10 +76,12 @@ class TuiWsBridge {
|
|
|
54
76
|
this.reconnectDelay = 1000;
|
|
55
77
|
this.reconnectTimer = null;
|
|
56
78
|
this._connected = false;
|
|
79
|
+
this._connectionState = "offline";
|
|
80
|
+
this._manualDisconnect = false;
|
|
57
81
|
this._url = buildTuiWebSocketUrl({
|
|
58
82
|
host,
|
|
59
83
|
port,
|
|
60
|
-
protocol,
|
|
84
|
+
protocol: resolveWebSocketProtocol({ protocol, configDir: this.configDir }),
|
|
61
85
|
token: resolveTuiAuthToken({ configDir: this.configDir }),
|
|
62
86
|
});
|
|
63
87
|
}
|
|
@@ -66,24 +90,26 @@ class TuiWsBridge {
|
|
|
66
90
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
67
91
|
return;
|
|
68
92
|
}
|
|
93
|
+
this._manualDisconnect = false;
|
|
69
94
|
|
|
70
95
|
try {
|
|
71
96
|
this._url = buildTuiWebSocketUrl({
|
|
72
97
|
host: this.host,
|
|
73
98
|
port: this.port,
|
|
74
|
-
protocol: this.protocol,
|
|
99
|
+
protocol: resolveWebSocketProtocol({ protocol: this.protocol, configDir: this.configDir }),
|
|
75
100
|
token: resolveTuiAuthToken({ configDir: this.configDir }),
|
|
76
101
|
});
|
|
77
102
|
this.ws = new WebSocket(this._url);
|
|
78
103
|
|
|
79
104
|
this.ws.onopen = () => {
|
|
80
105
|
this._connected = true;
|
|
106
|
+
this._connectionState = "connected";
|
|
81
107
|
this.reconnectAttempts = 0;
|
|
82
108
|
this.send("subscribe", {
|
|
83
109
|
channels: ["monitor", "stats", "sessions", "tasks", "workflows", "tui"],
|
|
84
110
|
});
|
|
111
|
+
this._emit("connection:state", { state: this._connectionState });
|
|
85
112
|
this._emit("connect", {});
|
|
86
|
-
console.log("[ws-bridge] Connected to UI server");
|
|
87
113
|
};
|
|
88
114
|
|
|
89
115
|
this.ws.onmessage = (event) => {
|
|
@@ -91,28 +117,35 @@ class TuiWsBridge {
|
|
|
91
117
|
const data = JSON.parse(event.data);
|
|
92
118
|
this._handleMessage(data);
|
|
93
119
|
} catch (err) {
|
|
94
|
-
|
|
120
|
+
this._emit("error", { message: err?.message || "Failed to parse message" });
|
|
95
121
|
}
|
|
96
122
|
};
|
|
97
123
|
|
|
98
124
|
this.ws.onclose = () => {
|
|
99
125
|
this._connected = false;
|
|
126
|
+
this._connectionState = this._manualDisconnect ? "offline" : "reconnecting";
|
|
127
|
+
this._emit("connection:state", { state: this._connectionState });
|
|
100
128
|
this._emit("disconnect", {});
|
|
101
129
|
console.log("[ws-bridge] Disconnected from UI server");
|
|
102
|
-
this.
|
|
130
|
+
if (!this._manualDisconnect) {
|
|
131
|
+
this._scheduleReconnect();
|
|
132
|
+
}
|
|
103
133
|
};
|
|
104
134
|
|
|
105
135
|
this.ws.onerror = (err) => {
|
|
106
|
-
|
|
107
|
-
this._emit("error", { message
|
|
136
|
+
const message = err?.message || err?.error?.message || "WebSocket error";
|
|
137
|
+
this._emit("error", { message });
|
|
138
|
+
this._emit("error", { message });
|
|
139
|
+
this._emit("error", { message });
|
|
108
140
|
};
|
|
109
141
|
} catch (err) {
|
|
110
|
-
|
|
142
|
+
this._emit("error", { message: err?.message || "Failed to connect" });
|
|
111
143
|
this._scheduleReconnect();
|
|
112
144
|
}
|
|
113
145
|
}
|
|
114
146
|
|
|
115
147
|
disconnect() {
|
|
148
|
+
this._manualDisconnect = true;
|
|
116
149
|
if (this.reconnectTimer) {
|
|
117
150
|
clearTimeout(this.reconnectTimer);
|
|
118
151
|
this.reconnectTimer = null;
|
|
@@ -122,19 +155,32 @@ class TuiWsBridge {
|
|
|
122
155
|
this.ws = null;
|
|
123
156
|
}
|
|
124
157
|
this._connected = false;
|
|
158
|
+
this._connectionState = "offline";
|
|
159
|
+
this._emit("connection:state", { state: this._connectionState });
|
|
125
160
|
}
|
|
126
161
|
|
|
127
162
|
_scheduleReconnect() {
|
|
128
163
|
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
164
|
+
this._connectionState = "offline";
|
|
165
|
+
this._emit("connection:state", { state: this._connectionState });
|
|
129
166
|
this._emit("error", { message: "Max reconnection attempts reached" });
|
|
130
167
|
return;
|
|
131
168
|
}
|
|
132
169
|
|
|
133
170
|
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts);
|
|
134
171
|
this.reconnectAttempts++;
|
|
172
|
+
this._connectionState = "reconnecting";
|
|
173
|
+
this._emit("connection:state", {
|
|
174
|
+
state: this._connectionState,
|
|
175
|
+
attempt: this.reconnectAttempts,
|
|
176
|
+
delayMs: delay,
|
|
177
|
+
});
|
|
178
|
+
this._emit("reconnecting", {
|
|
179
|
+
attempt: this.reconnectAttempts,
|
|
180
|
+
delayMs: delay,
|
|
181
|
+
});
|
|
135
182
|
|
|
136
183
|
this.reconnectTimer = setTimeout(() => {
|
|
137
|
-
console.log(`[ws-bridge] Reconnecting (attempt ${this.reconnectAttempts})...`);
|
|
138
184
|
this.connect();
|
|
139
185
|
}, delay);
|
|
140
186
|
}
|
|
@@ -232,6 +278,10 @@ class TuiWsBridge {
|
|
|
232
278
|
get isConnected() {
|
|
233
279
|
return this._connected;
|
|
234
280
|
}
|
|
281
|
+
|
|
282
|
+
get connectionState() {
|
|
283
|
+
return this._connectionState;
|
|
284
|
+
}
|
|
235
285
|
}
|
|
236
286
|
|
|
237
287
|
let _instance = null;
|
|
@@ -279,5 +329,7 @@ export {
|
|
|
279
329
|
buildTuiWebSocketUrl,
|
|
280
330
|
createWsBridge,
|
|
281
331
|
defaultConfigDir,
|
|
332
|
+
resolveWebSocketProtocol,
|
|
282
333
|
resolveTuiAuthToken,
|
|
283
334
|
};
|
|
335
|
+
|
package/tui/screens/agents.mjs
CHANGED
|
@@ -155,6 +155,8 @@ export default function AgentsScreen({ wsBridge, host = "127.0.0.1", port = 3080
|
|
|
155
155
|
};
|
|
156
156
|
}, [refreshData]);
|
|
157
157
|
|
|
158
|
+
const selectedSession = entries.find((entry) => entry.id === selectedId)?.session || entries[0]?.session || null;
|
|
159
|
+
|
|
158
160
|
React.useEffect(() => {
|
|
159
161
|
if (!wsBridge || typeof wsBridge.on !== "function") return undefined;
|
|
160
162
|
const handlers = [
|
|
@@ -166,7 +168,80 @@ export default function AgentsScreen({ wsBridge, host = "127.0.0.1", port = 3080
|
|
|
166
168
|
: [];
|
|
167
169
|
applySessionSnapshot(sessions, Date.now());
|
|
168
170
|
}),
|
|
171
|
+
wsBridge.on("session:event", (payload) => {
|
|
172
|
+
const session = payload?.session;
|
|
173
|
+
if (!session?.id) return;
|
|
174
|
+
const nextSessions = Array.isArray(liveSessionsRef.current)
|
|
175
|
+
? [...liveSessionsRef.current]
|
|
176
|
+
: [];
|
|
177
|
+
const existingIndex = nextSessions.findIndex((candidate) => candidate.id === session.id);
|
|
178
|
+
if (existingIndex >= 0) nextSessions[existingIndex] = session;
|
|
179
|
+
else nextSessions.unshift(session);
|
|
180
|
+
applySessionSnapshot(nextSessions, Date.now());
|
|
181
|
+
|
|
182
|
+
if (selectedId === session.id) {
|
|
183
|
+
setDetailView(detailLines(payload));
|
|
184
|
+
const hasMessages = Array.isArray(payload?.session?.messages);
|
|
185
|
+
const isMessageEvent = payload?.event?.kind === "message";
|
|
186
|
+
if (hasMessages || isMessageEvent) {
|
|
187
|
+
setLogLines(sessionMessagesToLogLines(payload));
|
|
188
|
+
}
|
|
189
|
+
const isMessageEvent = payload?.event?.kind === "message";
|
|
190
|
+
if (hasMessages || isMessageEvent) {
|
|
191
|
+
setLogLines(sessionMessagesToLogLines(payload));
|
|
192
|
+
}
|
|
193
|
+
const isMessageEvent = payload?.event?.kind === "message";
|
|
194
|
+
if (hasMessages || isMessageEvent) {
|
|
195
|
+
setLogLines(sessionMessagesToLogLines(payload));
|
|
196
|
+
}
|
|
197
|
+
const isMessageEvent = payload?.event?.kind === "message";
|
|
198
|
+
if (hasMessages || isMessageEvent) {
|
|
199
|
+
setLogLines(sessionMessagesToLogLines(payload));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}),
|
|
203
|
+
wsBridge.on("retry:update", applyRetryQueue),
|
|
204
|
+
wsBridge.on("retry-queue-updated", applyRetryQueue),
|
|
205
|
+
];
|
|
206
|
+
|
|
207
|
+
return () => {
|
|
208
|
+
handlers.forEach((handler) => {
|
|
209
|
+
if (typeof handler === "function") handler();
|
|
210
|
+
});
|
|
211
|
+
};
|
|
212
|
+
}, [applyRetryQueue, applySessionSnapshot, selectedId, wsBridge]);
|
|
213
|
+
|
|
214
|
+
useInput((input, key) => {
|
|
215
|
+
if (confirmKill) {
|
|
216
|
+
if ((input === "y" || input === "Y") && selectedSession?.id) {
|
|
217
|
+
void fetchJson(resolvedHost, resolvedPort, sessionActionPath(selectedSession.id, "stop"), {
|
|
218
|
+
method: "POST",
|
|
219
|
+
}).then(() => {
|
|
220
|
+
setStatusLine(`Stopped ${describeSelection(selectedSession)}`);
|
|
221
|
+
}).catch((error) => {
|
|
222
|
+
setStatusLine(error.message || String(error));
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
setConfirmKill(false);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
169
228
|
|
|
229
|
+
const selectableEntries = entries.filter((entry) => entry?.session);
|
|
230
|
+
const selectedIndex = selectableEntries.findIndex((entry) => entry.id === selectedId);
|
|
231
|
+
|
|
232
|
+
if (key.upArrow && selectedIndex > 0) {
|
|
233
|
+
setSelectedId(selectableEntries[selectedIndex - 1].id);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (key.downArrow && selectedIndex >= 0 && selectedIndex < selectableEntries.length - 1) {
|
|
237
|
+
setSelectedId(selectableEntries[selectedIndex + 1].id);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (key.return && selectedSession?.id) {
|
|
241
|
+
setDetailView(detailLines({ session: selectedSession }));
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (input === "c" || input === "C") {
|
|
170
245
|
if (selectedSession?.id) {
|
|
171
246
|
stdout.write(buildOsc52CopySequence(selectedSession.id));
|
|
172
247
|
setStatusLine(`Copied ${selectedSession.id}`);
|
|
@@ -177,6 +252,58 @@ export default function AgentsScreen({ wsBridge, host = "127.0.0.1", port = 3080
|
|
|
177
252
|
setShowBackoff((current) => !current);
|
|
178
253
|
return;
|
|
179
254
|
}
|
|
255
|
+
if (input === "p" || input === "P") {
|
|
256
|
+
if (selectedSession?.id) {
|
|
257
|
+
void fetchJson(resolvedHost, resolvedPort, sessionActionPath(selectedSession.id, "pause"), {
|
|
258
|
+
method: "POST",
|
|
259
|
+
}).then(() => setStatusLine(`Paused ${describeSelection(selectedSession)}`)).catch((error) => {
|
|
260
|
+
setStatusLine(error.message || String(error));
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (input === "r" || input === "R") {
|
|
266
|
+
if (selectedSession?.id) {
|
|
267
|
+
void fetchJson(resolvedHost, resolvedPort, sessionActionPath(selectedSession.id, "resume"), {
|
|
268
|
+
method: "POST",
|
|
269
|
+
}).then(() => setStatusLine(`Resumed ${describeSelection(selectedSession)}`)).catch((error) => {
|
|
270
|
+
setStatusLine(error.message || String(error));
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (input === "k" || input === "K") {
|
|
276
|
+
if (selectedSession?.id) {
|
|
277
|
+
setConfirmKill(true);
|
|
278
|
+
}
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (input === "l" || input === "L") {
|
|
282
|
+
if (selectedSession?.id) {
|
|
283
|
+
void fetchJson(resolvedHost, resolvedPort, `/api/sessions/${encodeURIComponent(selectedSession.id)}?workspace=all&full=1`)
|
|
284
|
+
.then((payload) => {
|
|
285
|
+
setLogLines(sessionMessagesToLogLines(payload));
|
|
286
|
+
setStatusLine(`Loaded logs for ${describeSelection(selectedSession)}`);
|
|
287
|
+
})
|
|
288
|
+
.catch((error) => {
|
|
289
|
+
setStatusLine(error.message || String(error));
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if (input === "d" || input === "D") {
|
|
295
|
+
if (selectedSession?.id) {
|
|
296
|
+
void fetchJson(resolvedHost, resolvedPort, sessionActionPath(selectedSession.id, "diff"))
|
|
297
|
+
.then((payload) => {
|
|
298
|
+
setDiffView(summarizeDiff(payload));
|
|
299
|
+
setStatusLine(`Loaded diff for ${describeSelection(selectedSession)}`);
|
|
300
|
+
})
|
|
301
|
+
.catch((error) => {
|
|
302
|
+
setStatusLine(error.message || String(error));
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
180
307
|
if (key.escape) {
|
|
181
308
|
setDetailView(null);
|
|
182
309
|
setLogLines([]);
|
package/tui/screens/tasks.mjs
CHANGED
|
@@ -1,48 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import htm from "htm";
|
|
3
|
-
import { Box, Text } from "ink";
|
|
4
|
-
|
|
5
|
-
const html = htm.bind(React.createElement);
|
|
6
|
-
|
|
7
|
-
const COLUMNS = ["todo", "inprogress", "inreview", "blocked", "done"];
|
|
8
|
-
|
|
9
|
-
function formatTask(task) {
|
|
10
|
-
const id = String(task?.id || "").slice(0, 8) || "--------";
|
|
11
|
-
const title = String(task?.title || "Untitled");
|
|
12
|
-
return `${id} ${title}`;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export default function TasksScreen({ tasks }) {
|
|
16
|
-
const buckets = new Map(COLUMNS.map((column) => [column, []]));
|
|
17
|
-
for (const task of Array.isArray(tasks) ? tasks : []) {
|
|
18
|
-
const status = String(task?.status || "todo").toLowerCase();
|
|
19
|
-
if (!buckets.has(status)) buckets.set(status, []);
|
|
20
|
-
buckets.get(status).push(task);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
return html`
|
|
24
|
-
<${Box} flexDirection="column" paddingY=${1}>
|
|
25
|
-
<${Text} dimColor>Task board view is read-only in the terminal build.<//>
|
|
26
|
-
<${Box} marginTop=${1}>
|
|
27
|
-
${COLUMNS.map((column) => html`
|
|
28
|
-
<${Box}
|
|
29
|
-
key=${column}
|
|
30
|
-
flexDirection="column"
|
|
31
|
-
borderStyle="single"
|
|
32
|
-
paddingX=${1}
|
|
33
|
-
marginRight=${1}
|
|
34
|
-
width=${24}
|
|
35
|
-
>
|
|
36
|
-
<${Text} bold>${column} (${(buckets.get(column) || []).length})<//>
|
|
37
|
-
${(buckets.get(column) || []).slice(0, 8).map((task) => html`
|
|
38
|
-
<${Text} key=${task.id} wrap="truncate-end">${formatTask(task)}<//>
|
|
39
|
-
`)}
|
|
40
|
-
${(buckets.get(column) || []).length === 0 && html`
|
|
41
|
-
<${Text} dimColor>No tasks<//>
|
|
42
|
-
`}
|
|
43
|
-
<//>
|
|
44
|
-
`)}
|
|
45
|
-
<//>
|
|
46
|
-
<//>
|
|
47
|
-
`;
|
|
48
|
-
}
|
|
1
|
+
export { default } from "../../ui/tui/TasksScreen.js";
|
package/ui/app.js
CHANGED
|
@@ -276,6 +276,7 @@ import {
|
|
|
276
276
|
import { formatRelative } from "./modules/utils.js";
|
|
277
277
|
import {
|
|
278
278
|
buildSessionApiPath,
|
|
279
|
+
shouldFallbackToAllSessions,
|
|
279
280
|
getSessionLifecycleState,
|
|
280
281
|
getSessionRecencyTimestamp,
|
|
281
282
|
getSessionRuntimeState,
|
|
@@ -1050,11 +1051,11 @@ function InspectorPanel({ onResizeStart, onResizeReset, showResizer }) {
|
|
|
1050
1051
|
try {
|
|
1051
1052
|
res = await apiFetch(fullSessionPath, { _silent: true });
|
|
1052
1053
|
} catch (err) {
|
|
1053
|
-
const
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
fallbackSessionPath
|
|
1057
|
-
|
|
1054
|
+
const shouldRetryAll = shouldFallbackToAllSessions(
|
|
1055
|
+
err,
|
|
1056
|
+
fullSessionPath,
|
|
1057
|
+
fallbackSessionPath,
|
|
1058
|
+
);
|
|
1058
1059
|
if (!shouldRetryAll) throw err;
|
|
1059
1060
|
res = await apiFetch(fallbackSessionPath, { _silent: true });
|
|
1060
1061
|
}
|
|
@@ -2688,3 +2689,5 @@ const remountApp = () => {
|
|
|
2688
2689
|
};
|
|
2689
2690
|
globalThis.__veRemountApp = remountApp;
|
|
2690
2691
|
mountApp();
|
|
2692
|
+
|
|
2693
|
+
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
loadTasks,
|
|
15
15
|
normalizeTaskLifecycleStatus,
|
|
16
16
|
classifyTaskLifecycleAction,
|
|
17
|
+
mergeTaskRecords,
|
|
17
18
|
} from "../modules/state.js";
|
|
18
19
|
import { apiFetch } from "../modules/api.js";
|
|
19
20
|
import { haptic, showConfirm } from "../modules/telegram.js";
|
|
@@ -249,6 +250,56 @@ function getTaskBaseBranch(task) {
|
|
|
249
250
|
);
|
|
250
251
|
}
|
|
251
252
|
|
|
253
|
+
function getTaskPrLinkage(task) {
|
|
254
|
+
if (!task) return [];
|
|
255
|
+
const records = [];
|
|
256
|
+
const seen = new Set();
|
|
257
|
+
const append = (record) => {
|
|
258
|
+
if (!record || typeof record !== "object") return;
|
|
259
|
+
const branchName = String(record.branchName || "").trim();
|
|
260
|
+
const prUrl = String(record.prUrl || "").trim();
|
|
261
|
+
const prNumber = Number.parseInt(String(record.prNumber ?? ""), 10);
|
|
262
|
+
if (!branchName && !prUrl && !(Number.isFinite(prNumber) && prNumber > 0)) return;
|
|
263
|
+
const normalized = {
|
|
264
|
+
branchName,
|
|
265
|
+
prUrl,
|
|
266
|
+
prNumber: Number.isFinite(prNumber) && prNumber > 0 ? prNumber : null,
|
|
267
|
+
source: String(record.source || task?.meta?.prLinkageSource || "").trim(),
|
|
268
|
+
freshness: String(record.freshness || task?.meta?.prLinkageFreshness || "").trim(),
|
|
269
|
+
linkedAt: String(record.linkedAt || "").trim(),
|
|
270
|
+
updatedAt: String(record.updatedAt || task?.meta?.prLinkageUpdatedAt || "").trim(),
|
|
271
|
+
};
|
|
272
|
+
const key = [normalized.branchName.toLowerCase(), normalized.prNumber || "", normalized.prUrl.toLowerCase()].join("|");
|
|
273
|
+
if (seen.has(key)) return;
|
|
274
|
+
seen.add(key);
|
|
275
|
+
records.push(normalized);
|
|
276
|
+
};
|
|
277
|
+
(Array.isArray(task.prLinkage) ? task.prLinkage : []).forEach(append);
|
|
278
|
+
(Array.isArray(task?.meta?.prLinkage) ? task.meta.prLinkage : []).forEach(append);
|
|
279
|
+
append(task);
|
|
280
|
+
return records;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function getPrimaryTaskPrLinkage(task) {
|
|
284
|
+
return getTaskPrLinkage(task)[0] || null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function formatPrLinkageFreshnessLabel(linkage) {
|
|
288
|
+
const freshness = String(linkage?.freshness || "").trim().toLowerCase();
|
|
289
|
+
const timestamp = String(linkage?.updatedAt || linkage?.linkedAt || "").trim();
|
|
290
|
+
const relative = timestamp ? formatRelative(timestamp) : "";
|
|
291
|
+
if (freshness === "stale") {
|
|
292
|
+
return relative ? `PR freshness: Stale (${relative})` : "PR freshness: Stale";
|
|
293
|
+
}
|
|
294
|
+
if (freshness === "fresh") {
|
|
295
|
+
return relative ? `PR freshness: Fresh (${relative})` : "PR freshness: Fresh";
|
|
296
|
+
}
|
|
297
|
+
if (relative) {
|
|
298
|
+
return `PR freshness: Updated ${relative}`;
|
|
299
|
+
}
|
|
300
|
+
return "PR freshness: Linked";
|
|
301
|
+
}
|
|
302
|
+
|
|
252
303
|
function getTaskRuntimeSnapshot(task) {
|
|
253
304
|
return task?.runtimeSnapshot || task?.meta?.runtimeSnapshot || null;
|
|
254
305
|
}
|
|
@@ -444,7 +495,7 @@ async function executeBoardTransition(task, newStatus, columnLabel) {
|
|
|
444
495
|
const merged = detail?.data || startRes?.data || null;
|
|
445
496
|
if (merged) {
|
|
446
497
|
tasksData.value = tasksData.value.map((t) =>
|
|
447
|
-
matchTaskId(t.id, taskId) ?
|
|
498
|
+
matchTaskId(t.id, taskId) ? mergeTaskRecords(t, merged) : t,
|
|
448
499
|
);
|
|
449
500
|
}
|
|
450
501
|
return startRes;
|
|
@@ -462,7 +513,7 @@ async function executeBoardTransition(task, newStatus, columnLabel) {
|
|
|
462
513
|
});
|
|
463
514
|
if (res?.data) {
|
|
464
515
|
tasksData.value = tasksData.value.map((t) =>
|
|
465
|
-
matchTaskId(t.id, taskId) ?
|
|
516
|
+
matchTaskId(t.id, taskId) ? mergeTaskRecords(t, res.data) : t,
|
|
466
517
|
);
|
|
467
518
|
}
|
|
468
519
|
return res;
|
|
@@ -621,6 +672,7 @@ function KanbanCard({ task, onOpen }) {
|
|
|
621
672
|
const storyPoints = getTaskStoryPoints(task);
|
|
622
673
|
const dueDate = getTaskDueDate(task);
|
|
623
674
|
const blockedPreview = getTaskBlockedPreview(task);
|
|
675
|
+
const prLinkage = getPrimaryTaskPrLinkage(task);
|
|
624
676
|
const repoName = task.repo || task.repository || "";
|
|
625
677
|
const issueNum = task.issueNumber || task.issue_number || (typeof task.id === "string" && /^\d+$/.test(task.id) ? task.id : null);
|
|
626
678
|
const hasAgent = Boolean(
|
|
@@ -693,6 +745,14 @@ function KanbanCard({ task, onOpen }) {
|
|
|
693
745
|
${dueDate && html`<${Chip} label=${`Due: ${truncate(dueDate, 18)}`} size="small" variant="outlined" color="warning" sx=${{ height: 20, fontSize: '0.65rem' }} />`}
|
|
694
746
|
</${Stack}>
|
|
695
747
|
`}
|
|
748
|
+
${prLinkage && html`
|
|
749
|
+
<${Stack} direction="row" spacing=${0.5} flexWrap="wrap" sx=${{ mt: 0.75 }}>
|
|
750
|
+
${prLinkage.prNumber && html`<${Chip} label=${`PR #${prLinkage.prNumber}`} size="small" variant="outlined" sx=${{ height: 20, fontSize: '0.65rem' }} />`}
|
|
751
|
+
${prLinkage.branchName && html`<${Chip} label=${`Branch: ${truncate(prLinkage.branchName, 18)}`} size="small" variant="outlined" sx=${{ height: 20, fontSize: '0.65rem' }} />`}
|
|
752
|
+
${prLinkage.source && html`<${Chip} label=${`PR source: ${truncate(prLinkage.source, 16)}`} size="small" variant="outlined" sx=${{ height: 20, fontSize: '0.65rem' }} />`}
|
|
753
|
+
${(prLinkage.freshness || prLinkage.updatedAt || prLinkage.linkedAt) && html`<${Chip} label=${formatPrLinkageFreshnessLabel(prLinkage)} size="small" color=${prLinkage.freshness === "stale" ? "warning" : "success"} sx=${{ height: 20, fontSize: '0.65rem' }} />`}
|
|
754
|
+
</${Stack}>
|
|
755
|
+
`}
|
|
696
756
|
${baseBranch && html`
|
|
697
757
|
<${Typography} variant="caption" color="text.secondary" sx=${{ display: 'block', mt: 0.5 }}>Base: ${truncate(baseBranch, 24)}</${Typography}>
|
|
698
758
|
`}
|
|
@@ -845,7 +905,7 @@ function KanbanColumn({
|
|
|
845
905
|
});
|
|
846
906
|
if (res?.data) {
|
|
847
907
|
tasksData.value = tasksData.value.map((t) =>
|
|
848
|
-
matchTaskId(t.id, taskId) ?
|
|
908
|
+
matchTaskId(t.id, taskId) ? mergeTaskRecords(t, res.data) : t,
|
|
849
909
|
);
|
|
850
910
|
}
|
|
851
911
|
return res;
|
|
@@ -1180,3 +1240,5 @@ export function KanbanBoard({ onOpenTask, hasMoreTasks = false, loadingMoreTasks
|
|
|
1180
1240
|
</${Box}>
|
|
1181
1241
|
`;
|
|
1182
1242
|
}
|
|
1243
|
+
|
|
1244
|
+
|