bosun 0.31.5 → 0.31.6
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/kanban-adapter.mjs +2 -2
- package/monitor.mjs +44 -32
- package/package.json +1 -1
- package/sync-engine.mjs +87 -2
- package/telegram-bot.mjs +46 -29
- package/ui/components/chat-view.js +90 -14
- package/ui/modules/streaming.js +8 -2
- package/ui/styles/sessions.css +57 -4
- package/ui/tabs/chat.js +46 -0
- package/ui/tabs/tasks.js +16 -23
- package/ui-server.mjs +31 -1
package/kanban-adapter.mjs
CHANGED
|
@@ -903,7 +903,7 @@ class GitHubIssuesAdapter {
|
|
|
903
903
|
process.env.BOSUN_TASK_LABEL || "bosun";
|
|
904
904
|
this._taskScopeLabels = normalizeLabels(
|
|
905
905
|
process.env.BOSUN_TASK_LABELS ||
|
|
906
|
-
`${this._canonicalTaskLabel},codex-
|
|
906
|
+
`${this._canonicalTaskLabel},codex-monitor`,
|
|
907
907
|
);
|
|
908
908
|
this._enforceTaskLabel = parseBooleanEnv(
|
|
909
909
|
process.env.BOSUN_ENFORCE_TASK_LABEL,
|
|
@@ -915,7 +915,7 @@ class GitHubIssuesAdapter {
|
|
|
915
915
|
true,
|
|
916
916
|
);
|
|
917
917
|
this._defaultAssignee =
|
|
918
|
-
process.env.GITHUB_DEFAULT_ASSIGNEE ||
|
|
918
|
+
String(process.env.GITHUB_DEFAULT_ASSIGNEE || "").trim() || null;
|
|
919
919
|
|
|
920
920
|
this._projectMode = String(process.env.GITHUB_PROJECT_MODE || "issues")
|
|
921
921
|
.trim()
|
package/monitor.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execSync, spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
2
3
|
import {
|
|
3
4
|
existsSync,
|
|
4
5
|
mkdirSync,
|
|
@@ -7902,10 +7903,8 @@ function extractPlannerTasksFromOutput(output, maxTasks) {
|
|
|
7902
7903
|
return normalized;
|
|
7903
7904
|
}
|
|
7904
7905
|
|
|
7905
|
-
async function materializePlannerTasksToKanban(
|
|
7906
|
-
const existingOpenTasks =
|
|
7907
|
-
status: "todo",
|
|
7908
|
-
});
|
|
7906
|
+
async function materializePlannerTasksToKanban(tasks) {
|
|
7907
|
+
const existingOpenTasks = getInternalTasksByStatus("todo");
|
|
7909
7908
|
const existingTitles = new Set(
|
|
7910
7909
|
(Array.isArray(existingOpenTasks) ? existingOpenTasks : [])
|
|
7911
7910
|
.map((task) => normalizePlannerTitleForComparison(task?.title))
|
|
@@ -7925,16 +7924,26 @@ async function materializePlannerTasksToKanban(projectId, tasks) {
|
|
|
7925
7924
|
skipped.push({ title: task.title, reason: "duplicate_title" });
|
|
7926
7925
|
continue;
|
|
7927
7926
|
}
|
|
7928
|
-
const
|
|
7927
|
+
const baseBranch = task.baseBranch || task.base_branch || extractModuleBaseBranchFromTitle(task.title);
|
|
7928
|
+
const plannerMeta = {
|
|
7929
|
+
source: "task-planner",
|
|
7930
|
+
plannerMode: "codex-sdk",
|
|
7931
|
+
kind: "planned-task",
|
|
7932
|
+
externalSyncPending: true,
|
|
7933
|
+
createdAt: new Date().toISOString(),
|
|
7934
|
+
};
|
|
7935
|
+
const createdTask = addInternalTask({
|
|
7936
|
+
id: randomUUID(),
|
|
7929
7937
|
title: task.title,
|
|
7930
7938
|
description: task.description,
|
|
7931
7939
|
status: "todo",
|
|
7932
|
-
|
|
7933
|
-
|
|
7934
|
-
|
|
7935
|
-
|
|
7936
|
-
|
|
7937
|
-
|
|
7940
|
+
projectId: process.env.INTERNAL_EXECUTOR_PROJECT_ID || "internal",
|
|
7941
|
+
...(baseBranch ? { baseBranch } : {}),
|
|
7942
|
+
syncDirty: true,
|
|
7943
|
+
meta: {
|
|
7944
|
+
planner: plannerMeta,
|
|
7945
|
+
...(baseBranch ? { base_branch: baseBranch, baseBranch } : {}),
|
|
7946
|
+
},
|
|
7938
7947
|
});
|
|
7939
7948
|
if (createdTask?.id) {
|
|
7940
7949
|
created.push({ id: createdTask.id, title: task.title });
|
|
@@ -8987,13 +8996,6 @@ async function triggerTaskPlannerViaKanban(
|
|
|
8987
8996
|
details,
|
|
8988
8997
|
numTasks,
|
|
8989
8998
|
);
|
|
8990
|
-
// Get project ID using the kanban adapter
|
|
8991
|
-
const projectId = await findKanbanProjectId();
|
|
8992
|
-
if (!projectId) {
|
|
8993
|
-
throw new Error(
|
|
8994
|
-
`Cannot reach kanban backend (${getActiveKanbanBackend()}) or no project found`,
|
|
8995
|
-
);
|
|
8996
|
-
}
|
|
8997
8999
|
|
|
8998
9000
|
const desiredTitle = userPrompt
|
|
8999
9001
|
? `[${plannerTaskSizeLabel}] Plan next tasks (${reason || "backlog-empty"}) — ${userPrompt.slice(0, 60)}${userPrompt.length > 60 ? "…" : ""}`
|
|
@@ -9008,7 +9010,7 @@ async function triggerTaskPlannerViaKanban(
|
|
|
9008
9010
|
|
|
9009
9011
|
// Check for existing planner tasks to avoid duplicates
|
|
9010
9012
|
// Only block on TODO tasks whose title matches the exact format we create
|
|
9011
|
-
const existingTasks =
|
|
9013
|
+
const existingTasks = getInternalTasksByStatus("todo");
|
|
9012
9014
|
const existingPlanner = (
|
|
9013
9015
|
Array.isArray(existingTasks) ? existingTasks : []
|
|
9014
9016
|
).find((t) => {
|
|
@@ -9042,7 +9044,7 @@ async function triggerTaskPlannerViaKanban(
|
|
|
9042
9044
|
);
|
|
9043
9045
|
}
|
|
9044
9046
|
|
|
9045
|
-
const taskUrl =
|
|
9047
|
+
const taskUrl = null;
|
|
9046
9048
|
if (notify) {
|
|
9047
9049
|
const suffix = taskUrl ? `\n${taskUrl}` : "";
|
|
9048
9050
|
await sendTelegramMessage(
|
|
@@ -9061,7 +9063,7 @@ async function triggerTaskPlannerViaKanban(
|
|
|
9061
9063
|
taskId: existingPlanner.id,
|
|
9062
9064
|
taskTitle: existingPlanner.title,
|
|
9063
9065
|
taskUrl,
|
|
9064
|
-
projectId,
|
|
9066
|
+
projectId: process.env.INTERNAL_EXECUTOR_PROJECT_ID || "internal",
|
|
9065
9067
|
};
|
|
9066
9068
|
}
|
|
9067
9069
|
|
|
@@ -9071,7 +9073,24 @@ async function triggerTaskPlannerViaKanban(
|
|
|
9071
9073
|
status: "todo",
|
|
9072
9074
|
};
|
|
9073
9075
|
|
|
9074
|
-
const createdTask =
|
|
9076
|
+
const createdTask = addInternalTask({
|
|
9077
|
+
id: randomUUID(),
|
|
9078
|
+
title: taskData.title,
|
|
9079
|
+
description: taskData.description,
|
|
9080
|
+
status: "todo",
|
|
9081
|
+
projectId: process.env.INTERNAL_EXECUTOR_PROJECT_ID || "internal",
|
|
9082
|
+
syncDirty: true,
|
|
9083
|
+
meta: {
|
|
9084
|
+
planner: {
|
|
9085
|
+
source: "task-planner",
|
|
9086
|
+
plannerMode: "kanban",
|
|
9087
|
+
kind: "planner-request",
|
|
9088
|
+
triggerReason: reason || "manual",
|
|
9089
|
+
externalSyncPending: true,
|
|
9090
|
+
createdAt: new Date().toISOString(),
|
|
9091
|
+
},
|
|
9092
|
+
},
|
|
9093
|
+
});
|
|
9075
9094
|
|
|
9076
9095
|
if (createdTask && createdTask.id) {
|
|
9077
9096
|
console.log(`[monitor] task planner task created: ${createdTask.id}`);
|
|
@@ -9082,7 +9101,7 @@ async function triggerTaskPlannerViaKanban(
|
|
|
9082
9101
|
last_result: "kanban_task_created",
|
|
9083
9102
|
});
|
|
9084
9103
|
const createdId = createdTask.id;
|
|
9085
|
-
const createdUrl =
|
|
9104
|
+
const createdUrl = null;
|
|
9086
9105
|
if (notify) {
|
|
9087
9106
|
const suffix = createdUrl ? `\n${createdUrl}` : "";
|
|
9088
9107
|
await sendTelegramMessage(
|
|
@@ -9094,7 +9113,7 @@ async function triggerTaskPlannerViaKanban(
|
|
|
9094
9113
|
taskId: createdId,
|
|
9095
9114
|
taskTitle: taskData.title,
|
|
9096
9115
|
taskUrl: createdUrl,
|
|
9097
|
-
projectId,
|
|
9116
|
+
projectId: process.env.INTERNAL_EXECUTOR_PROJECT_ID || "internal",
|
|
9098
9117
|
};
|
|
9099
9118
|
}
|
|
9100
9119
|
throw new Error("Task creation failed");
|
|
@@ -9194,14 +9213,7 @@ async function triggerTaskPlannerViaCodex(
|
|
|
9194
9213
|
"utf8",
|
|
9195
9214
|
);
|
|
9196
9215
|
|
|
9197
|
-
const projectId = await findKanbanProjectId();
|
|
9198
|
-
if (!projectId) {
|
|
9199
|
-
throw new Error(
|
|
9200
|
-
`Task planner produced ${parsedTasks.length} tasks, but no kanban project is reachable for backend "${getActiveKanbanBackend()}"`,
|
|
9201
|
-
);
|
|
9202
|
-
}
|
|
9203
9216
|
const { created, skipped } = await materializePlannerTasksToKanban(
|
|
9204
|
-
projectId,
|
|
9205
9217
|
parsedTasks,
|
|
9206
9218
|
);
|
|
9207
9219
|
|
|
@@ -9228,7 +9240,7 @@ async function triggerTaskPlannerViaCodex(
|
|
|
9228
9240
|
status: "completed",
|
|
9229
9241
|
outputPath: outPath,
|
|
9230
9242
|
artifactPath,
|
|
9231
|
-
projectId,
|
|
9243
|
+
projectId: process.env.INTERNAL_EXECUTOR_PROJECT_ID || "internal",
|
|
9232
9244
|
parsedTaskCount: parsedTasks.length,
|
|
9233
9245
|
createdTaskCount: created.length,
|
|
9234
9246
|
skippedTaskCount: skipped.length,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bosun",
|
|
3
|
-
"version": "0.31.
|
|
3
|
+
"version": "0.31.6",
|
|
4
4
|
"description": "AI-powered orchestrator supervisor — manages AI agent executors with failover, auto-restarts on failure, analyzes crashes with Codex SDK, creates PRs via Vibe-Kanban API, and sends Telegram notifications. Supports N executors with weighted distribution, multi-repo projects, and auto-setup.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache 2.0",
|
package/sync-engine.mjs
CHANGED
|
@@ -16,7 +16,6 @@
|
|
|
16
16
|
import {
|
|
17
17
|
getTask,
|
|
18
18
|
getAllTasks,
|
|
19
|
-
addTask,
|
|
20
19
|
updateTask,
|
|
21
20
|
getDirtyTasks,
|
|
22
21
|
markSynced,
|
|
@@ -29,6 +28,7 @@ import {
|
|
|
29
28
|
getKanbanAdapter,
|
|
30
29
|
getKanbanBackendName,
|
|
31
30
|
listTasks,
|
|
31
|
+
createTask as createExternalTask,
|
|
32
32
|
updateTaskStatus as updateExternalStatus,
|
|
33
33
|
} from "./kanban-adapter.mjs";
|
|
34
34
|
|
|
@@ -585,6 +585,11 @@ export class SyncEngine {
|
|
|
585
585
|
baseBranchCandidate &&
|
|
586
586
|
String(baseBranchCandidate) !== String(metaBaseBranch);
|
|
587
587
|
|
|
588
|
+
const shouldCreateExternal = this.#shouldCreateExternalTask(
|
|
589
|
+
task,
|
|
590
|
+
backendName,
|
|
591
|
+
);
|
|
592
|
+
|
|
588
593
|
// Check shared state for conflicts before pushing
|
|
589
594
|
if (SHARED_STATE_ENABLED) {
|
|
590
595
|
try {
|
|
@@ -618,9 +623,48 @@ export class SyncEngine {
|
|
|
618
623
|
// Continue with push on error (graceful degradation)
|
|
619
624
|
}
|
|
620
625
|
}
|
|
626
|
+
let pushId = task.externalId || task.id;
|
|
627
|
+
|
|
628
|
+
if (shouldCreateExternal) {
|
|
629
|
+
try {
|
|
630
|
+
const created = await this.#createExternalTask(task, baseBranchCandidate);
|
|
631
|
+
const createdId = this.#extractExternalTaskId(created);
|
|
632
|
+
if (!isIdValidForBackend(createdId, backendName)) {
|
|
633
|
+
throw new Error(
|
|
634
|
+
`external create returned incompatible id for ${backendName}: ${createdId || "<empty>"}`,
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const plannerMeta = {
|
|
639
|
+
...(task.meta?.planner || {}),
|
|
640
|
+
externalSyncPending: false,
|
|
641
|
+
externalCreatedAt: new Date().toISOString(),
|
|
642
|
+
externalTaskId: String(createdId),
|
|
643
|
+
};
|
|
644
|
+
updateTask(task.id, {
|
|
645
|
+
externalId: String(createdId),
|
|
646
|
+
externalBackend: backendName,
|
|
647
|
+
meta: {
|
|
648
|
+
...(task.meta || {}),
|
|
649
|
+
planner: plannerMeta,
|
|
650
|
+
},
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
pushId = String(createdId);
|
|
654
|
+
console.log(
|
|
655
|
+
TAG,
|
|
656
|
+
`Created external task for ${task.id} → ${pushId} (${backendName})`,
|
|
657
|
+
);
|
|
658
|
+
} catch (err) {
|
|
659
|
+
const msg = `External create failed for ${task.id}: ${err.message}`;
|
|
660
|
+
console.warn(TAG, msg);
|
|
661
|
+
result.errors.push(msg);
|
|
662
|
+
continue;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
621
666
|
// Skip tasks whose IDs are incompatible with the active backend.
|
|
622
667
|
// e.g. VK UUID tasks cannot be pushed to GitHub Issues (needs numeric IDs).
|
|
623
|
-
const pushId = task.externalId || task.id;
|
|
624
668
|
if (!isIdValidForBackend(pushId, backendName)) {
|
|
625
669
|
// If the task originated from a different backend, silently clear dirty
|
|
626
670
|
// flag — it will be synced when that backend is active.
|
|
@@ -973,6 +1017,28 @@ export class SyncEngine {
|
|
|
973
1017
|
return await updateExternalStatus(taskId, status);
|
|
974
1018
|
}
|
|
975
1019
|
|
|
1020
|
+
async #createExternalTask(task, baseBranchCandidate = null) {
|
|
1021
|
+
const payload = {
|
|
1022
|
+
title: task.title || "Untitled task",
|
|
1023
|
+
description: task.description || "",
|
|
1024
|
+
status: "todo",
|
|
1025
|
+
assignee: task.assignee || null,
|
|
1026
|
+
priority: task.priority || null,
|
|
1027
|
+
tags: Array.isArray(task.tags) ? task.tags : [],
|
|
1028
|
+
labels: Array.isArray(task.tags) ? task.tags : [],
|
|
1029
|
+
...(baseBranchCandidate ? { baseBranch: baseBranchCandidate } : {}),
|
|
1030
|
+
};
|
|
1031
|
+
|
|
1032
|
+
if (
|
|
1033
|
+
this.#kanbanAdapter &&
|
|
1034
|
+
typeof this.#kanbanAdapter.createTask === "function"
|
|
1035
|
+
) {
|
|
1036
|
+
return await this.#kanbanAdapter.createTask(this.#projectId, payload);
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
return await createExternalTask(this.#projectId, payload);
|
|
1040
|
+
}
|
|
1041
|
+
|
|
976
1042
|
async #updateExternalPatch(taskId, patch) {
|
|
977
1043
|
if (
|
|
978
1044
|
this.#kanbanAdapter &&
|
|
@@ -1022,6 +1088,25 @@ export class SyncEngine {
|
|
|
1022
1088
|
);
|
|
1023
1089
|
}
|
|
1024
1090
|
|
|
1091
|
+
#shouldCreateExternalTask(task, backend) {
|
|
1092
|
+
if (!task || backend === "internal") return false;
|
|
1093
|
+
if (task.externalId) return false;
|
|
1094
|
+
const plannerMeta = task.meta?.planner;
|
|
1095
|
+
if (!plannerMeta || typeof plannerMeta !== "object") return false;
|
|
1096
|
+
return plannerMeta.externalSyncPending === true;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
#extractExternalTaskId(created) {
|
|
1100
|
+
if (!created) return null;
|
|
1101
|
+
if (created.id != null && String(created.id).trim()) {
|
|
1102
|
+
return String(created.id).trim();
|
|
1103
|
+
}
|
|
1104
|
+
if (created.externalId != null && String(created.externalId).trim()) {
|
|
1105
|
+
return String(created.externalId).trim();
|
|
1106
|
+
}
|
|
1107
|
+
return null;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1025
1110
|
/** Simple async sleep. */
|
|
1026
1111
|
#sleep(ms) {
|
|
1027
1112
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
package/telegram-bot.mjs
CHANGED
|
@@ -789,6 +789,20 @@ function getBrowserUiUrl() {
|
|
|
789
789
|
const base = telegramUiUrl;
|
|
790
790
|
if (!base) return null;
|
|
791
791
|
const token = getSessionToken();
|
|
792
|
+
|
|
793
|
+
// Prefer LAN for browser opens when available (same-network path),
|
|
794
|
+
// and fall back to the configured/public URL.
|
|
795
|
+
try {
|
|
796
|
+
const parsed = new URL(base);
|
|
797
|
+
const lanIp = getLocalLanIp?.();
|
|
798
|
+
if (lanIp && parsed.port) {
|
|
799
|
+
const lanBase = `${parsed.protocol}//${lanIp}:${parsed.port}`;
|
|
800
|
+
return appendTokenToUrl(lanBase, token) || lanBase;
|
|
801
|
+
}
|
|
802
|
+
} catch {
|
|
803
|
+
// fall through to base URL
|
|
804
|
+
}
|
|
805
|
+
|
|
792
806
|
if (!token) return base;
|
|
793
807
|
return appendTokenToUrl(base, token) || base;
|
|
794
808
|
}
|
|
@@ -3042,6 +3056,7 @@ async function refreshMenuButton() {
|
|
|
3042
3056
|
|
|
3043
3057
|
const FAST_COMMANDS = new Set([
|
|
3044
3058
|
"/menu",
|
|
3059
|
+
"/background",
|
|
3045
3060
|
"/status",
|
|
3046
3061
|
"/tasks",
|
|
3047
3062
|
"/agents",
|
|
@@ -3063,39 +3078,36 @@ const FAST_COMMANDS = new Set([
|
|
|
3063
3078
|
]);
|
|
3064
3079
|
|
|
3065
3080
|
function getTelegramWebAppUrl(url) {
|
|
3066
|
-
//
|
|
3081
|
+
// Telegram Mini App must be HTTPS and publicly reachable.
|
|
3082
|
+
// Priority: explicit env URL -> tunnel URL -> provided URL.
|
|
3083
|
+
const explicit =
|
|
3084
|
+
process.env.TELEGRAM_WEBAPP_URL || process.env.TELEGRAM_UI_BASE_URL || "";
|
|
3067
3085
|
const tUrl = getTunnelUrl();
|
|
3068
|
-
|
|
3069
|
-
const normalizedTunnel = String(tUrl || "").trim().replace(/\/+$/, "");
|
|
3070
|
-
return appendTokenToUrl(normalizedTunnel, getSessionToken()) || normalizedTunnel;
|
|
3071
|
-
}
|
|
3086
|
+
const candidates = [explicit, tUrl, url];
|
|
3072
3087
|
|
|
3073
|
-
const
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
if (
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
}
|
|
3086
|
-
return null;
|
|
3087
|
-
}
|
|
3088
|
-
const normalizedUrl = parsed.toString().replace(/\/+$/, "");
|
|
3089
|
-
return appendTokenToUrl(normalizedUrl, getSessionToken()) || normalizedUrl;
|
|
3090
|
-
} catch {
|
|
3091
|
-
if (telegramWebAppUrlWarned !== normalized) {
|
|
3092
|
-
telegramWebAppUrlWarned = normalized;
|
|
3093
|
-
console.warn(
|
|
3094
|
-
`[telegram-bot] mini app URL is invalid: "${normalized}". Falling back to normal URL buttons only.`,
|
|
3095
|
-
);
|
|
3088
|
+
for (const candidate of candidates) {
|
|
3089
|
+
const normalized = String(candidate || "")
|
|
3090
|
+
.trim()
|
|
3091
|
+
.replace(/\/+$/, "");
|
|
3092
|
+
if (!normalized) continue;
|
|
3093
|
+
try {
|
|
3094
|
+
const parsed = new URL(normalized);
|
|
3095
|
+
if (parsed.protocol !== "https:") continue;
|
|
3096
|
+
const normalizedUrl = parsed.toString().replace(/\/+$/, "");
|
|
3097
|
+
return appendTokenToUrl(normalizedUrl, getSessionToken()) || normalizedUrl;
|
|
3098
|
+
} catch {
|
|
3099
|
+
// try next candidate
|
|
3096
3100
|
}
|
|
3097
|
-
return null;
|
|
3098
3101
|
}
|
|
3102
|
+
|
|
3103
|
+
const warnSource = String(explicit || tUrl || url || "").trim();
|
|
3104
|
+
if (warnSource && telegramWebAppUrlWarned !== warnSource) {
|
|
3105
|
+
telegramWebAppUrlWarned = warnSource;
|
|
3106
|
+
console.warn(
|
|
3107
|
+
`[telegram-bot] mini app URL requires a valid HTTPS URL. Got "${warnSource}". Falling back to browser URL buttons only.`,
|
|
3108
|
+
);
|
|
3109
|
+
}
|
|
3110
|
+
return null;
|
|
3099
3111
|
}
|
|
3100
3112
|
|
|
3101
3113
|
function appendTokenToUrl(inputUrl, token) {
|
|
@@ -4142,6 +4154,11 @@ Object.assign(UI_SCREENS, {
|
|
|
4142
4154
|
},
|
|
4143
4155
|
uiButton("✖", "cb:close_menu"),
|
|
4144
4156
|
]);
|
|
4157
|
+
if (telegramUiUrl) {
|
|
4158
|
+
rows.unshift([
|
|
4159
|
+
{ text: "🌐 Open in Browser", url: getBrowserUiUrl() || telegramUiUrl },
|
|
4160
|
+
]);
|
|
4161
|
+
}
|
|
4145
4162
|
} else if (telegramUiUrl) {
|
|
4146
4163
|
rows.unshift([
|
|
4147
4164
|
{ text: "🌐 Open Control Center", url: getBrowserUiUrl() || telegramUiUrl },
|
|
@@ -210,6 +210,15 @@ function categorizeMessage(msg) {
|
|
|
210
210
|
return "message";
|
|
211
211
|
}
|
|
212
212
|
|
|
213
|
+
function isThinkingTraceMessage(msg) {
|
|
214
|
+
if (!msg) return false;
|
|
215
|
+
const category = categorizeMessage(msg);
|
|
216
|
+
if (category === "tool" || category === "result" || category === "error") {
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
return (msg.type || "").toLowerCase() === "system";
|
|
220
|
+
}
|
|
221
|
+
|
|
213
222
|
function formatMessageLine(msg) {
|
|
214
223
|
const timestamp = msg?.timestamp || "";
|
|
215
224
|
const kind = msg?.role || msg?.type || "message";
|
|
@@ -258,6 +267,38 @@ const ChatBubble = memo(function ChatBubble({ msg }) {
|
|
|
258
267
|
`;
|
|
259
268
|
}, (prev, next) => prev.msg === next.msg);
|
|
260
269
|
|
|
270
|
+
const ThinkingPanel = memo(function ThinkingPanel({ entries }) {
|
|
271
|
+
if (!Array.isArray(entries) || entries.length === 0) return null;
|
|
272
|
+
return html`
|
|
273
|
+
<details class="chat-thinking-panel">
|
|
274
|
+
<summary class="chat-thinking-summary">
|
|
275
|
+
🧠 Thinking · ${entries.length} ${entries.length === 1 ? "step" : "steps"}
|
|
276
|
+
</summary>
|
|
277
|
+
<div class="chat-thinking-body">
|
|
278
|
+
${entries.map((entry, index) => {
|
|
279
|
+
const category = categorizeMessage(entry);
|
|
280
|
+
const label =
|
|
281
|
+
category === "tool"
|
|
282
|
+
? "Tool"
|
|
283
|
+
: category === "result"
|
|
284
|
+
? "Result"
|
|
285
|
+
: category === "error"
|
|
286
|
+
? "Error"
|
|
287
|
+
: "System";
|
|
288
|
+
return html`
|
|
289
|
+
<div class="chat-thinking-item" key=${entry.id || entry.timestamp || `thinking-${index}`}>
|
|
290
|
+
<div class="chat-thinking-item-label">${label}</div>
|
|
291
|
+
<div class="chat-thinking-item-content">
|
|
292
|
+
<${MessageContent} text=${entry.content || ""} />
|
|
293
|
+
</div>
|
|
294
|
+
</div>
|
|
295
|
+
`;
|
|
296
|
+
})}
|
|
297
|
+
</div>
|
|
298
|
+
</details>
|
|
299
|
+
`;
|
|
300
|
+
});
|
|
301
|
+
|
|
261
302
|
/* ─── Chat View component ─── */
|
|
262
303
|
export function ChatView({ sessionId, readOnly = false, embedded = false }) {
|
|
263
304
|
const [input, setInput] = useState("");
|
|
@@ -351,23 +392,60 @@ export function ChatView({ sessionId, readOnly = false, embedded = false }) {
|
|
|
351
392
|
}
|
|
352
393
|
}, [messages.length]);
|
|
353
394
|
|
|
354
|
-
/*
|
|
355
|
-
subscriptions. The status bar cosmetics don't justify re-rendering the
|
|
356
|
-
entire 200-message list. We piggyback on the messages.length change
|
|
357
|
-
(which already triggers a re-render) to refresh these values. */
|
|
395
|
+
/* Keep status indicators fully live while an agent is active. */
|
|
358
396
|
let errorCount = 0;
|
|
359
397
|
let statusState = "idle";
|
|
360
398
|
let statusText = "";
|
|
361
399
|
try {
|
|
362
|
-
errorCount = totalErrorCount.
|
|
363
|
-
statusState = paused ? "paused" : (agentStatus.
|
|
364
|
-
statusText = paused ? "Stream paused" : (agentStatusText.
|
|
400
|
+
errorCount = totalErrorCount.value || 0;
|
|
401
|
+
statusState = paused ? "paused" : (agentStatus.value?.state || "idle");
|
|
402
|
+
statusText = paused ? "Stream paused" : (agentStatusText.value || "Ready");
|
|
365
403
|
} catch (err) {
|
|
366
404
|
console.warn("[ChatView] Failed to read status signals:", err);
|
|
367
405
|
statusState = paused ? "paused" : "idle";
|
|
368
406
|
statusText = paused ? "Stream paused" : "Ready";
|
|
369
407
|
}
|
|
370
408
|
|
|
409
|
+
const renderItems = useMemo(() => {
|
|
410
|
+
const items = [];
|
|
411
|
+
let pendingThinking = [];
|
|
412
|
+
let panelIndex = 0;
|
|
413
|
+
|
|
414
|
+
const flushThinking = (anchor = "") => {
|
|
415
|
+
if (pendingThinking.length === 0) return;
|
|
416
|
+
const seed = pendingThinking[0]?.id || pendingThinking[0]?.timestamp || panelIndex;
|
|
417
|
+
items.push({
|
|
418
|
+
kind: "thinking",
|
|
419
|
+
key: `thinking-${sessionId || "session"}-${seed}-${anchor}-${panelIndex}`,
|
|
420
|
+
entries: pendingThinking,
|
|
421
|
+
});
|
|
422
|
+
pendingThinking = [];
|
|
423
|
+
panelIndex += 1;
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
for (const msg of visibleMessages) {
|
|
427
|
+
if (isThinkingTraceMessage(msg)) {
|
|
428
|
+
pendingThinking.push(msg);
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const isAssistantMessage =
|
|
433
|
+
(msg.role || "") === "assistant" || (msg.type || "") === "agent_message";
|
|
434
|
+
if (isAssistantMessage) {
|
|
435
|
+
flushThinking(msg.id || msg.timestamp || "assistant");
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
items.push({
|
|
439
|
+
kind: "message",
|
|
440
|
+
key: msg.id || msg.timestamp || `msg-${items.length}`,
|
|
441
|
+
msg,
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
flushThinking("tail");
|
|
446
|
+
return items;
|
|
447
|
+
}, [visibleMessages, sessionId]);
|
|
448
|
+
|
|
371
449
|
const refreshMessages = useCallback(async () => {
|
|
372
450
|
if (!sessionId) return;
|
|
373
451
|
setLoading(true);
|
|
@@ -461,7 +539,7 @@ export function ChatView({ sessionId, readOnly = false, embedded = false }) {
|
|
|
461
539
|
if (!paused && autoScroll) {
|
|
462
540
|
// Use smooth scroll for small increments, instant for large jumps
|
|
463
541
|
const gap = el.scrollHeight - el.scrollTop - el.clientHeight;
|
|
464
|
-
el.scrollTo({ top: el.scrollHeight, behavior: gap < 800 ? "smooth" : "
|
|
542
|
+
el.scrollTo({ top: el.scrollHeight, behavior: gap < 800 ? "smooth" : "auto" });
|
|
465
543
|
return;
|
|
466
544
|
}
|
|
467
545
|
if (newMessages && !autoScroll) {
|
|
@@ -806,12 +884,10 @@ export function ChatView({ sessionId, readOnly = false, embedded = false }) {
|
|
|
806
884
|
</button>
|
|
807
885
|
</div>
|
|
808
886
|
`}
|
|
809
|
-
${
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
/>`
|
|
814
|
-
)}
|
|
887
|
+
${renderItems.map((item) => item.kind === "thinking"
|
|
888
|
+
? html`<${ThinkingPanel} key=${item.key} entries=${item.entries} />`
|
|
889
|
+
: html`<${ChatBubble} key=${item.key} msg=${item.msg} />`
|
|
890
|
+
)}
|
|
815
891
|
|
|
816
892
|
${/* Pending messages (optimistic rendering) — use .peek() to avoid
|
|
817
893
|
subscribing ChatView to pendingMessages signal. Pending messages
|
package/ui/modules/streaming.js
CHANGED
|
@@ -536,7 +536,7 @@ export const agentStatusText = computed(() => {
|
|
|
536
536
|
});
|
|
537
537
|
|
|
538
538
|
let _idleTimer = null;
|
|
539
|
-
const IDLE_TIMEOUT =
|
|
539
|
+
const IDLE_TIMEOUT = 45000;
|
|
540
540
|
|
|
541
541
|
/**
|
|
542
542
|
* Internal: transition agent state and reset idle timer.
|
|
@@ -600,6 +600,12 @@ export function startAgentStatusTracking() {
|
|
|
600
600
|
const role = message.role;
|
|
601
601
|
const type = message.type;
|
|
602
602
|
const adapter = payload.session?.type || "";
|
|
603
|
+
const sessionStatus = payload.session?.status || "active";
|
|
604
|
+
|
|
605
|
+
if (sessionStatus !== "active") {
|
|
606
|
+
_setAgentState("idle", "");
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
603
609
|
|
|
604
610
|
if (role === "assistant" || type === "agent_message") {
|
|
605
611
|
_setAgentState("streaming", adapter);
|
|
@@ -607,7 +613,7 @@ export function startAgentStatusTracking() {
|
|
|
607
613
|
_setAgentState("executing", adapter);
|
|
608
614
|
} else if (type === "tool_result") {
|
|
609
615
|
_setAgentState("streaming", adapter);
|
|
610
|
-
} else if (type === "error" || type === "
|
|
616
|
+
} else if (type === "error" || type === "stream_error") {
|
|
611
617
|
_setAgentState("idle", "");
|
|
612
618
|
}
|
|
613
619
|
});
|
package/ui/styles/sessions.css
CHANGED
|
@@ -771,6 +771,7 @@
|
|
|
771
771
|
min-height: 0;
|
|
772
772
|
overflow-y: auto;
|
|
773
773
|
overflow-x: hidden;
|
|
774
|
+
touch-action: pan-y;
|
|
774
775
|
-webkit-overflow-scrolling: touch;
|
|
775
776
|
overscroll-behavior-y: contain;
|
|
776
777
|
scroll-padding-bottom: 80px;
|
|
@@ -952,6 +953,58 @@
|
|
|
952
953
|
margin-top: 6px;
|
|
953
954
|
}
|
|
954
955
|
|
|
956
|
+
.chat-thinking-panel {
|
|
957
|
+
align-self: stretch;
|
|
958
|
+
border: 1px solid var(--border);
|
|
959
|
+
border-radius: 12px;
|
|
960
|
+
background: rgba(255, 255, 255, 0.02);
|
|
961
|
+
margin: 2px 0 4px;
|
|
962
|
+
overflow: hidden;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
.chat-thinking-summary {
|
|
966
|
+
cursor: pointer;
|
|
967
|
+
user-select: none;
|
|
968
|
+
font-size: 12px;
|
|
969
|
+
font-weight: 600;
|
|
970
|
+
color: var(--text-secondary);
|
|
971
|
+
padding: 8px 12px;
|
|
972
|
+
list-style: none;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
.chat-thinking-summary::-webkit-details-marker {
|
|
976
|
+
display: none;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
.chat-thinking-body {
|
|
980
|
+
border-top: 1px solid var(--border);
|
|
981
|
+
padding: 8px 10px;
|
|
982
|
+
display: flex;
|
|
983
|
+
flex-direction: column;
|
|
984
|
+
gap: 8px;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
.chat-thinking-item {
|
|
988
|
+
display: flex;
|
|
989
|
+
flex-direction: column;
|
|
990
|
+
gap: 4px;
|
|
991
|
+
padding: 6px 8px;
|
|
992
|
+
border-radius: 10px;
|
|
993
|
+
background: rgba(255, 255, 255, 0.02);
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
.chat-thinking-item-label {
|
|
997
|
+
font-size: 10px;
|
|
998
|
+
text-transform: uppercase;
|
|
999
|
+
letter-spacing: 0.06em;
|
|
1000
|
+
color: var(--text-hint);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
.chat-thinking-item-content {
|
|
1004
|
+
font-size: 12px;
|
|
1005
|
+
color: var(--text-secondary);
|
|
1006
|
+
}
|
|
1007
|
+
|
|
955
1008
|
/* Subtle entrance for bubbles — only the last few get the animation
|
|
956
1009
|
(content-visibility: auto on older bubbles skips them for free) */
|
|
957
1010
|
.chat-bubble:last-child,
|
|
@@ -1441,7 +1494,7 @@ ul.md-list li::before {
|
|
|
1441
1494
|
position: sticky;
|
|
1442
1495
|
bottom: 0;
|
|
1443
1496
|
z-index: 10;
|
|
1444
|
-
padding: 16px 16px;
|
|
1497
|
+
padding: 16px 16px calc(16px + var(--chat-keyboard-offset, 0px));
|
|
1445
1498
|
border-top: 1px solid var(--border);
|
|
1446
1499
|
background: var(--bg-surface, var(--bg-card));
|
|
1447
1500
|
flex-shrink: 0;
|
|
@@ -1466,7 +1519,7 @@ ul.md-list li::before {
|
|
|
1466
1519
|
.session-detail > .chat-view-embedded {
|
|
1467
1520
|
flex: 1;
|
|
1468
1521
|
min-height: 0;
|
|
1469
|
-
overflow
|
|
1522
|
+
overflow: hidden;
|
|
1470
1523
|
display: flex;
|
|
1471
1524
|
flex-direction: column;
|
|
1472
1525
|
}
|
|
@@ -1481,11 +1534,11 @@ ul.md-list li::before {
|
|
|
1481
1534
|
}
|
|
1482
1535
|
|
|
1483
1536
|
.app-shell[data-tab="chat"] .chat-view-embedded .chat-messages {
|
|
1484
|
-
padding: 16px 20px 16px;
|
|
1537
|
+
padding: 16px 20px calc(16px + var(--chat-keyboard-offset, 0px));
|
|
1485
1538
|
}
|
|
1486
1539
|
|
|
1487
1540
|
.app-shell[data-tab="chat"] .chat-input-area {
|
|
1488
|
-
padding: 0 0 calc(12px + var(--safe-bottom, 0px));
|
|
1541
|
+
padding: 0 0 calc(12px + var(--safe-bottom, 0px) + var(--chat-keyboard-offset, 0px));
|
|
1489
1542
|
}
|
|
1490
1543
|
|
|
1491
1544
|
.chat-input-wrapper {
|
package/ui/tabs/chat.js
CHANGED
|
@@ -323,6 +323,52 @@ export function ChatTab() {
|
|
|
323
323
|
};
|
|
324
324
|
}, []);
|
|
325
325
|
|
|
326
|
+
useEffect(() => {
|
|
327
|
+
if (typeof window === "undefined" || typeof document === "undefined") {
|
|
328
|
+
return undefined;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const root = document.documentElement;
|
|
332
|
+
const viewport = window.visualViewport;
|
|
333
|
+
|
|
334
|
+
const updateKeyboardOffset = () => {
|
|
335
|
+
if (!viewport) {
|
|
336
|
+
root.style.setProperty("--chat-keyboard-offset", "0px");
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const keyboardOffset = Math.max(
|
|
341
|
+
0,
|
|
342
|
+
Math.round(window.innerHeight - viewport.height - viewport.offsetTop),
|
|
343
|
+
);
|
|
344
|
+
root.style.setProperty("--chat-keyboard-offset", `${keyboardOffset}px`);
|
|
345
|
+
if (keyboardOffset > 0) {
|
|
346
|
+
root.setAttribute("data-chat-keyboard-open", "true");
|
|
347
|
+
} else {
|
|
348
|
+
root.removeAttribute("data-chat-keyboard-open");
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
updateKeyboardOffset();
|
|
353
|
+
|
|
354
|
+
if (!viewport) return () => {
|
|
355
|
+
root.style.setProperty("--chat-keyboard-offset", "0px");
|
|
356
|
+
root.removeAttribute("data-chat-keyboard-open");
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
viewport.addEventListener("resize", updateKeyboardOffset);
|
|
360
|
+
viewport.addEventListener("scroll", updateKeyboardOffset);
|
|
361
|
+
window.addEventListener("orientationchange", updateKeyboardOffset);
|
|
362
|
+
|
|
363
|
+
return () => {
|
|
364
|
+
viewport.removeEventListener("resize", updateKeyboardOffset);
|
|
365
|
+
viewport.removeEventListener("scroll", updateKeyboardOffset);
|
|
366
|
+
window.removeEventListener("orientationchange", updateKeyboardOffset);
|
|
367
|
+
root.style.setProperty("--chat-keyboard-offset", "0px");
|
|
368
|
+
root.removeAttribute("data-chat-keyboard-open");
|
|
369
|
+
};
|
|
370
|
+
}, []);
|
|
371
|
+
|
|
326
372
|
useEffect(() => {
|
|
327
373
|
if (typeof document === "undefined") return undefined;
|
|
328
374
|
if (focusMode) {
|
package/ui/tabs/tasks.js
CHANGED
|
@@ -1178,30 +1178,23 @@ export function TasksTab() {
|
|
|
1178
1178
|
|
|
1179
1179
|
const startTask = async ({ taskId, sdk, model }) => {
|
|
1180
1180
|
haptic("medium");
|
|
1181
|
-
const prev = cloneValue(tasks);
|
|
1182
1181
|
let res = null;
|
|
1183
|
-
|
|
1184
|
-
(
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
},
|
|
1200
|
-
() => {
|
|
1201
|
-
tasksData.value = prev;
|
|
1202
|
-
},
|
|
1203
|
-
).catch(() => {});
|
|
1204
|
-
if (res?.wasPaused) {
|
|
1182
|
+
try {
|
|
1183
|
+
res = await apiFetch("/api/tasks/start", {
|
|
1184
|
+
method: "POST",
|
|
1185
|
+
body: JSON.stringify({
|
|
1186
|
+
taskId,
|
|
1187
|
+
...(sdk ? { sdk } : {}),
|
|
1188
|
+
...(model ? { model } : {}),
|
|
1189
|
+
}),
|
|
1190
|
+
});
|
|
1191
|
+
} catch {
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
if (res?.queued) {
|
|
1196
|
+
showToast("Task queued (waiting for free slot)", "info");
|
|
1197
|
+
} else if (res?.wasPaused) {
|
|
1205
1198
|
showToast("Task started (executor paused — force-dispatched)", "warning");
|
|
1206
1199
|
} else if (res) {
|
|
1207
1200
|
showToast("Task started", "success");
|
package/ui-server.mjs
CHANGED
|
@@ -2968,6 +2968,30 @@ async function handleApi(req, res, url) {
|
|
|
2968
2968
|
jsonResponse(res, 404, { ok: false, error: "Task not found." });
|
|
2969
2969
|
return;
|
|
2970
2970
|
}
|
|
2971
|
+
|
|
2972
|
+
const status = executor.getStatus?.() || {};
|
|
2973
|
+
const freeSlots =
|
|
2974
|
+
(status.maxParallel || 0) - (status.activeSlots || 0);
|
|
2975
|
+
|
|
2976
|
+
if (freeSlots <= 0) {
|
|
2977
|
+
jsonResponse(res, 202, {
|
|
2978
|
+
ok: true,
|
|
2979
|
+
taskId,
|
|
2980
|
+
queued: true,
|
|
2981
|
+
started: false,
|
|
2982
|
+
reason: "No free slots",
|
|
2983
|
+
});
|
|
2984
|
+
broadcastUiEvent(
|
|
2985
|
+
["tasks", "overview", "executor", "agents"],
|
|
2986
|
+
"invalidate",
|
|
2987
|
+
{
|
|
2988
|
+
reason: "task-queued",
|
|
2989
|
+
taskId,
|
|
2990
|
+
},
|
|
2991
|
+
);
|
|
2992
|
+
return;
|
|
2993
|
+
}
|
|
2994
|
+
|
|
2971
2995
|
try {
|
|
2972
2996
|
if (typeof adapter.updateTaskStatus === "function") {
|
|
2973
2997
|
await adapter.updateTaskStatus(taskId, "inprogress");
|
|
@@ -2989,7 +3013,13 @@ async function handleApi(req, res, url) {
|
|
|
2989
3013
|
`[telegram-ui] failed to execute task ${taskId}: ${error.message}`,
|
|
2990
3014
|
);
|
|
2991
3015
|
});
|
|
2992
|
-
jsonResponse(res, 200, {
|
|
3016
|
+
jsonResponse(res, 200, {
|
|
3017
|
+
ok: true,
|
|
3018
|
+
taskId,
|
|
3019
|
+
queued: false,
|
|
3020
|
+
started: true,
|
|
3021
|
+
wasPaused,
|
|
3022
|
+
});
|
|
2993
3023
|
broadcastUiEvent(
|
|
2994
3024
|
["tasks", "overview", "executor", "agents"],
|
|
2995
3025
|
"invalidate",
|