codex-autorunner 0.1.2__py3-none-any.whl → 1.1.0__py3-none-any.whl
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.
- codex_autorunner/__init__.py +12 -1
- codex_autorunner/__main__.py +4 -0
- codex_autorunner/agents/codex/harness.py +1 -1
- codex_autorunner/agents/opencode/client.py +68 -35
- codex_autorunner/agents/opencode/constants.py +3 -0
- codex_autorunner/agents/opencode/harness.py +6 -1
- codex_autorunner/agents/opencode/logging.py +21 -5
- codex_autorunner/agents/opencode/run_prompt.py +1 -0
- codex_autorunner/agents/opencode/runtime.py +176 -47
- codex_autorunner/agents/opencode/supervisor.py +36 -48
- codex_autorunner/agents/registry.py +155 -8
- codex_autorunner/api.py +25 -0
- codex_autorunner/bootstrap.py +22 -37
- codex_autorunner/cli.py +5 -1156
- codex_autorunner/codex_cli.py +20 -84
- codex_autorunner/core/__init__.py +4 -0
- codex_autorunner/core/about_car.py +49 -32
- codex_autorunner/core/adapter_utils.py +21 -0
- codex_autorunner/core/app_server_ids.py +59 -0
- codex_autorunner/core/app_server_logging.py +7 -3
- codex_autorunner/core/app_server_prompts.py +27 -260
- codex_autorunner/core/app_server_threads.py +26 -28
- codex_autorunner/core/app_server_utils.py +165 -0
- codex_autorunner/core/archive.py +349 -0
- codex_autorunner/core/codex_runner.py +12 -2
- codex_autorunner/core/config.py +587 -103
- codex_autorunner/core/docs.py +10 -2
- codex_autorunner/core/drafts.py +136 -0
- codex_autorunner/core/engine.py +1531 -866
- codex_autorunner/core/exceptions.py +4 -0
- codex_autorunner/core/flows/__init__.py +25 -0
- codex_autorunner/core/flows/controller.py +202 -0
- codex_autorunner/core/flows/definition.py +82 -0
- codex_autorunner/core/flows/models.py +88 -0
- codex_autorunner/core/flows/reasons.py +52 -0
- codex_autorunner/core/flows/reconciler.py +131 -0
- codex_autorunner/core/flows/runtime.py +382 -0
- codex_autorunner/core/flows/store.py +568 -0
- codex_autorunner/core/flows/transition.py +138 -0
- codex_autorunner/core/flows/ux_helpers.py +257 -0
- codex_autorunner/core/flows/worker_process.py +242 -0
- codex_autorunner/core/git_utils.py +62 -0
- codex_autorunner/core/hub.py +136 -16
- codex_autorunner/core/locks.py +4 -0
- codex_autorunner/core/notifications.py +14 -2
- codex_autorunner/core/ports/__init__.py +28 -0
- codex_autorunner/core/ports/agent_backend.py +150 -0
- codex_autorunner/core/ports/backend_orchestrator.py +41 -0
- codex_autorunner/core/ports/run_event.py +91 -0
- codex_autorunner/core/prompt.py +15 -7
- codex_autorunner/core/redaction.py +29 -0
- codex_autorunner/core/review_context.py +5 -8
- codex_autorunner/core/run_index.py +6 -0
- codex_autorunner/core/runner_process.py +5 -2
- codex_autorunner/core/state.py +0 -88
- codex_autorunner/core/state_roots.py +57 -0
- codex_autorunner/core/supervisor_protocol.py +15 -0
- codex_autorunner/core/supervisor_utils.py +67 -0
- codex_autorunner/core/text_delta_coalescer.py +54 -0
- codex_autorunner/core/ticket_linter_cli.py +201 -0
- codex_autorunner/core/ticket_manager_cli.py +432 -0
- codex_autorunner/core/update.py +24 -16
- codex_autorunner/core/update_paths.py +28 -0
- codex_autorunner/core/update_runner.py +2 -0
- codex_autorunner/core/usage.py +164 -12
- codex_autorunner/core/utils.py +120 -11
- codex_autorunner/discovery.py +2 -4
- codex_autorunner/flows/review/__init__.py +17 -0
- codex_autorunner/{core/review.py → flows/review/service.py} +15 -10
- codex_autorunner/flows/ticket_flow/__init__.py +3 -0
- codex_autorunner/flows/ticket_flow/definition.py +98 -0
- codex_autorunner/integrations/agents/__init__.py +17 -0
- codex_autorunner/integrations/agents/backend_orchestrator.py +284 -0
- codex_autorunner/integrations/agents/codex_adapter.py +90 -0
- codex_autorunner/integrations/agents/codex_backend.py +448 -0
- codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
- codex_autorunner/integrations/agents/opencode_backend.py +598 -0
- codex_autorunner/integrations/agents/runner.py +91 -0
- codex_autorunner/integrations/agents/wiring.py +271 -0
- codex_autorunner/integrations/app_server/client.py +583 -152
- codex_autorunner/integrations/app_server/env.py +2 -107
- codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
- codex_autorunner/integrations/app_server/supervisor.py +59 -33
- codex_autorunner/integrations/telegram/adapter.py +204 -165
- codex_autorunner/integrations/telegram/api_schemas.py +120 -0
- codex_autorunner/integrations/telegram/config.py +221 -0
- codex_autorunner/integrations/telegram/constants.py +17 -2
- codex_autorunner/integrations/telegram/dispatch.py +17 -0
- codex_autorunner/integrations/telegram/doctor.py +47 -0
- codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -4
- codex_autorunner/integrations/telegram/handlers/commands/__init__.py +2 -0
- codex_autorunner/integrations/telegram/handlers/commands/execution.py +53 -57
- codex_autorunner/integrations/telegram/handlers/commands/files.py +2 -6
- codex_autorunner/integrations/telegram/handlers/commands/flows.py +1364 -0
- codex_autorunner/integrations/telegram/handlers/commands/formatting.py +1 -1
- codex_autorunner/integrations/telegram/handlers/commands/github.py +41 -582
- codex_autorunner/integrations/telegram/handlers/commands/workspace.py +8 -8
- codex_autorunner/integrations/telegram/handlers/commands_runtime.py +137 -478
- codex_autorunner/integrations/telegram/handlers/commands_spec.py +17 -4
- codex_autorunner/integrations/telegram/handlers/messages.py +121 -9
- codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
- codex_autorunner/integrations/telegram/helpers.py +111 -16
- codex_autorunner/integrations/telegram/outbox.py +208 -37
- codex_autorunner/integrations/telegram/progress_stream.py +3 -10
- codex_autorunner/integrations/telegram/service.py +221 -42
- codex_autorunner/integrations/telegram/state.py +100 -2
- codex_autorunner/integrations/telegram/ticket_flow_bridge.py +611 -0
- codex_autorunner/integrations/telegram/transport.py +39 -4
- codex_autorunner/integrations/telegram/trigger_mode.py +53 -0
- codex_autorunner/manifest.py +2 -0
- codex_autorunner/plugin_api.py +22 -0
- codex_autorunner/routes/__init__.py +37 -67
- codex_autorunner/routes/agents.py +2 -137
- codex_autorunner/routes/analytics.py +3 -0
- codex_autorunner/routes/app_server.py +2 -131
- codex_autorunner/routes/base.py +2 -624
- codex_autorunner/routes/file_chat.py +7 -0
- codex_autorunner/routes/flows.py +7 -0
- codex_autorunner/routes/messages.py +7 -0
- codex_autorunner/routes/repos.py +2 -196
- codex_autorunner/routes/review.py +2 -147
- codex_autorunner/routes/sessions.py +2 -175
- codex_autorunner/routes/settings.py +2 -168
- codex_autorunner/routes/shared.py +2 -275
- codex_autorunner/routes/system.py +4 -188
- codex_autorunner/routes/usage.py +3 -0
- codex_autorunner/routes/voice.py +2 -119
- codex_autorunner/routes/workspace.py +3 -0
- codex_autorunner/server.py +3 -2
- codex_autorunner/static/agentControls.js +41 -11
- codex_autorunner/static/agentEvents.js +248 -0
- codex_autorunner/static/app.js +35 -24
- codex_autorunner/static/archive.js +826 -0
- codex_autorunner/static/archiveApi.js +37 -0
- codex_autorunner/static/autoRefresh.js +36 -8
- codex_autorunner/static/bootstrap.js +1 -0
- codex_autorunner/static/bus.js +1 -0
- codex_autorunner/static/cache.js +1 -0
- codex_autorunner/static/constants.js +20 -4
- codex_autorunner/static/dashboard.js +344 -325
- codex_autorunner/static/diffRenderer.js +37 -0
- codex_autorunner/static/docChatCore.js +324 -0
- codex_autorunner/static/docChatStorage.js +65 -0
- codex_autorunner/static/docChatVoice.js +65 -0
- codex_autorunner/static/docEditor.js +133 -0
- codex_autorunner/static/env.js +1 -0
- codex_autorunner/static/eventSummarizer.js +166 -0
- codex_autorunner/static/fileChat.js +182 -0
- codex_autorunner/static/health.js +155 -0
- codex_autorunner/static/hub.js +126 -185
- codex_autorunner/static/index.html +839 -863
- codex_autorunner/static/liveUpdates.js +1 -0
- codex_autorunner/static/loader.js +1 -0
- codex_autorunner/static/messages.js +873 -0
- codex_autorunner/static/mobileCompact.js +2 -1
- codex_autorunner/static/preserve.js +17 -0
- codex_autorunner/static/settings.js +149 -217
- codex_autorunner/static/smartRefresh.js +52 -0
- codex_autorunner/static/styles.css +8850 -3876
- codex_autorunner/static/tabs.js +175 -11
- codex_autorunner/static/terminal.js +32 -0
- codex_autorunner/static/terminalManager.js +34 -59
- codex_autorunner/static/ticketChatActions.js +333 -0
- codex_autorunner/static/ticketChatEvents.js +16 -0
- codex_autorunner/static/ticketChatStorage.js +16 -0
- codex_autorunner/static/ticketChatStream.js +264 -0
- codex_autorunner/static/ticketEditor.js +844 -0
- codex_autorunner/static/ticketVoice.js +9 -0
- codex_autorunner/static/tickets.js +1988 -0
- codex_autorunner/static/utils.js +43 -3
- codex_autorunner/static/voice.js +1 -0
- codex_autorunner/static/workspace.js +765 -0
- codex_autorunner/static/workspaceApi.js +53 -0
- codex_autorunner/static/workspaceFileBrowser.js +504 -0
- codex_autorunner/surfaces/__init__.py +5 -0
- codex_autorunner/surfaces/cli/__init__.py +6 -0
- codex_autorunner/surfaces/cli/cli.py +1224 -0
- codex_autorunner/surfaces/cli/codex_cli.py +20 -0
- codex_autorunner/surfaces/telegram/__init__.py +3 -0
- codex_autorunner/surfaces/web/__init__.py +1 -0
- codex_autorunner/surfaces/web/app.py +2019 -0
- codex_autorunner/surfaces/web/hub_jobs.py +192 -0
- codex_autorunner/surfaces/web/middleware.py +587 -0
- codex_autorunner/surfaces/web/pty_session.py +370 -0
- codex_autorunner/surfaces/web/review.py +6 -0
- codex_autorunner/surfaces/web/routes/__init__.py +78 -0
- codex_autorunner/surfaces/web/routes/agents.py +138 -0
- codex_autorunner/surfaces/web/routes/analytics.py +277 -0
- codex_autorunner/surfaces/web/routes/app_server.py +132 -0
- codex_autorunner/surfaces/web/routes/archive.py +357 -0
- codex_autorunner/surfaces/web/routes/base.py +615 -0
- codex_autorunner/surfaces/web/routes/file_chat.py +836 -0
- codex_autorunner/surfaces/web/routes/flows.py +1164 -0
- codex_autorunner/surfaces/web/routes/messages.py +459 -0
- codex_autorunner/surfaces/web/routes/repos.py +197 -0
- codex_autorunner/surfaces/web/routes/review.py +148 -0
- codex_autorunner/surfaces/web/routes/sessions.py +176 -0
- codex_autorunner/surfaces/web/routes/settings.py +169 -0
- codex_autorunner/surfaces/web/routes/shared.py +280 -0
- codex_autorunner/surfaces/web/routes/system.py +196 -0
- codex_autorunner/surfaces/web/routes/usage.py +89 -0
- codex_autorunner/surfaces/web/routes/voice.py +120 -0
- codex_autorunner/surfaces/web/routes/workspace.py +271 -0
- codex_autorunner/surfaces/web/runner_manager.py +25 -0
- codex_autorunner/surfaces/web/schemas.py +417 -0
- codex_autorunner/surfaces/web/static_assets.py +490 -0
- codex_autorunner/surfaces/web/static_refresh.py +86 -0
- codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
- codex_autorunner/tickets/__init__.py +27 -0
- codex_autorunner/tickets/agent_pool.py +399 -0
- codex_autorunner/tickets/files.py +89 -0
- codex_autorunner/tickets/frontmatter.py +55 -0
- codex_autorunner/tickets/lint.py +102 -0
- codex_autorunner/tickets/models.py +97 -0
- codex_autorunner/tickets/outbox.py +244 -0
- codex_autorunner/tickets/replies.py +179 -0
- codex_autorunner/tickets/runner.py +881 -0
- codex_autorunner/tickets/spec_ingest.py +77 -0
- codex_autorunner/web/__init__.py +5 -1
- codex_autorunner/web/app.py +2 -1771
- codex_autorunner/web/hub_jobs.py +2 -191
- codex_autorunner/web/middleware.py +2 -587
- codex_autorunner/web/pty_session.py +2 -369
- codex_autorunner/web/runner_manager.py +2 -24
- codex_autorunner/web/schemas.py +2 -396
- codex_autorunner/web/static_assets.py +4 -484
- codex_autorunner/web/static_refresh.py +2 -85
- codex_autorunner/web/terminal_sessions.py +2 -77
- codex_autorunner/workspace/__init__.py +40 -0
- codex_autorunner/workspace/paths.py +335 -0
- codex_autorunner-1.1.0.dist-info/METADATA +154 -0
- codex_autorunner-1.1.0.dist-info/RECORD +308 -0
- {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/WHEEL +1 -1
- codex_autorunner/agents/execution/policy.py +0 -292
- codex_autorunner/agents/factory.py +0 -52
- codex_autorunner/agents/orchestrator.py +0 -358
- codex_autorunner/core/doc_chat.py +0 -1446
- codex_autorunner/core/snapshot.py +0 -580
- codex_autorunner/integrations/github/chatops.py +0 -268
- codex_autorunner/integrations/github/pr_flow.py +0 -1314
- codex_autorunner/routes/docs.py +0 -381
- codex_autorunner/routes/github.py +0 -327
- codex_autorunner/routes/runs.py +0 -250
- codex_autorunner/spec_ingest.py +0 -812
- codex_autorunner/static/docChatActions.js +0 -287
- codex_autorunner/static/docChatEvents.js +0 -300
- codex_autorunner/static/docChatRender.js +0 -205
- codex_autorunner/static/docChatStream.js +0 -361
- codex_autorunner/static/docs.js +0 -20
- codex_autorunner/static/docsClipboard.js +0 -69
- codex_autorunner/static/docsCrud.js +0 -257
- codex_autorunner/static/docsDocUpdates.js +0 -62
- codex_autorunner/static/docsDrafts.js +0 -16
- codex_autorunner/static/docsElements.js +0 -69
- codex_autorunner/static/docsInit.js +0 -285
- codex_autorunner/static/docsParse.js +0 -160
- codex_autorunner/static/docsSnapshot.js +0 -87
- codex_autorunner/static/docsSpecIngest.js +0 -263
- codex_autorunner/static/docsState.js +0 -127
- codex_autorunner/static/docsThreadRegistry.js +0 -44
- codex_autorunner/static/docsUi.js +0 -153
- codex_autorunner/static/docsVoice.js +0 -56
- codex_autorunner/static/github.js +0 -504
- codex_autorunner/static/logs.js +0 -678
- codex_autorunner/static/review.js +0 -157
- codex_autorunner/static/runs.js +0 -418
- codex_autorunner/static/snapshot.js +0 -124
- codex_autorunner/static/state.js +0 -94
- codex_autorunner/static/todoPreview.js +0 -27
- codex_autorunner/workspace.py +0 -16
- codex_autorunner-0.1.2.dist-info/METADATA +0 -249
- codex_autorunner-0.1.2.dist-info/RECORD +0 -222
- /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
- {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/entry_points.txt +0 -0
- {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/licenses/LICENSE +0 -0
- {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,873 @@
|
|
|
1
|
+
// GENERATED FILE - do not edit directly. Source: static_src/
|
|
2
|
+
import { api, escapeHtml, flash, getUrlParams, resolvePath, updateUrlParams, } from "./utils.js";
|
|
3
|
+
import { subscribe } from "./bus.js";
|
|
4
|
+
import { isRepoHealthy } from "./health.js";
|
|
5
|
+
import { preserveScroll } from "./preserve.js";
|
|
6
|
+
import { createSmartRefresh } from "./smartRefresh.js";
|
|
7
|
+
let bellInitialized = false;
|
|
8
|
+
let messagesInitialized = false;
|
|
9
|
+
let activeRunId = null;
|
|
10
|
+
let selectedRunId = null;
|
|
11
|
+
const MESSAGE_REFRESH_REASONS = ["initial", "background", "manual"];
|
|
12
|
+
const threadsEl = document.getElementById("messages-thread-list");
|
|
13
|
+
const detailEl = document.getElementById("messages-thread-detail");
|
|
14
|
+
const layoutEl = document.querySelector(".messages-layout");
|
|
15
|
+
const backBtn = document.getElementById("messages-back-btn");
|
|
16
|
+
const refreshEl = document.getElementById("messages-refresh");
|
|
17
|
+
const replyBodyEl = document.getElementById("messages-reply-body");
|
|
18
|
+
const replyFilesEl = document.getElementById("messages-reply-files");
|
|
19
|
+
const replySendEl = document.getElementById("messages-reply-send");
|
|
20
|
+
let threadListRefreshCount = 0;
|
|
21
|
+
let threadDetailRefreshCount = 0;
|
|
22
|
+
function isMobileViewport() {
|
|
23
|
+
return window.innerWidth <= 640;
|
|
24
|
+
}
|
|
25
|
+
function showThreadList() {
|
|
26
|
+
layoutEl?.classList.remove("viewing-detail");
|
|
27
|
+
}
|
|
28
|
+
function showThreadDetail() {
|
|
29
|
+
if (isMobileViewport()) {
|
|
30
|
+
layoutEl?.classList.add("viewing-detail");
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function setThreadListRefreshing(active) {
|
|
34
|
+
if (!threadsEl)
|
|
35
|
+
return;
|
|
36
|
+
threadListRefreshCount = Math.max(0, threadListRefreshCount + (active ? 1 : -1));
|
|
37
|
+
threadsEl.classList.toggle("refreshing", threadListRefreshCount > 0);
|
|
38
|
+
}
|
|
39
|
+
function setThreadDetailRefreshing(active) {
|
|
40
|
+
if (!detailEl)
|
|
41
|
+
return;
|
|
42
|
+
threadDetailRefreshCount = Math.max(0, threadDetailRefreshCount + (active ? 1 : -1));
|
|
43
|
+
detailEl.classList.toggle("refreshing", threadDetailRefreshCount > 0);
|
|
44
|
+
}
|
|
45
|
+
function formatTimestamp(ts) {
|
|
46
|
+
if (!ts)
|
|
47
|
+
return "–";
|
|
48
|
+
const date = new Date(ts);
|
|
49
|
+
if (Number.isNaN(date.getTime()))
|
|
50
|
+
return ts;
|
|
51
|
+
return date.toLocaleString();
|
|
52
|
+
}
|
|
53
|
+
function setBadge(count) {
|
|
54
|
+
const badge = document.getElementById("tab-badge-inbox");
|
|
55
|
+
if (!badge)
|
|
56
|
+
return;
|
|
57
|
+
if (count > 0) {
|
|
58
|
+
badge.textContent = String(count);
|
|
59
|
+
badge.classList.remove("hidden");
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
badge.textContent = "";
|
|
63
|
+
badge.classList.add("hidden");
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
export async function refreshBell() {
|
|
67
|
+
if (!isRepoHealthy()) {
|
|
68
|
+
activeRunId = null;
|
|
69
|
+
setBadge(0);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
const res = (await api("/api/messages/active"));
|
|
74
|
+
if (res?.active && res.run_id) {
|
|
75
|
+
activeRunId = res.run_id;
|
|
76
|
+
setBadge(1);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
activeRunId = null;
|
|
80
|
+
setBadge(0);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch (_err) {
|
|
84
|
+
// Best-effort.
|
|
85
|
+
activeRunId = null;
|
|
86
|
+
setBadge(0);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
export function initMessageBell() {
|
|
90
|
+
if (bellInitialized)
|
|
91
|
+
return;
|
|
92
|
+
bellInitialized = true;
|
|
93
|
+
// Cheap polling. (The repo shell already does other polling; keep this light.)
|
|
94
|
+
refreshBell();
|
|
95
|
+
window.setInterval(() => {
|
|
96
|
+
if (document.hidden)
|
|
97
|
+
return;
|
|
98
|
+
if (!isRepoHealthy())
|
|
99
|
+
return;
|
|
100
|
+
refreshBell();
|
|
101
|
+
}, 15000);
|
|
102
|
+
subscribe("repo:health", (payload) => {
|
|
103
|
+
const status = payload?.status || "";
|
|
104
|
+
if (status === "ok" || status === "degraded") {
|
|
105
|
+
void refreshBell();
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
function formatRelativeTime(ts) {
|
|
110
|
+
if (!ts)
|
|
111
|
+
return "";
|
|
112
|
+
const date = new Date(ts);
|
|
113
|
+
if (Number.isNaN(date.getTime()))
|
|
114
|
+
return "";
|
|
115
|
+
const now = new Date();
|
|
116
|
+
const diffMs = now.getTime() - date.getTime();
|
|
117
|
+
const diffSecs = Math.floor(diffMs / 1000);
|
|
118
|
+
if (diffSecs < 60)
|
|
119
|
+
return "just now";
|
|
120
|
+
const diffMins = Math.floor(diffSecs / 60);
|
|
121
|
+
if (diffMins < 60)
|
|
122
|
+
return `${diffMins}m ago`;
|
|
123
|
+
const diffHours = Math.floor(diffMins / 60);
|
|
124
|
+
if (diffHours < 24)
|
|
125
|
+
return `${diffHours}h ago`;
|
|
126
|
+
const diffDays = Math.floor(diffHours / 24);
|
|
127
|
+
if (diffDays < 7)
|
|
128
|
+
return `${diffDays}d ago`;
|
|
129
|
+
return date.toLocaleDateString([], { month: "short", day: "numeric" });
|
|
130
|
+
}
|
|
131
|
+
function getStatusPillClass(status) {
|
|
132
|
+
switch (status) {
|
|
133
|
+
case "paused":
|
|
134
|
+
return "pill-action";
|
|
135
|
+
case "running":
|
|
136
|
+
case "pending":
|
|
137
|
+
return "pill-success";
|
|
138
|
+
case "completed":
|
|
139
|
+
return "pill-idle";
|
|
140
|
+
case "failed":
|
|
141
|
+
case "stopped":
|
|
142
|
+
return "pill-error";
|
|
143
|
+
default:
|
|
144
|
+
return "pill-idle";
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function renderThreadItem(thread) {
|
|
148
|
+
const latestDispatch = thread.latest?.dispatch;
|
|
149
|
+
const isHandoff = latestDispatch?.is_handoff || latestDispatch?.mode === "pause";
|
|
150
|
+
const title = latestDispatch?.title || (isHandoff ? "Handoff" : "Dispatch");
|
|
151
|
+
const subtitle = latestDispatch?.body ? latestDispatch.body.slice(0, 120) : "";
|
|
152
|
+
const isPaused = thread.status === "paused";
|
|
153
|
+
const isActive = selectedRunId && thread.run_id === selectedRunId;
|
|
154
|
+
// Only show action indicator if there's an unreplied handoff (pause)
|
|
155
|
+
// Compare dispatch_seq vs reply_seq to check if user has responded
|
|
156
|
+
const ticketState = thread.ticket_state;
|
|
157
|
+
const dispatchSeq = ticketState?.dispatch_seq ?? 0;
|
|
158
|
+
const replySeq = ticketState?.reply_seq ?? 0;
|
|
159
|
+
const hasUnrepliedHandoff = isPaused && (dispatchSeq > replySeq || (isHandoff && replySeq === 0));
|
|
160
|
+
const indicator = hasUnrepliedHandoff ? `<span class="messages-thread-indicator" title="Action required"></span>` : "";
|
|
161
|
+
const dispatches = thread.dispatch_count ?? 0;
|
|
162
|
+
const replies = thread.reply_count ?? 0;
|
|
163
|
+
// Format timestamp for last dispatch
|
|
164
|
+
const lastTs = thread.latest?.created_at;
|
|
165
|
+
const timeAgo = formatRelativeTime(lastTs);
|
|
166
|
+
// Status badge
|
|
167
|
+
const status = thread.status || "idle";
|
|
168
|
+
const statusClass = getStatusPillClass(status);
|
|
169
|
+
const statusLabel = status === "paused" && hasUnrepliedHandoff ? "action" : status;
|
|
170
|
+
// Build meta line with timestamp
|
|
171
|
+
const countPart = `${dispatches} dispatch${dispatches !== 1 ? "es" : ""} · ${replies} repl${replies !== 1 ? "ies" : "y"}`;
|
|
172
|
+
return `
|
|
173
|
+
<button class="messages-thread${isActive ? " active" : ""}" data-run-id="${escapeHtml(thread.run_id)}">
|
|
174
|
+
<div class="messages-thread-header">
|
|
175
|
+
<div class="messages-thread-title">${indicator}${escapeHtml(title)}</div>
|
|
176
|
+
<span class="pill pill-small ${statusClass}">${escapeHtml(statusLabel)}</span>
|
|
177
|
+
</div>
|
|
178
|
+
<div class="messages-thread-subtitle muted">${escapeHtml(subtitle)}</div>
|
|
179
|
+
<div class="messages-thread-meta-line">
|
|
180
|
+
<span class="messages-thread-counts">${escapeHtml(countPart)}</span>
|
|
181
|
+
${timeAgo ? `<span class="messages-thread-time">${escapeHtml(timeAgo)}</span>` : ""}
|
|
182
|
+
</div>
|
|
183
|
+
</button>
|
|
184
|
+
`;
|
|
185
|
+
}
|
|
186
|
+
function syncSelectedThread() {
|
|
187
|
+
if (!threadsEl)
|
|
188
|
+
return;
|
|
189
|
+
const buttons = threadsEl.querySelectorAll(".messages-thread");
|
|
190
|
+
buttons.forEach((btn) => {
|
|
191
|
+
const runId = btn.dataset.runId || "";
|
|
192
|
+
btn.classList.toggle("active", Boolean(runId) && runId === selectedRunId);
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
function threadListSignature(conversations) {
|
|
196
|
+
return conversations
|
|
197
|
+
.map((thread) => {
|
|
198
|
+
const latest = thread.latest;
|
|
199
|
+
const dispatch = latest?.dispatch;
|
|
200
|
+
const ticketState = thread.ticket_state;
|
|
201
|
+
return [
|
|
202
|
+
thread.run_id,
|
|
203
|
+
thread.status ?? "",
|
|
204
|
+
latest?.seq ?? "",
|
|
205
|
+
latest?.created_at ?? "",
|
|
206
|
+
dispatch?.mode ?? "",
|
|
207
|
+
dispatch?.is_handoff ? "1" : "0",
|
|
208
|
+
thread.dispatch_count ?? "",
|
|
209
|
+
thread.reply_count ?? "",
|
|
210
|
+
ticketState?.dispatch_seq ?? "",
|
|
211
|
+
ticketState?.reply_seq ?? "",
|
|
212
|
+
ticketState?.status ?? "",
|
|
213
|
+
].join("|");
|
|
214
|
+
})
|
|
215
|
+
.join("::");
|
|
216
|
+
}
|
|
217
|
+
function threadDetailSignature(detail) {
|
|
218
|
+
const dispatches = detail.dispatch_history || [];
|
|
219
|
+
const replies = detail.reply_history || [];
|
|
220
|
+
const maxDispatchSeq = dispatches.reduce((max, entry) => Math.max(max, entry.seq || 0), 0);
|
|
221
|
+
const maxReplySeq = replies.reduce((max, entry) => Math.max(max, entry.seq || 0), 0);
|
|
222
|
+
const lastDispatchAt = dispatches.find((entry) => entry.seq === maxDispatchSeq)?.created_at ?? "";
|
|
223
|
+
const lastReplyAt = replies.find((entry) => entry.seq === maxReplySeq)?.created_at ?? "";
|
|
224
|
+
const ticketState = detail.ticket_state;
|
|
225
|
+
return [
|
|
226
|
+
detail.run?.status ?? "",
|
|
227
|
+
detail.run?.created_at ?? "",
|
|
228
|
+
detail.dispatch_count ?? dispatches.length,
|
|
229
|
+
detail.reply_count ?? replies.length,
|
|
230
|
+
maxDispatchSeq,
|
|
231
|
+
maxReplySeq,
|
|
232
|
+
lastDispatchAt ?? "",
|
|
233
|
+
lastReplyAt ?? "",
|
|
234
|
+
ticketState?.dispatch_seq ?? "",
|
|
235
|
+
ticketState?.reply_seq ?? "",
|
|
236
|
+
ticketState?.status ?? "",
|
|
237
|
+
ticketState?.current_ticket ?? "",
|
|
238
|
+
ticketState?.total_turns ?? "",
|
|
239
|
+
ticketState?.ticket_turns ?? "",
|
|
240
|
+
].join("|");
|
|
241
|
+
}
|
|
242
|
+
const threadListRefresh = createSmartRefresh({
|
|
243
|
+
getSignature: (payload) => {
|
|
244
|
+
if (payload.status !== "ok")
|
|
245
|
+
return payload.status;
|
|
246
|
+
return `ok::${threadListSignature(payload.conversations)}`;
|
|
247
|
+
},
|
|
248
|
+
render: (payload) => {
|
|
249
|
+
if (!threadsEl)
|
|
250
|
+
return;
|
|
251
|
+
const renderList = () => {
|
|
252
|
+
if (payload.status !== "ok") {
|
|
253
|
+
threadsEl.innerHTML = "<div class=\"muted\">Repo offline or uninitialized</div>";
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
const conversations = payload.conversations || [];
|
|
257
|
+
if (!conversations.length) {
|
|
258
|
+
threadsEl.innerHTML = "<div class=\"muted\">No dispatches</div>";
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
threadsEl.innerHTML = conversations.map(renderThreadItem).join("");
|
|
262
|
+
threadsEl.querySelectorAll(".messages-thread").forEach((btn) => {
|
|
263
|
+
btn.addEventListener("click", () => {
|
|
264
|
+
const runId = btn.dataset.runId || "";
|
|
265
|
+
if (!runId)
|
|
266
|
+
return;
|
|
267
|
+
selectedRunId = runId;
|
|
268
|
+
syncSelectedThread();
|
|
269
|
+
updateUrlParams({ tab: "inbox", run_id: runId });
|
|
270
|
+
showThreadDetail();
|
|
271
|
+
void loadThread(runId, "manual");
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
};
|
|
275
|
+
preserveScroll(threadsEl, renderList, { restoreOnNextFrame: true });
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
const threadDetailRefresh = createSmartRefresh({
|
|
279
|
+
getSignature: (payload) => {
|
|
280
|
+
if (payload.status !== "ok")
|
|
281
|
+
return `${payload.status}::${payload.runId}`;
|
|
282
|
+
if (!payload.detail)
|
|
283
|
+
return `empty::${payload.runId}`;
|
|
284
|
+
return `ok::${payload.runId}::${threadDetailSignature(payload.detail)}`;
|
|
285
|
+
},
|
|
286
|
+
render: (payload, ctx) => {
|
|
287
|
+
if (!detailEl)
|
|
288
|
+
return;
|
|
289
|
+
if (payload.status !== "ok") {
|
|
290
|
+
detailEl.innerHTML = "<div class=\"muted\">Repo offline or uninitialized.</div>";
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
const detail = payload.detail;
|
|
294
|
+
if (!detail) {
|
|
295
|
+
detailEl.innerHTML = "<div class=\"muted\">No thread selected.</div>";
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
renderThreadDetail(detail, payload.runId, ctx);
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
async function fetchThreadsPayload() {
|
|
302
|
+
if (!isRepoHealthy()) {
|
|
303
|
+
return { status: "offline", conversations: [] };
|
|
304
|
+
}
|
|
305
|
+
const res = (await api("/api/messages/threads"));
|
|
306
|
+
return { status: "ok", conversations: res?.conversations || [] };
|
|
307
|
+
}
|
|
308
|
+
async function loadThreads(reason = "manual") {
|
|
309
|
+
if (!threadsEl)
|
|
310
|
+
return;
|
|
311
|
+
if (!MESSAGE_REFRESH_REASONS.includes(reason)) {
|
|
312
|
+
reason = "manual";
|
|
313
|
+
}
|
|
314
|
+
const showFullLoading = reason === "initial";
|
|
315
|
+
if (showFullLoading) {
|
|
316
|
+
threadsEl.innerHTML = "Loading…";
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
setThreadListRefreshing(true);
|
|
320
|
+
}
|
|
321
|
+
try {
|
|
322
|
+
await threadListRefresh.refresh(fetchThreadsPayload, { reason });
|
|
323
|
+
}
|
|
324
|
+
catch (_err) {
|
|
325
|
+
if (showFullLoading) {
|
|
326
|
+
threadsEl.innerHTML = "";
|
|
327
|
+
}
|
|
328
|
+
flash("Failed to load inbox", "error");
|
|
329
|
+
}
|
|
330
|
+
finally {
|
|
331
|
+
if (!showFullLoading) {
|
|
332
|
+
setThreadListRefreshing(false);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
function formatBytes(size) {
|
|
337
|
+
if (typeof size !== "number" || Number.isNaN(size))
|
|
338
|
+
return "";
|
|
339
|
+
if (size >= 1000000)
|
|
340
|
+
return `${(size / 1000000).toFixed(1)} MB`;
|
|
341
|
+
if (size >= 1000)
|
|
342
|
+
return `${(size / 1000).toFixed(0)} KB`;
|
|
343
|
+
return `${size} B`;
|
|
344
|
+
}
|
|
345
|
+
export function renderMarkdown(body) {
|
|
346
|
+
if (!body)
|
|
347
|
+
return "";
|
|
348
|
+
let text = escapeHtml(body);
|
|
349
|
+
// Extract fenced code blocks to avoid mutating their contents later.
|
|
350
|
+
const codeBlocks = [];
|
|
351
|
+
text = text.replace(/```([\s\S]*?)```/g, (_m, code) => {
|
|
352
|
+
const placeholder = `@@CODEBLOCK_${codeBlocks.length}@@`;
|
|
353
|
+
codeBlocks.push(`<pre class="md-code"><code>${code}</code></pre>`);
|
|
354
|
+
return placeholder;
|
|
355
|
+
});
|
|
356
|
+
// Extract inline code to avoid linking inside it
|
|
357
|
+
const inlineCode = [];
|
|
358
|
+
text = text.replace(/`([^`]+)`/g, (_m, code) => {
|
|
359
|
+
const placeholder = `@@INLINECODE_${inlineCode.length}@@`;
|
|
360
|
+
inlineCode.push(`<code>${code}</code>`);
|
|
361
|
+
return placeholder;
|
|
362
|
+
});
|
|
363
|
+
// Bold and italic (simple, non-nested)
|
|
364
|
+
text = text.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
|
365
|
+
text = text.replace(/\*([^*]+)\*/g, "<em>$1</em>");
|
|
366
|
+
// Extract markdown links [text](url) to avoid double-linking
|
|
367
|
+
const links = [];
|
|
368
|
+
text = text.replace(/\[([^\]]+)\]\((https?:[^)]+)\)/g, (_m, label, url) => {
|
|
369
|
+
const placeholder = `@@LINK_${links.length}@@`;
|
|
370
|
+
// Note: label and url are already escaped because text is escaped.
|
|
371
|
+
links.push(`<a href="${url}" target="_blank" rel="noopener">${label}</a>`);
|
|
372
|
+
return placeholder;
|
|
373
|
+
});
|
|
374
|
+
// Auto-link raw URLs
|
|
375
|
+
text = text.replace(/(https?:\/\/[^\s]+)/g, (url) => {
|
|
376
|
+
let cleanUrl = url;
|
|
377
|
+
let suffix = "";
|
|
378
|
+
const trailing = /[.,;!?)]$/;
|
|
379
|
+
while (trailing.test(cleanUrl)) {
|
|
380
|
+
suffix = cleanUrl.slice(-1) + suffix;
|
|
381
|
+
cleanUrl = cleanUrl.slice(0, -1);
|
|
382
|
+
}
|
|
383
|
+
return `<a href="${cleanUrl}" target="_blank" rel="noopener">${cleanUrl}</a>${suffix}`;
|
|
384
|
+
});
|
|
385
|
+
// Restore markdown links
|
|
386
|
+
text = text.replace(/@@LINK_(\d+)@@/g, (_m, id) => {
|
|
387
|
+
return links[Number(id)] ?? "";
|
|
388
|
+
});
|
|
389
|
+
// Restore inline code
|
|
390
|
+
text = text.replace(/@@INLINECODE_(\d+)@@/g, (_m, id) => {
|
|
391
|
+
return inlineCode[Number(id)] ?? "";
|
|
392
|
+
});
|
|
393
|
+
// Lists (skip placeholders so code fences remain untouched)
|
|
394
|
+
const lines = text.split(/\n/);
|
|
395
|
+
const out = [];
|
|
396
|
+
let inList = false;
|
|
397
|
+
lines.forEach((line) => {
|
|
398
|
+
if (/^@@CODEBLOCK_\d+@@$/.test(line)) {
|
|
399
|
+
if (inList) {
|
|
400
|
+
out.push("</ul>");
|
|
401
|
+
inList = false;
|
|
402
|
+
}
|
|
403
|
+
out.push(line);
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
if (/^[-*]\s+/.test(line)) {
|
|
407
|
+
if (!inList) {
|
|
408
|
+
out.push("", "<ul>");
|
|
409
|
+
inList = true;
|
|
410
|
+
}
|
|
411
|
+
out.push(`<li>${line.replace(/^[-*]\s+/, "")}</li>`);
|
|
412
|
+
}
|
|
413
|
+
else {
|
|
414
|
+
if (inList) {
|
|
415
|
+
out.push("</ul>", "");
|
|
416
|
+
inList = false;
|
|
417
|
+
}
|
|
418
|
+
out.push(line);
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
if (inList)
|
|
422
|
+
out.push("</ul>", "");
|
|
423
|
+
// Paragraphs and placeholder restoration
|
|
424
|
+
const joined = out.join("\n");
|
|
425
|
+
return joined
|
|
426
|
+
.split(/\n\n+/)
|
|
427
|
+
.map((block) => {
|
|
428
|
+
if (block.trim().startsWith("<ul>")) {
|
|
429
|
+
return block;
|
|
430
|
+
}
|
|
431
|
+
const match = block.match(/^@@CODEBLOCK_(\d+)@@$/);
|
|
432
|
+
if (match) {
|
|
433
|
+
const idx = Number(match[1]);
|
|
434
|
+
return codeBlocks[idx] ?? "";
|
|
435
|
+
}
|
|
436
|
+
return `<p>${block.replace(/\n/g, "<br>")}</p>`;
|
|
437
|
+
})
|
|
438
|
+
.join("");
|
|
439
|
+
}
|
|
440
|
+
function renderFiles(files) {
|
|
441
|
+
if (!files || !files.length)
|
|
442
|
+
return "";
|
|
443
|
+
const items = files
|
|
444
|
+
.map((f) => {
|
|
445
|
+
const size = formatBytes(f.size);
|
|
446
|
+
const href = resolvePath(f.url || "");
|
|
447
|
+
return `<li class="messages-file">
|
|
448
|
+
<span class="messages-file-icon">📎</span>
|
|
449
|
+
<a href="${escapeHtml(href)}" target="_blank" rel="noopener">${escapeHtml(f.name)}</a>
|
|
450
|
+
${size ? `<span class="messages-file-size muted small">${escapeHtml(size)}</span>` : ""}
|
|
451
|
+
</li>`;
|
|
452
|
+
})
|
|
453
|
+
.join("");
|
|
454
|
+
return `<ul class="messages-files">${items}</ul>`;
|
|
455
|
+
}
|
|
456
|
+
function renderDispatch(entry, isLatest, runStatus, isLastInTimeline = false) {
|
|
457
|
+
const dispatch = entry.dispatch;
|
|
458
|
+
const isHandoff = dispatch?.is_handoff || dispatch?.mode === "pause";
|
|
459
|
+
const isNotify = dispatch?.mode === "notify";
|
|
460
|
+
const isTurnSummary = dispatch?.mode === "turn_summary" || dispatch?.extra?.is_turn_summary;
|
|
461
|
+
const title = dispatch?.title || (isHandoff ? "Handoff" : "Agent update");
|
|
462
|
+
let modeClass = "pill-info";
|
|
463
|
+
let modeLabel = "INFO";
|
|
464
|
+
if (isHandoff) {
|
|
465
|
+
// Only show "ACTION REQUIRED" if this is the latest dispatch AND the run is actually paused.
|
|
466
|
+
// Otherwise, show "HANDOFF" to indicate a historical pause point.
|
|
467
|
+
if (isLatest && runStatus === "paused") {
|
|
468
|
+
modeClass = "pill-action";
|
|
469
|
+
modeLabel = "ACTION REQUIRED";
|
|
470
|
+
}
|
|
471
|
+
else {
|
|
472
|
+
modeClass = "pill-idle";
|
|
473
|
+
modeLabel = "HANDOFF";
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
// Determine dispatch type for color coding
|
|
477
|
+
let dispatchTypeClass = "";
|
|
478
|
+
if (isHandoff) {
|
|
479
|
+
dispatchTypeClass = "dispatch-pause";
|
|
480
|
+
}
|
|
481
|
+
else if (isNotify) {
|
|
482
|
+
dispatchTypeClass = "dispatch-notify";
|
|
483
|
+
}
|
|
484
|
+
else if (isTurnSummary) {
|
|
485
|
+
dispatchTypeClass = "dispatch-turn";
|
|
486
|
+
}
|
|
487
|
+
// Collapse all but the last dispatch in the timeline
|
|
488
|
+
const isCollapsed = !isLastInTimeline;
|
|
489
|
+
const modePill = dispatch?.mode ? ` <span class="pill pill-small ${modeClass}">${escapeHtml(modeLabel)}</span>` : "";
|
|
490
|
+
const body = dispatch?.body ? `<div class="messages-body messages-markdown">${renderMarkdown(dispatch.body)}</div>` : "";
|
|
491
|
+
const ts = entry.created_at ? formatTimestamp(entry.created_at) : "";
|
|
492
|
+
const collapseTitle = isCollapsed ? "Click to expand" : "Click to collapse";
|
|
493
|
+
return `
|
|
494
|
+
<div class="messages-entry${dispatchTypeClass ? " " + dispatchTypeClass : ""}${isCollapsed ? " collapsed" : ""}"
|
|
495
|
+
data-seq="${entry.seq}"
|
|
496
|
+
data-type="dispatch"
|
|
497
|
+
data-created="${escapeHtml(entry.created_at || "")}">
|
|
498
|
+
<div class="messages-collapse-bar"
|
|
499
|
+
role="button"
|
|
500
|
+
tabindex="0"
|
|
501
|
+
title="${collapseTitle}"
|
|
502
|
+
aria-label="${isCollapsed ? "Expand dispatch" : "Collapse dispatch"}"
|
|
503
|
+
aria-expanded="${String(!isCollapsed)}"></div>
|
|
504
|
+
<div class="messages-content-wrapper">
|
|
505
|
+
<div class="messages-entry-header">
|
|
506
|
+
<span class="messages-entry-seq">#${entry.seq.toString().padStart(4, "0")}</span>
|
|
507
|
+
<span class="messages-entry-title">${escapeHtml(title)}</span>
|
|
508
|
+
${modePill}
|
|
509
|
+
<span class="messages-entry-time">${escapeHtml(ts)}</span>
|
|
510
|
+
</div>
|
|
511
|
+
<div class="messages-entry-body">
|
|
512
|
+
${body}
|
|
513
|
+
${renderFiles(entry.files)}
|
|
514
|
+
</div>
|
|
515
|
+
</div>
|
|
516
|
+
</div>
|
|
517
|
+
`;
|
|
518
|
+
}
|
|
519
|
+
function renderReply(entry, parentSeq) {
|
|
520
|
+
const rep = entry.reply;
|
|
521
|
+
const title = rep?.title || "Your reply";
|
|
522
|
+
const body = rep?.body ? `<div class="messages-body messages-markdown">${renderMarkdown(rep.body)}</div>` : "";
|
|
523
|
+
const ts = entry.created_at ? formatTimestamp(entry.created_at) : "";
|
|
524
|
+
const replyIndicator = parentSeq !== undefined
|
|
525
|
+
? `<div class="messages-reply-indicator">In response to #${parentSeq.toString().padStart(4, "0")}</div>`
|
|
526
|
+
: "";
|
|
527
|
+
return `
|
|
528
|
+
<div class="messages-entry messages-entry-reply" data-seq="${entry.seq}" data-type="reply" data-created="${escapeHtml(entry.created_at || "")}">
|
|
529
|
+
<div class="messages-collapse-bar"
|
|
530
|
+
role="button"
|
|
531
|
+
tabindex="0"
|
|
532
|
+
title="Click to collapse"
|
|
533
|
+
aria-label="Collapse reply"
|
|
534
|
+
aria-expanded="true"></div>
|
|
535
|
+
<div class="messages-content-wrapper">
|
|
536
|
+
${replyIndicator}
|
|
537
|
+
<div class="messages-entry-header">
|
|
538
|
+
<span class="messages-entry-seq">#${entry.seq.toString().padStart(4, "0")}</span>
|
|
539
|
+
<span class="messages-entry-title">${escapeHtml(title)}</span>
|
|
540
|
+
<span class="pill pill-small pill-idle">you</span>
|
|
541
|
+
<span class="messages-entry-time">${escapeHtml(ts)}</span>
|
|
542
|
+
</div>
|
|
543
|
+
<div class="messages-entry-body">
|
|
544
|
+
${body}
|
|
545
|
+
${renderFiles(entry.files)}
|
|
546
|
+
</div>
|
|
547
|
+
</div>
|
|
548
|
+
</div>
|
|
549
|
+
`;
|
|
550
|
+
}
|
|
551
|
+
function buildThreadedTimeline(dispatches, replies, runStatus) {
|
|
552
|
+
// Combine all entries into a single timeline
|
|
553
|
+
const timeline = [];
|
|
554
|
+
// Find the latest dispatch sequence number to identify the most recent agent message
|
|
555
|
+
let maxDispatchSeq = -1;
|
|
556
|
+
dispatches.forEach((d) => {
|
|
557
|
+
if (d.seq > maxDispatchSeq)
|
|
558
|
+
maxDispatchSeq = d.seq;
|
|
559
|
+
timeline.push({
|
|
560
|
+
type: "dispatch",
|
|
561
|
+
seq: d.seq,
|
|
562
|
+
created_at: d.created_at || null,
|
|
563
|
+
dispatch: d,
|
|
564
|
+
});
|
|
565
|
+
});
|
|
566
|
+
replies.forEach((r) => {
|
|
567
|
+
timeline.push({
|
|
568
|
+
type: "reply",
|
|
569
|
+
seq: r.seq,
|
|
570
|
+
created_at: r.created_at || null,
|
|
571
|
+
reply: r,
|
|
572
|
+
});
|
|
573
|
+
});
|
|
574
|
+
// Sort chronologically by created_at, fallback to seq
|
|
575
|
+
timeline.sort((a, b) => {
|
|
576
|
+
if (a.created_at && b.created_at) {
|
|
577
|
+
return new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
|
|
578
|
+
}
|
|
579
|
+
return a.seq - b.seq;
|
|
580
|
+
});
|
|
581
|
+
// Count total dispatches in the sorted timeline
|
|
582
|
+
let dispatchCount = 0;
|
|
583
|
+
timeline.forEach((entry) => {
|
|
584
|
+
if (entry.type === "dispatch") {
|
|
585
|
+
dispatchCount++;
|
|
586
|
+
}
|
|
587
|
+
});
|
|
588
|
+
// Render timeline, associating replies with preceding dispatches
|
|
589
|
+
let lastDispatchSeq;
|
|
590
|
+
let currentDispatchIndex = 0;
|
|
591
|
+
const rendered = [];
|
|
592
|
+
timeline.forEach((entry) => {
|
|
593
|
+
if (entry.type === "dispatch" && entry.dispatch) {
|
|
594
|
+
lastDispatchSeq = entry.dispatch.seq;
|
|
595
|
+
const isLatest = entry.dispatch.seq === maxDispatchSeq;
|
|
596
|
+
const isLastInTimeline = currentDispatchIndex === dispatchCount - 1;
|
|
597
|
+
rendered.push(renderDispatch(entry.dispatch, isLatest, runStatus, isLastInTimeline));
|
|
598
|
+
currentDispatchIndex++;
|
|
599
|
+
}
|
|
600
|
+
else if (entry.type === "reply" && entry.reply) {
|
|
601
|
+
rendered.push(renderReply(entry.reply, lastDispatchSeq));
|
|
602
|
+
}
|
|
603
|
+
});
|
|
604
|
+
return rendered.join("");
|
|
605
|
+
}
|
|
606
|
+
async function loadThread(runId, reason = "manual") {
|
|
607
|
+
selectedRunId = runId;
|
|
608
|
+
syncSelectedThread();
|
|
609
|
+
if (!detailEl)
|
|
610
|
+
return;
|
|
611
|
+
if (!MESSAGE_REFRESH_REASONS.includes(reason)) {
|
|
612
|
+
reason = "manual";
|
|
613
|
+
}
|
|
614
|
+
const showFullLoading = reason === "initial";
|
|
615
|
+
if (showFullLoading) {
|
|
616
|
+
detailEl.innerHTML = "Loading…";
|
|
617
|
+
}
|
|
618
|
+
else {
|
|
619
|
+
setThreadDetailRefreshing(true);
|
|
620
|
+
}
|
|
621
|
+
try {
|
|
622
|
+
await threadDetailRefresh.refresh(async () => {
|
|
623
|
+
if (!isRepoHealthy()) {
|
|
624
|
+
return { status: "offline", runId };
|
|
625
|
+
}
|
|
626
|
+
const detail = (await api(`/api/messages/threads/${encodeURIComponent(runId)}`));
|
|
627
|
+
return { status: "ok", runId, detail };
|
|
628
|
+
}, { reason });
|
|
629
|
+
}
|
|
630
|
+
catch (_err) {
|
|
631
|
+
if (showFullLoading) {
|
|
632
|
+
detailEl.innerHTML = "";
|
|
633
|
+
}
|
|
634
|
+
flash("Failed to load message thread", "error");
|
|
635
|
+
}
|
|
636
|
+
finally {
|
|
637
|
+
if (!showFullLoading) {
|
|
638
|
+
setThreadDetailRefreshing(false);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
function isAtBottom(el) {
|
|
643
|
+
const threshold = 8;
|
|
644
|
+
return el.scrollTop + el.clientHeight >= el.scrollHeight - threshold;
|
|
645
|
+
}
|
|
646
|
+
function updateMobileDetailHeader(status, dispatchCount, replyCount) {
|
|
647
|
+
const statusEl = document.getElementById("messages-detail-status");
|
|
648
|
+
const countsEl = document.getElementById("messages-detail-counts");
|
|
649
|
+
if (statusEl) {
|
|
650
|
+
statusEl.className = `messages-detail-status pill pill-small ${getStatusPillClass(status)}`;
|
|
651
|
+
statusEl.textContent = status || "idle";
|
|
652
|
+
}
|
|
653
|
+
if (countsEl) {
|
|
654
|
+
countsEl.textContent = `${dispatchCount}D · ${replyCount}R`;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
function attachCollapseHandlers() {
|
|
658
|
+
if (!detailEl)
|
|
659
|
+
return;
|
|
660
|
+
// Helper to toggle collapse state
|
|
661
|
+
const toggleEntry = (entry, bar) => {
|
|
662
|
+
const isNowCollapsed = entry.classList.toggle("collapsed");
|
|
663
|
+
bar.title = isNowCollapsed ? "Click to expand" : "Click to collapse";
|
|
664
|
+
bar.setAttribute("aria-expanded", String(!isNowCollapsed));
|
|
665
|
+
bar.setAttribute("aria-label", isNowCollapsed ? "Expand dispatch" : "Collapse dispatch");
|
|
666
|
+
};
|
|
667
|
+
// Attach handlers to collapse bars
|
|
668
|
+
const collapseBars = detailEl.querySelectorAll(".messages-collapse-bar");
|
|
669
|
+
collapseBars.forEach((bar) => {
|
|
670
|
+
// Remove existing listeners by cloning
|
|
671
|
+
const newBar = bar.cloneNode(true);
|
|
672
|
+
bar.parentNode?.replaceChild(newBar, bar);
|
|
673
|
+
newBar.addEventListener("click", (e) => {
|
|
674
|
+
e.stopPropagation();
|
|
675
|
+
const entry = newBar.closest(".messages-entry");
|
|
676
|
+
if (entry) {
|
|
677
|
+
toggleEntry(entry, newBar);
|
|
678
|
+
}
|
|
679
|
+
});
|
|
680
|
+
// Keyboard support
|
|
681
|
+
newBar.addEventListener("keydown", (e) => {
|
|
682
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
683
|
+
e.preventDefault();
|
|
684
|
+
const entry = newBar.closest(".messages-entry");
|
|
685
|
+
if (entry) {
|
|
686
|
+
toggleEntry(entry, newBar);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
});
|
|
690
|
+
});
|
|
691
|
+
// Also make headers clickable for collapse
|
|
692
|
+
const headers = detailEl.querySelectorAll(".messages-entry-header");
|
|
693
|
+
headers.forEach((header) => {
|
|
694
|
+
// Remove existing listeners by cloning
|
|
695
|
+
const newHeader = header.cloneNode(true);
|
|
696
|
+
header.parentNode?.replaceChild(newHeader, header);
|
|
697
|
+
newHeader.addEventListener("click", (e) => {
|
|
698
|
+
// Don't toggle if clicking on a link
|
|
699
|
+
if (e.target.closest("a"))
|
|
700
|
+
return;
|
|
701
|
+
e.stopPropagation();
|
|
702
|
+
const entry = newHeader.closest(".messages-entry");
|
|
703
|
+
const bar = entry?.querySelector(".messages-collapse-bar");
|
|
704
|
+
if (entry && bar) {
|
|
705
|
+
toggleEntry(entry, bar);
|
|
706
|
+
}
|
|
707
|
+
});
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
function renderThreadDetail(detail, runId, ctx) {
|
|
711
|
+
if (!detailEl)
|
|
712
|
+
return;
|
|
713
|
+
const runStatus = (detail.run?.status || "").toString();
|
|
714
|
+
const isPaused = runStatus === "paused";
|
|
715
|
+
const dispatchHistory = detail.dispatch_history || [];
|
|
716
|
+
const replyHistory = detail.reply_history || [];
|
|
717
|
+
const dispatchCount = detail.dispatch_count ?? dispatchHistory.length;
|
|
718
|
+
const replyCount = detail.reply_count ?? replyHistory.length;
|
|
719
|
+
const ticketState = detail.ticket_state;
|
|
720
|
+
const turns = ticketState?.total_turns ?? null;
|
|
721
|
+
// Update mobile header metadata
|
|
722
|
+
updateMobileDetailHeader(runStatus, dispatchCount, replyCount);
|
|
723
|
+
// Truncate run ID for display
|
|
724
|
+
const shortRunId = runId.length > 12 ? runId.slice(0, 8) + "…" : runId;
|
|
725
|
+
// Build compact stats line
|
|
726
|
+
const statsParts = [];
|
|
727
|
+
statsParts.push(`${dispatchCount} dispatch${dispatchCount !== 1 ? "es" : ""}`);
|
|
728
|
+
statsParts.push(`${replyCount} repl${replyCount !== 1 ? "ies" : "y"}`);
|
|
729
|
+
if (turns != null)
|
|
730
|
+
statsParts.push(`${turns} turn${turns !== 1 ? "s" : ""}`);
|
|
731
|
+
const statsLine = statsParts.join(" · ");
|
|
732
|
+
// Status pill
|
|
733
|
+
const statusPillClass = isPaused ? "pill-action" : "pill-idle";
|
|
734
|
+
const statusLabel = isPaused ? "paused" : runStatus || "idle";
|
|
735
|
+
// Build threaded timeline
|
|
736
|
+
const threadedContent = buildThreadedTimeline(dispatchHistory, replyHistory, runStatus);
|
|
737
|
+
const renderDetail = () => {
|
|
738
|
+
detailEl.innerHTML = `
|
|
739
|
+
<div class="messages-thread-history">
|
|
740
|
+
${threadedContent || '<div class="muted">No dispatches yet</div>'}
|
|
741
|
+
</div>
|
|
742
|
+
<div class="messages-thread-footer">
|
|
743
|
+
<code title="${escapeHtml(runId)}">${escapeHtml(shortRunId)}</code>
|
|
744
|
+
<span class="pill pill-small ${statusPillClass}">${escapeHtml(statusLabel)}</span>
|
|
745
|
+
<span class="messages-footer-stats">${escapeHtml(statsLine)}</span>
|
|
746
|
+
</div>
|
|
747
|
+
`;
|
|
748
|
+
};
|
|
749
|
+
const preserve = ctx.reason === "background" && detailEl.scrollHeight > 0 && !isAtBottom(detailEl);
|
|
750
|
+
if (preserve) {
|
|
751
|
+
preserveScroll(detailEl, () => {
|
|
752
|
+
renderDetail();
|
|
753
|
+
attachCollapseHandlers();
|
|
754
|
+
}, { restoreOnNextFrame: true });
|
|
755
|
+
}
|
|
756
|
+
else {
|
|
757
|
+
renderDetail();
|
|
758
|
+
attachCollapseHandlers();
|
|
759
|
+
}
|
|
760
|
+
// Only show reply box for paused runs - replies to other states won't be seen
|
|
761
|
+
const replyBoxEl = document.querySelector(".messages-reply-box");
|
|
762
|
+
if (replyBoxEl) {
|
|
763
|
+
replyBoxEl.classList.toggle("hidden", !isPaused);
|
|
764
|
+
}
|
|
765
|
+
if (!preserve) {
|
|
766
|
+
requestAnimationFrame(() => {
|
|
767
|
+
if (detailEl) {
|
|
768
|
+
detailEl.scrollTop = detailEl.scrollHeight;
|
|
769
|
+
}
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
async function sendReply() {
|
|
774
|
+
const runId = selectedRunId;
|
|
775
|
+
if (!runId) {
|
|
776
|
+
flash("Select a message thread first", "error");
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
if (!isRepoHealthy()) {
|
|
780
|
+
flash("Repo offline; cannot send reply.", "error");
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
const body = replyBodyEl?.value || "";
|
|
784
|
+
const fd = new FormData();
|
|
785
|
+
fd.append("body", body);
|
|
786
|
+
if (replyFilesEl?.files) {
|
|
787
|
+
Array.from(replyFilesEl.files).forEach((f) => fd.append("files", f));
|
|
788
|
+
}
|
|
789
|
+
try {
|
|
790
|
+
await api(`/api/messages/${encodeURIComponent(runId)}/reply`, {
|
|
791
|
+
method: "POST",
|
|
792
|
+
body: fd,
|
|
793
|
+
});
|
|
794
|
+
if (replyBodyEl)
|
|
795
|
+
replyBodyEl.value = "";
|
|
796
|
+
if (replyFilesEl)
|
|
797
|
+
replyFilesEl.value = "";
|
|
798
|
+
flash("Reply sent", "success");
|
|
799
|
+
// Always resume after sending
|
|
800
|
+
await api(`/api/flows/${encodeURIComponent(runId)}/resume`, { method: "POST" });
|
|
801
|
+
flash("Run resumed", "success");
|
|
802
|
+
void refreshBell();
|
|
803
|
+
void loadThread(runId);
|
|
804
|
+
}
|
|
805
|
+
catch (_err) {
|
|
806
|
+
flash("Failed to send reply", "error");
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
export function initMessages() {
|
|
810
|
+
if (messagesInitialized)
|
|
811
|
+
return;
|
|
812
|
+
if (!threadsEl || !detailEl)
|
|
813
|
+
return;
|
|
814
|
+
messagesInitialized = true;
|
|
815
|
+
backBtn?.addEventListener("click", showThreadList);
|
|
816
|
+
window.addEventListener("resize", () => {
|
|
817
|
+
if (!isMobileViewport()) {
|
|
818
|
+
layoutEl?.classList.remove("viewing-detail");
|
|
819
|
+
}
|
|
820
|
+
});
|
|
821
|
+
refreshEl?.addEventListener("click", () => {
|
|
822
|
+
void loadThreads("manual");
|
|
823
|
+
const runId = selectedRunId;
|
|
824
|
+
if (runId)
|
|
825
|
+
void loadThread(runId, "manual");
|
|
826
|
+
});
|
|
827
|
+
replySendEl?.addEventListener("click", () => {
|
|
828
|
+
void sendReply();
|
|
829
|
+
});
|
|
830
|
+
// Load threads immediately, and try to open run_id from URL if present.
|
|
831
|
+
void loadThreads("initial").then(() => {
|
|
832
|
+
const params = getUrlParams();
|
|
833
|
+
const runId = params.get("run_id");
|
|
834
|
+
if (runId) {
|
|
835
|
+
selectedRunId = runId;
|
|
836
|
+
showThreadDetail();
|
|
837
|
+
void loadThread(runId, "initial");
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
// Fall back to active message if any.
|
|
841
|
+
if (activeRunId) {
|
|
842
|
+
selectedRunId = activeRunId;
|
|
843
|
+
updateUrlParams({ run_id: activeRunId });
|
|
844
|
+
showThreadDetail();
|
|
845
|
+
void loadThread(activeRunId, "initial");
|
|
846
|
+
}
|
|
847
|
+
});
|
|
848
|
+
subscribe("tab:change", (tabId) => {
|
|
849
|
+
if (tabId === "inbox") {
|
|
850
|
+
void refreshBell();
|
|
851
|
+
void loadThreads("manual");
|
|
852
|
+
const params = getUrlParams();
|
|
853
|
+
const runId = params.get("run_id");
|
|
854
|
+
if (runId) {
|
|
855
|
+
selectedRunId = runId;
|
|
856
|
+
showThreadDetail();
|
|
857
|
+
void loadThread(runId, "manual");
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
});
|
|
861
|
+
subscribe("state:update", () => {
|
|
862
|
+
void refreshBell();
|
|
863
|
+
});
|
|
864
|
+
subscribe("repo:health", (payload) => {
|
|
865
|
+
const status = payload?.status || "";
|
|
866
|
+
if (status === "ok" || status === "degraded" || status === "offline") {
|
|
867
|
+
void loadThreads("background");
|
|
868
|
+
if (selectedRunId) {
|
|
869
|
+
void loadThread(selectedRunId, "background");
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
});
|
|
873
|
+
}
|