codex-autorunner 1.0.0__py3-none-any.whl → 1.2.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/agents/codex/harness.py +1 -1
- codex_autorunner/agents/opencode/client.py +113 -4
- codex_autorunner/agents/opencode/constants.py +3 -0
- codex_autorunner/agents/opencode/harness.py +6 -1
- codex_autorunner/agents/opencode/runtime.py +59 -18
- codex_autorunner/agents/opencode/supervisor.py +4 -0
- codex_autorunner/agents/registry.py +36 -7
- codex_autorunner/bootstrap.py +226 -4
- codex_autorunner/cli.py +5 -1174
- codex_autorunner/codex_cli.py +20 -84
- codex_autorunner/core/__init__.py +20 -0
- codex_autorunner/core/about_car.py +119 -1
- codex_autorunner/core/app_server_ids.py +59 -0
- codex_autorunner/core/app_server_threads.py +17 -2
- codex_autorunner/core/app_server_utils.py +165 -0
- codex_autorunner/core/archive.py +349 -0
- codex_autorunner/core/codex_runner.py +6 -2
- codex_autorunner/core/config.py +433 -4
- codex_autorunner/core/context_awareness.py +38 -0
- codex_autorunner/core/docs.py +0 -122
- codex_autorunner/core/drafts.py +58 -4
- codex_autorunner/core/exceptions.py +4 -0
- codex_autorunner/core/filebox.py +265 -0
- codex_autorunner/core/flows/controller.py +96 -2
- codex_autorunner/core/flows/models.py +13 -0
- codex_autorunner/core/flows/reasons.py +52 -0
- codex_autorunner/core/flows/reconciler.py +134 -0
- codex_autorunner/core/flows/runtime.py +57 -4
- codex_autorunner/core/flows/store.py +142 -7
- codex_autorunner/core/flows/transition.py +27 -15
- codex_autorunner/core/flows/ux_helpers.py +272 -0
- codex_autorunner/core/flows/worker_process.py +32 -6
- codex_autorunner/core/git_utils.py +62 -0
- codex_autorunner/core/hub.py +291 -20
- codex_autorunner/core/lifecycle_events.py +253 -0
- codex_autorunner/core/notifications.py +14 -2
- codex_autorunner/core/path_utils.py +2 -1
- codex_autorunner/core/pma_audit.py +224 -0
- codex_autorunner/core/pma_context.py +496 -0
- codex_autorunner/core/pma_dispatch_interceptor.py +284 -0
- codex_autorunner/core/pma_lifecycle.py +527 -0
- codex_autorunner/core/pma_queue.py +367 -0
- codex_autorunner/core/pma_safety.py +221 -0
- codex_autorunner/core/pma_state.py +115 -0
- codex_autorunner/core/ports/__init__.py +28 -0
- codex_autorunner/{integrations/agents → core/ports}/agent_backend.py +13 -8
- codex_autorunner/core/ports/backend_orchestrator.py +41 -0
- codex_autorunner/{integrations/agents → core/ports}/run_event.py +23 -6
- codex_autorunner/core/prompt.py +0 -80
- codex_autorunner/core/prompts.py +56 -172
- codex_autorunner/core/redaction.py +0 -4
- codex_autorunner/core/review_context.py +11 -9
- codex_autorunner/core/runner_controller.py +35 -33
- codex_autorunner/core/runner_state.py +147 -0
- codex_autorunner/core/runtime.py +829 -0
- codex_autorunner/core/sqlite_utils.py +13 -4
- codex_autorunner/core/state.py +7 -10
- codex_autorunner/core/state_roots.py +62 -0
- codex_autorunner/core/supervisor_protocol.py +15 -0
- codex_autorunner/core/templates/__init__.py +39 -0
- codex_autorunner/core/templates/git_mirror.py +234 -0
- codex_autorunner/core/templates/provenance.py +56 -0
- codex_autorunner/core/templates/scan_cache.py +120 -0
- codex_autorunner/core/text_delta_coalescer.py +54 -0
- codex_autorunner/core/ticket_linter_cli.py +218 -0
- codex_autorunner/core/ticket_manager_cli.py +494 -0
- codex_autorunner/core/time_utils.py +11 -0
- codex_autorunner/core/types.py +18 -0
- codex_autorunner/core/update.py +4 -5
- codex_autorunner/core/update_paths.py +28 -0
- codex_autorunner/core/usage.py +164 -12
- codex_autorunner/core/utils.py +125 -15
- codex_autorunner/flows/review/__init__.py +17 -0
- codex_autorunner/{core/review.py → flows/review/service.py} +37 -34
- codex_autorunner/flows/ticket_flow/definition.py +52 -3
- codex_autorunner/integrations/agents/__init__.py +11 -19
- codex_autorunner/integrations/agents/backend_orchestrator.py +302 -0
- codex_autorunner/integrations/agents/codex_adapter.py +90 -0
- codex_autorunner/integrations/agents/codex_backend.py +177 -25
- codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
- codex_autorunner/integrations/agents/opencode_backend.py +305 -32
- codex_autorunner/integrations/agents/runner.py +86 -0
- codex_autorunner/integrations/agents/wiring.py +279 -0
- codex_autorunner/integrations/app_server/client.py +7 -60
- 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/telegram/adapter.py +65 -0
- codex_autorunner/integrations/telegram/config.py +46 -0
- codex_autorunner/integrations/telegram/constants.py +1 -1
- codex_autorunner/integrations/telegram/doctor.py +228 -6
- codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -0
- codex_autorunner/integrations/telegram/handlers/commands/execution.py +236 -74
- codex_autorunner/integrations/telegram/handlers/commands/files.py +314 -75
- codex_autorunner/integrations/telegram/handlers/commands/flows.py +1496 -71
- codex_autorunner/integrations/telegram/handlers/commands/workspace.py +498 -37
- codex_autorunner/integrations/telegram/handlers/commands_runtime.py +206 -48
- codex_autorunner/integrations/telegram/handlers/commands_spec.py +20 -3
- codex_autorunner/integrations/telegram/handlers/messages.py +27 -1
- codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
- codex_autorunner/integrations/telegram/helpers.py +22 -1
- codex_autorunner/integrations/telegram/runtime.py +9 -4
- codex_autorunner/integrations/telegram/service.py +45 -10
- codex_autorunner/integrations/telegram/state.py +38 -0
- codex_autorunner/integrations/telegram/ticket_flow_bridge.py +338 -43
- codex_autorunner/integrations/telegram/transport.py +13 -4
- codex_autorunner/integrations/templates/__init__.py +27 -0
- codex_autorunner/integrations/templates/scan_agent.py +312 -0
- codex_autorunner/routes/__init__.py +37 -76
- codex_autorunner/routes/agents.py +2 -137
- codex_autorunner/routes/analytics.py +2 -238
- codex_autorunner/routes/app_server.py +2 -131
- codex_autorunner/routes/base.py +2 -596
- codex_autorunner/routes/file_chat.py +4 -833
- codex_autorunner/routes/flows.py +4 -977
- codex_autorunner/routes/messages.py +4 -456
- 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 -193
- codex_autorunner/routes/usage.py +2 -86
- codex_autorunner/routes/voice.py +2 -119
- codex_autorunner/routes/workspace.py +2 -270
- codex_autorunner/server.py +4 -4
- codex_autorunner/static/agentControls.js +61 -16
- codex_autorunner/static/app.js +126 -14
- codex_autorunner/static/archive.js +826 -0
- codex_autorunner/static/archiveApi.js +37 -0
- codex_autorunner/static/autoRefresh.js +7 -7
- codex_autorunner/static/chatUploads.js +137 -0
- codex_autorunner/static/dashboard.js +224 -171
- codex_autorunner/static/docChatCore.js +185 -13
- codex_autorunner/static/fileChat.js +68 -40
- codex_autorunner/static/fileboxUi.js +159 -0
- codex_autorunner/static/hub.js +114 -131
- codex_autorunner/static/index.html +375 -49
- codex_autorunner/static/messages.js +568 -87
- codex_autorunner/static/notifications.js +255 -0
- codex_autorunner/static/pma.js +1167 -0
- codex_autorunner/static/preserve.js +17 -0
- codex_autorunner/static/settings.js +128 -6
- codex_autorunner/static/smartRefresh.js +52 -0
- codex_autorunner/static/streamUtils.js +57 -0
- codex_autorunner/static/styles.css +9798 -6143
- codex_autorunner/static/tabs.js +152 -11
- codex_autorunner/static/templateReposSettings.js +225 -0
- codex_autorunner/static/terminal.js +18 -0
- codex_autorunner/static/ticketChatActions.js +165 -3
- codex_autorunner/static/ticketChatStream.js +17 -119
- codex_autorunner/static/ticketEditor.js +137 -15
- codex_autorunner/static/ticketTemplates.js +798 -0
- codex_autorunner/static/tickets.js +821 -98
- codex_autorunner/static/turnEvents.js +27 -0
- codex_autorunner/static/turnResume.js +33 -0
- codex_autorunner/static/utils.js +39 -0
- codex_autorunner/static/workspace.js +389 -82
- codex_autorunner/static/workspaceFileBrowser.js +15 -13
- codex_autorunner/surfaces/__init__.py +5 -0
- codex_autorunner/surfaces/cli/__init__.py +6 -0
- codex_autorunner/surfaces/cli/cli.py +2534 -0
- codex_autorunner/surfaces/cli/codex_cli.py +20 -0
- codex_autorunner/surfaces/cli/pma_cli.py +817 -0
- codex_autorunner/surfaces/telegram/__init__.py +3 -0
- codex_autorunner/surfaces/web/__init__.py +1 -0
- codex_autorunner/surfaces/web/app.py +2223 -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 +82 -0
- codex_autorunner/surfaces/web/routes/agents.py +138 -0
- codex_autorunner/surfaces/web/routes/analytics.py +284 -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 +1117 -0
- codex_autorunner/surfaces/web/routes/filebox.py +227 -0
- codex_autorunner/surfaces/web/routes/flows.py +1354 -0
- codex_autorunner/surfaces/web/routes/messages.py +490 -0
- codex_autorunner/surfaces/web/routes/pma.py +1652 -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 +277 -0
- codex_autorunner/surfaces/web/routes/system.py +196 -0
- codex_autorunner/surfaces/web/routes/templates.py +634 -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 +469 -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 +8 -1
- codex_autorunner/tickets/agent_pool.py +53 -4
- codex_autorunner/tickets/files.py +37 -16
- codex_autorunner/tickets/lint.py +50 -0
- codex_autorunner/tickets/models.py +6 -1
- codex_autorunner/tickets/outbox.py +50 -2
- codex_autorunner/tickets/runner.py +396 -57
- codex_autorunner/web/__init__.py +5 -1
- codex_autorunner/web/app.py +2 -1949
- codex_autorunner/web/hub_jobs.py +2 -191
- codex_autorunner/web/middleware.py +2 -586
- codex_autorunner/web/pty_session.py +2 -369
- codex_autorunner/web/runner_manager.py +2 -24
- codex_autorunner/web/schemas.py +2 -376
- codex_autorunner/web/static_assets.py +4 -441
- codex_autorunner/web/static_refresh.py +2 -85
- codex_autorunner/web/terminal_sessions.py +2 -77
- codex_autorunner/workspace/paths.py +49 -33
- codex_autorunner-1.2.0.dist-info/METADATA +150 -0
- codex_autorunner-1.2.0.dist-info/RECORD +339 -0
- codex_autorunner/core/adapter_utils.py +0 -21
- codex_autorunner/core/engine.py +0 -2653
- codex_autorunner/core/static_assets.py +0 -55
- codex_autorunner-1.0.0.dist-info/METADATA +0 -246
- codex_autorunner-1.0.0.dist-info/RECORD +0 -251
- /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
- {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/WHEEL +0 -0
- {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/entry_points.txt +0 -0
- {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/licenses/LICENSE +0 -0
- {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/top_level.txt +0 -0
|
@@ -2,16 +2,70 @@
|
|
|
2
2
|
import { api, escapeHtml, flash, getUrlParams, resolvePath, updateUrlParams, } from "./utils.js";
|
|
3
3
|
import { subscribe } from "./bus.js";
|
|
4
4
|
import { isRepoHealthy } from "./health.js";
|
|
5
|
+
import { preserveScroll } from "./preserve.js";
|
|
6
|
+
import { createSmartRefresh } from "./smartRefresh.js";
|
|
7
|
+
import { createFileBoxWidget } from "./fileboxUi.js";
|
|
5
8
|
let bellInitialized = false;
|
|
6
9
|
let messagesInitialized = false;
|
|
7
10
|
let activeRunId = null;
|
|
8
11
|
let selectedRunId = null;
|
|
12
|
+
const MESSAGE_REFRESH_REASONS = ["initial", "background", "manual"];
|
|
9
13
|
const threadsEl = document.getElementById("messages-thread-list");
|
|
10
14
|
const detailEl = document.getElementById("messages-thread-detail");
|
|
15
|
+
const layoutEl = document.querySelector(".messages-layout");
|
|
16
|
+
const backBtn = document.getElementById("messages-back-btn");
|
|
11
17
|
const refreshEl = document.getElementById("messages-refresh");
|
|
12
18
|
const replyBodyEl = document.getElementById("messages-reply-body");
|
|
13
19
|
const replyFilesEl = document.getElementById("messages-reply-files");
|
|
14
20
|
const replySendEl = document.getElementById("messages-reply-send");
|
|
21
|
+
const replyAttachBtn = document.getElementById("messages-reply-attach");
|
|
22
|
+
const replyAttachSummary = document.getElementById("messages-reply-attach-summary");
|
|
23
|
+
const fileBoxInboxEl = document.getElementById("messages-filebox-inbox");
|
|
24
|
+
const fileBoxOutboxEl = document.getElementById("messages-filebox-outbox");
|
|
25
|
+
const fileBoxUploadEl = document.getElementById("messages-filebox-upload");
|
|
26
|
+
const fileBoxUploadBtn = document.getElementById("messages-filebox-upload-btn");
|
|
27
|
+
const fileBoxRefreshBtn = document.getElementById("messages-filebox-refresh");
|
|
28
|
+
let threadListRefreshCount = 0;
|
|
29
|
+
let threadDetailRefreshCount = 0;
|
|
30
|
+
let fileBoxCtrl = null;
|
|
31
|
+
function isMobileViewport() {
|
|
32
|
+
return window.innerWidth <= 640;
|
|
33
|
+
}
|
|
34
|
+
function showThreadList() {
|
|
35
|
+
layoutEl?.classList.remove("viewing-detail");
|
|
36
|
+
}
|
|
37
|
+
function showThreadDetail() {
|
|
38
|
+
if (isMobileViewport()) {
|
|
39
|
+
layoutEl?.classList.add("viewing-detail");
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function initFileBox() {
|
|
43
|
+
if (fileBoxCtrl || (!fileBoxInboxEl && !fileBoxOutboxEl))
|
|
44
|
+
return;
|
|
45
|
+
fileBoxCtrl = createFileBoxWidget({
|
|
46
|
+
scope: "repo",
|
|
47
|
+
inboxEl: fileBoxInboxEl,
|
|
48
|
+
outboxEl: fileBoxOutboxEl,
|
|
49
|
+
uploadInput: fileBoxUploadEl,
|
|
50
|
+
uploadBtn: fileBoxUploadBtn,
|
|
51
|
+
refreshBtn: fileBoxRefreshBtn,
|
|
52
|
+
uploadBox: "inbox",
|
|
53
|
+
emptyMessage: "No files",
|
|
54
|
+
});
|
|
55
|
+
void fileBoxCtrl.refresh();
|
|
56
|
+
}
|
|
57
|
+
function setThreadListRefreshing(active) {
|
|
58
|
+
if (!threadsEl)
|
|
59
|
+
return;
|
|
60
|
+
threadListRefreshCount = Math.max(0, threadListRefreshCount + (active ? 1 : -1));
|
|
61
|
+
threadsEl.classList.toggle("refreshing", threadListRefreshCount > 0);
|
|
62
|
+
}
|
|
63
|
+
function setThreadDetailRefreshing(active) {
|
|
64
|
+
if (!detailEl)
|
|
65
|
+
return;
|
|
66
|
+
threadDetailRefreshCount = Math.max(0, threadDetailRefreshCount + (active ? 1 : -1));
|
|
67
|
+
detailEl.classList.toggle("refreshing", threadDetailRefreshCount > 0);
|
|
68
|
+
}
|
|
15
69
|
function formatTimestamp(ts) {
|
|
16
70
|
if (!ts)
|
|
17
71
|
return "–";
|
|
@@ -76,12 +130,51 @@ export function initMessageBell() {
|
|
|
76
130
|
}
|
|
77
131
|
});
|
|
78
132
|
}
|
|
133
|
+
function formatRelativeTime(ts) {
|
|
134
|
+
if (!ts)
|
|
135
|
+
return "";
|
|
136
|
+
const date = new Date(ts);
|
|
137
|
+
if (Number.isNaN(date.getTime()))
|
|
138
|
+
return "";
|
|
139
|
+
const now = new Date();
|
|
140
|
+
const diffMs = now.getTime() - date.getTime();
|
|
141
|
+
const diffSecs = Math.floor(diffMs / 1000);
|
|
142
|
+
if (diffSecs < 60)
|
|
143
|
+
return "just now";
|
|
144
|
+
const diffMins = Math.floor(diffSecs / 60);
|
|
145
|
+
if (diffMins < 60)
|
|
146
|
+
return `${diffMins}m ago`;
|
|
147
|
+
const diffHours = Math.floor(diffMins / 60);
|
|
148
|
+
if (diffHours < 24)
|
|
149
|
+
return `${diffHours}h ago`;
|
|
150
|
+
const diffDays = Math.floor(diffHours / 24);
|
|
151
|
+
if (diffDays < 7)
|
|
152
|
+
return `${diffDays}d ago`;
|
|
153
|
+
return date.toLocaleDateString([], { month: "short", day: "numeric" });
|
|
154
|
+
}
|
|
155
|
+
function getStatusPillClass(status) {
|
|
156
|
+
switch (status) {
|
|
157
|
+
case "paused":
|
|
158
|
+
return "pill-action";
|
|
159
|
+
case "running":
|
|
160
|
+
case "pending":
|
|
161
|
+
return "pill-success";
|
|
162
|
+
case "completed":
|
|
163
|
+
return "pill-idle";
|
|
164
|
+
case "failed":
|
|
165
|
+
case "stopped":
|
|
166
|
+
return "pill-error";
|
|
167
|
+
default:
|
|
168
|
+
return "pill-idle";
|
|
169
|
+
}
|
|
170
|
+
}
|
|
79
171
|
function renderThreadItem(thread) {
|
|
80
172
|
const latestDispatch = thread.latest?.dispatch;
|
|
81
173
|
const isHandoff = latestDispatch?.is_handoff || latestDispatch?.mode === "pause";
|
|
82
174
|
const title = latestDispatch?.title || (isHandoff ? "Handoff" : "Dispatch");
|
|
83
175
|
const subtitle = latestDispatch?.body ? latestDispatch.body.slice(0, 120) : "";
|
|
84
176
|
const isPaused = thread.status === "paused";
|
|
177
|
+
const isActive = selectedRunId && thread.run_id === selectedRunId;
|
|
85
178
|
// Only show action indicator if there's an unreplied handoff (pause)
|
|
86
179
|
// Compare dispatch_seq vs reply_seq to check if user has responded
|
|
87
180
|
const ticketState = thread.ticket_state;
|
|
@@ -91,47 +184,178 @@ function renderThreadItem(thread) {
|
|
|
91
184
|
const indicator = hasUnrepliedHandoff ? `<span class="messages-thread-indicator" title="Action required"></span>` : "";
|
|
92
185
|
const dispatches = thread.dispatch_count ?? 0;
|
|
93
186
|
const replies = thread.reply_count ?? 0;
|
|
94
|
-
|
|
187
|
+
// Format timestamp for last dispatch
|
|
188
|
+
const lastTs = thread.latest?.created_at;
|
|
189
|
+
const timeAgo = formatRelativeTime(lastTs);
|
|
190
|
+
// Status badge
|
|
191
|
+
const status = thread.status || "idle";
|
|
192
|
+
const statusClass = getStatusPillClass(status);
|
|
193
|
+
const statusLabel = status === "paused" && hasUnrepliedHandoff ? "action" : status;
|
|
194
|
+
// Build meta line with timestamp
|
|
195
|
+
const countPart = `${dispatches} dispatch${dispatches !== 1 ? "es" : ""} · ${replies} repl${replies !== 1 ? "ies" : "y"}`;
|
|
95
196
|
return `
|
|
96
|
-
<button class="messages-thread" data-run-id="${escapeHtml(thread.run_id)}">
|
|
97
|
-
<div class="messages-thread-
|
|
197
|
+
<button class="messages-thread${isActive ? " active" : ""}" data-run-id="${escapeHtml(thread.run_id)}">
|
|
198
|
+
<div class="messages-thread-header">
|
|
199
|
+
<div class="messages-thread-title">${indicator}${escapeHtml(title)}</div>
|
|
200
|
+
<span class="pill pill-small ${statusClass}">${escapeHtml(statusLabel)}</span>
|
|
201
|
+
</div>
|
|
98
202
|
<div class="messages-thread-subtitle muted">${escapeHtml(subtitle)}</div>
|
|
99
|
-
<div class="messages-thread-meta-line"
|
|
203
|
+
<div class="messages-thread-meta-line">
|
|
204
|
+
<span class="messages-thread-counts">${escapeHtml(countPart)}</span>
|
|
205
|
+
${timeAgo ? `<span class="messages-thread-time">${escapeHtml(timeAgo)}</span>` : ""}
|
|
206
|
+
</div>
|
|
100
207
|
</button>
|
|
101
208
|
`;
|
|
102
209
|
}
|
|
103
|
-
|
|
210
|
+
function syncSelectedThread() {
|
|
104
211
|
if (!threadsEl)
|
|
105
212
|
return;
|
|
106
|
-
|
|
213
|
+
const buttons = threadsEl.querySelectorAll(".messages-thread");
|
|
214
|
+
buttons.forEach((btn) => {
|
|
215
|
+
const runId = btn.dataset.runId || "";
|
|
216
|
+
btn.classList.toggle("active", Boolean(runId) && runId === selectedRunId);
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
function threadListSignature(conversations) {
|
|
220
|
+
return conversations
|
|
221
|
+
.map((thread) => {
|
|
222
|
+
const latest = thread.latest;
|
|
223
|
+
const dispatch = latest?.dispatch;
|
|
224
|
+
const ticketState = thread.ticket_state;
|
|
225
|
+
return [
|
|
226
|
+
thread.run_id,
|
|
227
|
+
thread.status ?? "",
|
|
228
|
+
latest?.seq ?? "",
|
|
229
|
+
latest?.created_at ?? "",
|
|
230
|
+
dispatch?.mode ?? "",
|
|
231
|
+
dispatch?.is_handoff ? "1" : "0",
|
|
232
|
+
thread.dispatch_count ?? "",
|
|
233
|
+
thread.reply_count ?? "",
|
|
234
|
+
ticketState?.dispatch_seq ?? "",
|
|
235
|
+
ticketState?.reply_seq ?? "",
|
|
236
|
+
ticketState?.status ?? "",
|
|
237
|
+
].join("|");
|
|
238
|
+
})
|
|
239
|
+
.join("::");
|
|
240
|
+
}
|
|
241
|
+
function threadDetailSignature(detail) {
|
|
242
|
+
const dispatches = detail.dispatch_history || [];
|
|
243
|
+
const replies = detail.reply_history || [];
|
|
244
|
+
const maxDispatchSeq = dispatches.reduce((max, entry) => Math.max(max, entry.seq || 0), 0);
|
|
245
|
+
const maxReplySeq = replies.reduce((max, entry) => Math.max(max, entry.seq || 0), 0);
|
|
246
|
+
const lastDispatchAt = dispatches.find((entry) => entry.seq === maxDispatchSeq)?.created_at ?? "";
|
|
247
|
+
const lastReplyAt = replies.find((entry) => entry.seq === maxReplySeq)?.created_at ?? "";
|
|
248
|
+
const ticketState = detail.ticket_state;
|
|
249
|
+
return [
|
|
250
|
+
detail.run?.status ?? "",
|
|
251
|
+
detail.run?.created_at ?? "",
|
|
252
|
+
detail.dispatch_count ?? dispatches.length,
|
|
253
|
+
detail.reply_count ?? replies.length,
|
|
254
|
+
maxDispatchSeq,
|
|
255
|
+
maxReplySeq,
|
|
256
|
+
lastDispatchAt ?? "",
|
|
257
|
+
lastReplyAt ?? "",
|
|
258
|
+
ticketState?.dispatch_seq ?? "",
|
|
259
|
+
ticketState?.reply_seq ?? "",
|
|
260
|
+
ticketState?.status ?? "",
|
|
261
|
+
ticketState?.current_ticket ?? "",
|
|
262
|
+
ticketState?.total_turns ?? "",
|
|
263
|
+
ticketState?.ticket_turns ?? "",
|
|
264
|
+
].join("|");
|
|
265
|
+
}
|
|
266
|
+
const threadListRefresh = createSmartRefresh({
|
|
267
|
+
getSignature: (payload) => {
|
|
268
|
+
if (payload.status !== "ok")
|
|
269
|
+
return payload.status;
|
|
270
|
+
return `ok::${threadListSignature(payload.conversations)}`;
|
|
271
|
+
},
|
|
272
|
+
render: (payload) => {
|
|
273
|
+
if (!threadsEl)
|
|
274
|
+
return;
|
|
275
|
+
const renderList = () => {
|
|
276
|
+
if (payload.status !== "ok") {
|
|
277
|
+
threadsEl.innerHTML = "<div class=\"muted\">Repo offline or uninitialized</div>";
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const conversations = payload.conversations || [];
|
|
281
|
+
if (!conversations.length) {
|
|
282
|
+
threadsEl.innerHTML = "<div class=\"muted\">No dispatches</div>";
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
threadsEl.innerHTML = conversations.map(renderThreadItem).join("");
|
|
286
|
+
threadsEl.querySelectorAll(".messages-thread").forEach((btn) => {
|
|
287
|
+
btn.addEventListener("click", () => {
|
|
288
|
+
const runId = btn.dataset.runId || "";
|
|
289
|
+
if (!runId)
|
|
290
|
+
return;
|
|
291
|
+
selectedRunId = runId;
|
|
292
|
+
syncSelectedThread();
|
|
293
|
+
updateUrlParams({ tab: "inbox", run_id: runId });
|
|
294
|
+
showThreadDetail();
|
|
295
|
+
void loadThread(runId, "manual");
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
};
|
|
299
|
+
preserveScroll(threadsEl, renderList, { restoreOnNextFrame: true });
|
|
300
|
+
},
|
|
301
|
+
});
|
|
302
|
+
const threadDetailRefresh = createSmartRefresh({
|
|
303
|
+
getSignature: (payload) => {
|
|
304
|
+
if (payload.status !== "ok")
|
|
305
|
+
return `${payload.status}::${payload.runId}`;
|
|
306
|
+
if (!payload.detail)
|
|
307
|
+
return `empty::${payload.runId}`;
|
|
308
|
+
return `ok::${payload.runId}::${threadDetailSignature(payload.detail)}`;
|
|
309
|
+
},
|
|
310
|
+
render: (payload, ctx) => {
|
|
311
|
+
if (!detailEl)
|
|
312
|
+
return;
|
|
313
|
+
if (payload.status !== "ok") {
|
|
314
|
+
detailEl.innerHTML = "<div class=\"muted\">Repo offline or uninitialized.</div>";
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
const detail = payload.detail;
|
|
318
|
+
if (!detail) {
|
|
319
|
+
detailEl.innerHTML = "<div class=\"muted\">No thread selected.</div>";
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
renderThreadDetail(detail, payload.runId, ctx);
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
async function fetchThreadsPayload() {
|
|
107
326
|
if (!isRepoHealthy()) {
|
|
108
|
-
|
|
327
|
+
return { status: "offline", conversations: [] };
|
|
328
|
+
}
|
|
329
|
+
const res = (await api("/api/messages/threads"));
|
|
330
|
+
return { status: "ok", conversations: res?.conversations || [] };
|
|
331
|
+
}
|
|
332
|
+
async function loadThreads(reason = "manual") {
|
|
333
|
+
if (!threadsEl)
|
|
109
334
|
return;
|
|
335
|
+
if (!MESSAGE_REFRESH_REASONS.includes(reason)) {
|
|
336
|
+
reason = "manual";
|
|
337
|
+
}
|
|
338
|
+
const showFullLoading = reason === "initial";
|
|
339
|
+
if (showFullLoading) {
|
|
340
|
+
threadsEl.innerHTML = "Loading…";
|
|
341
|
+
}
|
|
342
|
+
else {
|
|
343
|
+
setThreadListRefreshing(true);
|
|
110
344
|
}
|
|
111
|
-
let res;
|
|
112
345
|
try {
|
|
113
|
-
|
|
346
|
+
await threadListRefresh.refresh(fetchThreadsPayload, { reason });
|
|
114
347
|
}
|
|
115
|
-
catch (
|
|
116
|
-
|
|
348
|
+
catch (_err) {
|
|
349
|
+
if (showFullLoading) {
|
|
350
|
+
threadsEl.innerHTML = "";
|
|
351
|
+
}
|
|
117
352
|
flash("Failed to load inbox", "error");
|
|
118
|
-
return;
|
|
119
353
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
354
|
+
finally {
|
|
355
|
+
if (!showFullLoading) {
|
|
356
|
+
setThreadListRefreshing(false);
|
|
357
|
+
}
|
|
124
358
|
}
|
|
125
|
-
threadsEl.innerHTML = conversations.map(renderThreadItem).join("");
|
|
126
|
-
threadsEl.querySelectorAll(".messages-thread").forEach((btn) => {
|
|
127
|
-
btn.addEventListener("click", () => {
|
|
128
|
-
const runId = btn.dataset.runId || "";
|
|
129
|
-
if (!runId)
|
|
130
|
-
return;
|
|
131
|
-
updateUrlParams({ tab: "inbox", run_id: runId });
|
|
132
|
-
void loadThread(runId);
|
|
133
|
-
});
|
|
134
|
-
});
|
|
135
359
|
}
|
|
136
360
|
function formatBytes(size) {
|
|
137
361
|
if (typeof size !== "number" || Number.isNaN(size))
|
|
@@ -142,6 +366,27 @@ function formatBytes(size) {
|
|
|
142
366
|
return `${(size / 1000).toFixed(0)} KB`;
|
|
143
367
|
return `${size} B`;
|
|
144
368
|
}
|
|
369
|
+
function isSafeHref(url) {
|
|
370
|
+
const trimmed = (url || "").trim();
|
|
371
|
+
if (!trimmed)
|
|
372
|
+
return false;
|
|
373
|
+
const lower = trimmed.toLowerCase();
|
|
374
|
+
if (lower.startsWith("javascript:"))
|
|
375
|
+
return false;
|
|
376
|
+
if (lower.startsWith("data:"))
|
|
377
|
+
return false;
|
|
378
|
+
if (lower.startsWith("vbscript:"))
|
|
379
|
+
return false;
|
|
380
|
+
if (lower.startsWith("file:"))
|
|
381
|
+
return false;
|
|
382
|
+
return (lower.startsWith("http://") ||
|
|
383
|
+
lower.startsWith("https://") ||
|
|
384
|
+
trimmed.startsWith("/") ||
|
|
385
|
+
trimmed.startsWith("./") ||
|
|
386
|
+
trimmed.startsWith("../") ||
|
|
387
|
+
trimmed.startsWith("#") ||
|
|
388
|
+
lower.startsWith("mailto:"));
|
|
389
|
+
}
|
|
145
390
|
export function renderMarkdown(body) {
|
|
146
391
|
if (!body)
|
|
147
392
|
return "";
|
|
@@ -153,14 +398,46 @@ export function renderMarkdown(body) {
|
|
|
153
398
|
codeBlocks.push(`<pre class="md-code"><code>${code}</code></pre>`);
|
|
154
399
|
return placeholder;
|
|
155
400
|
});
|
|
156
|
-
//
|
|
157
|
-
|
|
401
|
+
// Extract inline code to avoid linking inside it
|
|
402
|
+
const inlineCode = [];
|
|
403
|
+
text = text.replace(/`([^`]+)`/g, (_m, code) => {
|
|
404
|
+
const placeholder = `@@INLINECODE_${inlineCode.length}@@`;
|
|
405
|
+
inlineCode.push(`<code>${code}</code>`);
|
|
406
|
+
return placeholder;
|
|
407
|
+
});
|
|
158
408
|
// Bold and italic (simple, non-nested)
|
|
159
409
|
text = text.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
|
160
410
|
text = text.replace(/\*([^*]+)\*/g, "<em>$1</em>");
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
|
|
411
|
+
// Extract markdown links [text](url) to avoid double-linking
|
|
412
|
+
const links = [];
|
|
413
|
+
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, label, rawUrl) => {
|
|
414
|
+
const url = (rawUrl || "").trim();
|
|
415
|
+
if (!isSafeHref(url)) {
|
|
416
|
+
return match;
|
|
417
|
+
}
|
|
418
|
+
const placeholder = `@@LINK_${links.length}@@`;
|
|
419
|
+
// Note: label and url are already escaped because text is escaped.
|
|
420
|
+
links.push(`<a href="${url}" target="_blank" rel="noopener">${label}</a>`);
|
|
421
|
+
return placeholder;
|
|
422
|
+
});
|
|
423
|
+
// Auto-link raw URLs
|
|
424
|
+
text = text.replace(/(https?:\/\/[^\s]+)/g, (url) => {
|
|
425
|
+
let cleanUrl = url;
|
|
426
|
+
let suffix = "";
|
|
427
|
+
const trailing = /[.,;!?)]$/;
|
|
428
|
+
while (trailing.test(cleanUrl)) {
|
|
429
|
+
suffix = cleanUrl.slice(-1) + suffix;
|
|
430
|
+
cleanUrl = cleanUrl.slice(0, -1);
|
|
431
|
+
}
|
|
432
|
+
return `<a href="${cleanUrl}" target="_blank" rel="noopener">${cleanUrl}</a>${suffix}`;
|
|
433
|
+
});
|
|
434
|
+
// Restore markdown links
|
|
435
|
+
text = text.replace(/@@LINK_(\d+)@@/g, (_m, id) => {
|
|
436
|
+
return links[Number(id)] ?? "";
|
|
437
|
+
});
|
|
438
|
+
// Restore inline code
|
|
439
|
+
text = text.replace(/@@INLINECODE_(\d+)@@/g, (_m, id) => {
|
|
440
|
+
return inlineCode[Number(id)] ?? "";
|
|
164
441
|
});
|
|
165
442
|
// Lists (skip placeholders so code fences remain untouched)
|
|
166
443
|
const lines = text.split(/\n/);
|
|
@@ -177,32 +454,36 @@ export function renderMarkdown(body) {
|
|
|
177
454
|
}
|
|
178
455
|
if (/^[-*]\s+/.test(line)) {
|
|
179
456
|
if (!inList) {
|
|
180
|
-
out.push("<ul>");
|
|
457
|
+
out.push("", "<ul>");
|
|
181
458
|
inList = true;
|
|
182
459
|
}
|
|
183
460
|
out.push(`<li>${line.replace(/^[-*]\s+/, "")}</li>`);
|
|
184
461
|
}
|
|
185
462
|
else {
|
|
186
463
|
if (inList) {
|
|
187
|
-
out.push("</ul>");
|
|
464
|
+
out.push("</ul>", "");
|
|
188
465
|
inList = false;
|
|
189
466
|
}
|
|
190
467
|
out.push(line);
|
|
191
468
|
}
|
|
192
469
|
});
|
|
193
470
|
if (inList)
|
|
194
|
-
out.push("</ul>");
|
|
471
|
+
out.push("</ul>", "");
|
|
195
472
|
// Paragraphs and placeholder restoration
|
|
196
473
|
const joined = out.join("\n");
|
|
197
474
|
return joined
|
|
198
475
|
.split(/\n\n+/)
|
|
199
476
|
.map((block) => {
|
|
477
|
+
if (block.trim().startsWith("<ul>")) {
|
|
478
|
+
return block;
|
|
479
|
+
}
|
|
200
480
|
const match = block.match(/^@@CODEBLOCK_(\d+)@@$/);
|
|
201
481
|
if (match) {
|
|
202
482
|
const idx = Number(match[1]);
|
|
203
483
|
return codeBlocks[idx] ?? "";
|
|
204
484
|
}
|
|
205
|
-
|
|
485
|
+
const content = block.replace(/\n/g, "<br>").replace(/@@CODEBLOCK_(\d+)@@/g, (_m, id) => codeBlocks[Number(id)] ?? "");
|
|
486
|
+
return `<p>${content}</p>`;
|
|
206
487
|
})
|
|
207
488
|
.join("");
|
|
208
489
|
}
|
|
@@ -213,18 +494,23 @@ function renderFiles(files) {
|
|
|
213
494
|
.map((f) => {
|
|
214
495
|
const size = formatBytes(f.size);
|
|
215
496
|
const href = resolvePath(f.url || "");
|
|
497
|
+
const isHttp = /^https?:\/\//.test(href);
|
|
498
|
+
const targetAttrs = isHttp ? ' target="_blank" rel="noopener"' : "";
|
|
499
|
+
const downloadAttr = isHttp ? "" : " download";
|
|
216
500
|
return `<li class="messages-file">
|
|
217
501
|
<span class="messages-file-icon">📎</span>
|
|
218
|
-
<a href="${escapeHtml(href)}"
|
|
502
|
+
<a href="${escapeHtml(href)}"${downloadAttr}${targetAttrs}>${escapeHtml(f.name)}</a>
|
|
219
503
|
${size ? `<span class="messages-file-size muted small">${escapeHtml(size)}</span>` : ""}
|
|
220
504
|
</li>`;
|
|
221
505
|
})
|
|
222
506
|
.join("");
|
|
223
507
|
return `<ul class="messages-files">${items}</ul>`;
|
|
224
508
|
}
|
|
225
|
-
function renderDispatch(entry, isLatest, runStatus) {
|
|
509
|
+
function renderDispatch(entry, isLatest, runStatus, isLastInTimeline = false) {
|
|
226
510
|
const dispatch = entry.dispatch;
|
|
227
511
|
const isHandoff = dispatch?.is_handoff || dispatch?.mode === "pause";
|
|
512
|
+
const isNotify = dispatch?.mode === "notify";
|
|
513
|
+
const isTurnSummary = dispatch?.mode === "turn_summary" || dispatch?.extra?.is_turn_summary;
|
|
228
514
|
const title = dispatch?.title || (isHandoff ? "Handoff" : "Agent update");
|
|
229
515
|
let modeClass = "pill-info";
|
|
230
516
|
let modeLabel = "INFO";
|
|
@@ -240,19 +526,46 @@ function renderDispatch(entry, isLatest, runStatus) {
|
|
|
240
526
|
modeLabel = "HANDOFF";
|
|
241
527
|
}
|
|
242
528
|
}
|
|
529
|
+
// Determine dispatch type for color coding
|
|
530
|
+
let dispatchTypeClass = "";
|
|
531
|
+
if (isHandoff) {
|
|
532
|
+
dispatchTypeClass = "dispatch-pause";
|
|
533
|
+
}
|
|
534
|
+
else if (isNotify) {
|
|
535
|
+
dispatchTypeClass = "dispatch-notify";
|
|
536
|
+
}
|
|
537
|
+
else if (isTurnSummary) {
|
|
538
|
+
dispatchTypeClass = "dispatch-turn";
|
|
539
|
+
}
|
|
540
|
+
// Collapse all but the last dispatch in the timeline
|
|
541
|
+
const isCollapsed = !isLastInTimeline;
|
|
243
542
|
const modePill = dispatch?.mode ? ` <span class="pill pill-small ${modeClass}">${escapeHtml(modeLabel)}</span>` : "";
|
|
244
543
|
const body = dispatch?.body ? `<div class="messages-body messages-markdown">${renderMarkdown(dispatch.body)}</div>` : "";
|
|
245
544
|
const ts = entry.created_at ? formatTimestamp(entry.created_at) : "";
|
|
545
|
+
const collapseTitle = isCollapsed ? "Click to expand" : "Click to collapse";
|
|
246
546
|
return `
|
|
247
|
-
<div class="messages-entry
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
547
|
+
<div class="messages-entry${dispatchTypeClass ? " " + dispatchTypeClass : ""}${isCollapsed ? " collapsed" : ""}"
|
|
548
|
+
data-seq="${entry.seq}"
|
|
549
|
+
data-type="dispatch"
|
|
550
|
+
data-created="${escapeHtml(entry.created_at || "")}">
|
|
551
|
+
<div class="messages-collapse-bar"
|
|
552
|
+
role="button"
|
|
553
|
+
tabindex="0"
|
|
554
|
+
title="${collapseTitle}"
|
|
555
|
+
aria-label="${isCollapsed ? "Expand dispatch" : "Collapse dispatch"}"
|
|
556
|
+
aria-expanded="${String(!isCollapsed)}"></div>
|
|
557
|
+
<div class="messages-content-wrapper">
|
|
558
|
+
<div class="messages-entry-header">
|
|
559
|
+
<span class="messages-entry-seq">#${entry.seq.toString().padStart(4, "0")}</span>
|
|
560
|
+
<span class="messages-entry-title">${escapeHtml(title)}</span>
|
|
561
|
+
${modePill}
|
|
562
|
+
<span class="messages-entry-time">${escapeHtml(ts)}</span>
|
|
563
|
+
</div>
|
|
564
|
+
<div class="messages-entry-body">
|
|
565
|
+
${body}
|
|
566
|
+
${renderFiles(entry.files)}
|
|
567
|
+
</div>
|
|
253
568
|
</div>
|
|
254
|
-
${body}
|
|
255
|
-
${renderFiles(entry.files)}
|
|
256
569
|
</div>
|
|
257
570
|
`;
|
|
258
571
|
}
|
|
@@ -266,15 +579,25 @@ function renderReply(entry, parentSeq) {
|
|
|
266
579
|
: "";
|
|
267
580
|
return `
|
|
268
581
|
<div class="messages-entry messages-entry-reply" data-seq="${entry.seq}" data-type="reply" data-created="${escapeHtml(entry.created_at || "")}">
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
582
|
+
<div class="messages-collapse-bar"
|
|
583
|
+
role="button"
|
|
584
|
+
tabindex="0"
|
|
585
|
+
title="Click to collapse"
|
|
586
|
+
aria-label="Collapse reply"
|
|
587
|
+
aria-expanded="true"></div>
|
|
588
|
+
<div class="messages-content-wrapper">
|
|
589
|
+
${replyIndicator}
|
|
590
|
+
<div class="messages-entry-header">
|
|
591
|
+
<span class="messages-entry-seq">#${entry.seq.toString().padStart(4, "0")}</span>
|
|
592
|
+
<span class="messages-entry-title">${escapeHtml(title)}</span>
|
|
593
|
+
<span class="pill pill-small pill-idle">you</span>
|
|
594
|
+
<span class="messages-entry-time">${escapeHtml(ts)}</span>
|
|
595
|
+
</div>
|
|
596
|
+
<div class="messages-entry-body">
|
|
597
|
+
${body}
|
|
598
|
+
${renderFiles(entry.files)}
|
|
599
|
+
</div>
|
|
275
600
|
</div>
|
|
276
|
-
${body}
|
|
277
|
-
${renderFiles(entry.files)}
|
|
278
601
|
</div>
|
|
279
602
|
`;
|
|
280
603
|
}
|
|
@@ -308,14 +631,24 @@ function buildThreadedTimeline(dispatches, replies, runStatus) {
|
|
|
308
631
|
}
|
|
309
632
|
return a.seq - b.seq;
|
|
310
633
|
});
|
|
634
|
+
// Count total dispatches in the sorted timeline
|
|
635
|
+
let dispatchCount = 0;
|
|
636
|
+
timeline.forEach((entry) => {
|
|
637
|
+
if (entry.type === "dispatch") {
|
|
638
|
+
dispatchCount++;
|
|
639
|
+
}
|
|
640
|
+
});
|
|
311
641
|
// Render timeline, associating replies with preceding dispatches
|
|
312
642
|
let lastDispatchSeq;
|
|
643
|
+
let currentDispatchIndex = 0;
|
|
313
644
|
const rendered = [];
|
|
314
645
|
timeline.forEach((entry) => {
|
|
315
646
|
if (entry.type === "dispatch" && entry.dispatch) {
|
|
316
647
|
lastDispatchSeq = entry.dispatch.seq;
|
|
317
648
|
const isLatest = entry.dispatch.seq === maxDispatchSeq;
|
|
318
|
-
|
|
649
|
+
const isLastInTimeline = currentDispatchIndex === dispatchCount - 1;
|
|
650
|
+
rendered.push(renderDispatch(entry.dispatch, isLatest, runStatus, isLastInTimeline));
|
|
651
|
+
currentDispatchIndex++;
|
|
319
652
|
}
|
|
320
653
|
else if (entry.type === "reply" && entry.reply) {
|
|
321
654
|
rendered.push(renderReply(entry.reply, lastDispatchSeq));
|
|
@@ -323,24 +656,113 @@ function buildThreadedTimeline(dispatches, replies, runStatus) {
|
|
|
323
656
|
});
|
|
324
657
|
return rendered.join("");
|
|
325
658
|
}
|
|
326
|
-
async function loadThread(runId) {
|
|
659
|
+
async function loadThread(runId, reason = "manual") {
|
|
327
660
|
selectedRunId = runId;
|
|
661
|
+
syncSelectedThread();
|
|
328
662
|
if (!detailEl)
|
|
329
663
|
return;
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
664
|
+
if (!MESSAGE_REFRESH_REASONS.includes(reason)) {
|
|
665
|
+
reason = "manual";
|
|
666
|
+
}
|
|
667
|
+
const showFullLoading = reason === "initial";
|
|
668
|
+
if (showFullLoading) {
|
|
669
|
+
detailEl.innerHTML = "Loading…";
|
|
670
|
+
}
|
|
671
|
+
else {
|
|
672
|
+
setThreadDetailRefreshing(true);
|
|
334
673
|
}
|
|
335
|
-
let detail;
|
|
336
674
|
try {
|
|
337
|
-
|
|
675
|
+
await threadDetailRefresh.refresh(async () => {
|
|
676
|
+
if (!isRepoHealthy()) {
|
|
677
|
+
return { status: "offline", runId };
|
|
678
|
+
}
|
|
679
|
+
const detail = (await api(`/api/messages/threads/${encodeURIComponent(runId)}`));
|
|
680
|
+
return { status: "ok", runId, detail };
|
|
681
|
+
}, { reason });
|
|
338
682
|
}
|
|
339
683
|
catch (_err) {
|
|
340
|
-
|
|
684
|
+
if (showFullLoading) {
|
|
685
|
+
detailEl.innerHTML = "";
|
|
686
|
+
}
|
|
341
687
|
flash("Failed to load message thread", "error");
|
|
342
|
-
return;
|
|
343
688
|
}
|
|
689
|
+
finally {
|
|
690
|
+
if (!showFullLoading) {
|
|
691
|
+
setThreadDetailRefreshing(false);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
function isAtBottom(el) {
|
|
696
|
+
const threshold = 8;
|
|
697
|
+
return el.scrollTop + el.clientHeight >= el.scrollHeight - threshold;
|
|
698
|
+
}
|
|
699
|
+
function updateMobileDetailHeader(status, dispatchCount, replyCount) {
|
|
700
|
+
const statusEl = document.getElementById("messages-detail-status");
|
|
701
|
+
const countsEl = document.getElementById("messages-detail-counts");
|
|
702
|
+
if (statusEl) {
|
|
703
|
+
statusEl.className = `messages-detail-status pill pill-small ${getStatusPillClass(status)}`;
|
|
704
|
+
statusEl.textContent = status || "idle";
|
|
705
|
+
}
|
|
706
|
+
if (countsEl) {
|
|
707
|
+
countsEl.textContent = `${dispatchCount}D · ${replyCount}R`;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
function attachCollapseHandlers() {
|
|
711
|
+
if (!detailEl)
|
|
712
|
+
return;
|
|
713
|
+
// Helper to toggle collapse state
|
|
714
|
+
const toggleEntry = (entry, bar) => {
|
|
715
|
+
const isNowCollapsed = entry.classList.toggle("collapsed");
|
|
716
|
+
bar.title = isNowCollapsed ? "Click to expand" : "Click to collapse";
|
|
717
|
+
bar.setAttribute("aria-expanded", String(!isNowCollapsed));
|
|
718
|
+
bar.setAttribute("aria-label", isNowCollapsed ? "Expand dispatch" : "Collapse dispatch");
|
|
719
|
+
};
|
|
720
|
+
// Attach handlers to collapse bars
|
|
721
|
+
const collapseBars = detailEl.querySelectorAll(".messages-collapse-bar");
|
|
722
|
+
collapseBars.forEach((bar) => {
|
|
723
|
+
// Remove existing listeners by cloning
|
|
724
|
+
const newBar = bar.cloneNode(true);
|
|
725
|
+
bar.parentNode?.replaceChild(newBar, bar);
|
|
726
|
+
newBar.addEventListener("click", (e) => {
|
|
727
|
+
e.stopPropagation();
|
|
728
|
+
const entry = newBar.closest(".messages-entry");
|
|
729
|
+
if (entry) {
|
|
730
|
+
toggleEntry(entry, newBar);
|
|
731
|
+
}
|
|
732
|
+
});
|
|
733
|
+
// Keyboard support
|
|
734
|
+
newBar.addEventListener("keydown", (e) => {
|
|
735
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
736
|
+
e.preventDefault();
|
|
737
|
+
const entry = newBar.closest(".messages-entry");
|
|
738
|
+
if (entry) {
|
|
739
|
+
toggleEntry(entry, newBar);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
});
|
|
743
|
+
});
|
|
744
|
+
// Also make headers clickable for collapse
|
|
745
|
+
const headers = detailEl.querySelectorAll(".messages-entry-header");
|
|
746
|
+
headers.forEach((header) => {
|
|
747
|
+
// Remove existing listeners by cloning
|
|
748
|
+
const newHeader = header.cloneNode(true);
|
|
749
|
+
header.parentNode?.replaceChild(newHeader, header);
|
|
750
|
+
newHeader.addEventListener("click", (e) => {
|
|
751
|
+
// Don't toggle if clicking on a link
|
|
752
|
+
if (e.target.closest("a"))
|
|
753
|
+
return;
|
|
754
|
+
e.stopPropagation();
|
|
755
|
+
const entry = newHeader.closest(".messages-entry");
|
|
756
|
+
const bar = entry?.querySelector(".messages-collapse-bar");
|
|
757
|
+
if (entry && bar) {
|
|
758
|
+
toggleEntry(entry, bar);
|
|
759
|
+
}
|
|
760
|
+
});
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
function renderThreadDetail(detail, runId, ctx) {
|
|
764
|
+
if (!detailEl)
|
|
765
|
+
return;
|
|
344
766
|
const runStatus = (detail.run?.status || "").toString();
|
|
345
767
|
const isPaused = runStatus === "paused";
|
|
346
768
|
const dispatchHistory = detail.dispatch_history || [];
|
|
@@ -349,6 +771,8 @@ async function loadThread(runId) {
|
|
|
349
771
|
const replyCount = detail.reply_count ?? replyHistory.length;
|
|
350
772
|
const ticketState = detail.ticket_state;
|
|
351
773
|
const turns = ticketState?.total_turns ?? null;
|
|
774
|
+
// Update mobile header metadata
|
|
775
|
+
updateMobileDetailHeader(runStatus, dispatchCount, replyCount);
|
|
352
776
|
// Truncate run ID for display
|
|
353
777
|
const shortRunId = runId.length > 12 ? runId.slice(0, 8) + "…" : runId;
|
|
354
778
|
// Build compact stats line
|
|
@@ -363,27 +787,54 @@ async function loadThread(runId) {
|
|
|
363
787
|
const statusLabel = isPaused ? "paused" : runStatus || "idle";
|
|
364
788
|
// Build threaded timeline
|
|
365
789
|
const threadedContent = buildThreadedTimeline(dispatchHistory, replyHistory, runStatus);
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
<
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
790
|
+
const renderDetail = () => {
|
|
791
|
+
detailEl.innerHTML = `
|
|
792
|
+
<div class="messages-thread-history">
|
|
793
|
+
${threadedContent || '<div class="muted">No dispatches yet</div>'}
|
|
794
|
+
</div>
|
|
795
|
+
<div class="messages-thread-footer">
|
|
796
|
+
<code title="${escapeHtml(runId)}">${escapeHtml(shortRunId)}</code>
|
|
797
|
+
<span class="pill pill-small ${statusPillClass}">${escapeHtml(statusLabel)}</span>
|
|
798
|
+
<span class="messages-footer-stats">${escapeHtml(statsLine)}</span>
|
|
799
|
+
</div>
|
|
800
|
+
`;
|
|
801
|
+
};
|
|
802
|
+
const preserve = ctx.reason === "background" && detailEl.scrollHeight > 0 && !isAtBottom(detailEl);
|
|
803
|
+
if (preserve) {
|
|
804
|
+
preserveScroll(detailEl, () => {
|
|
805
|
+
renderDetail();
|
|
806
|
+
attachCollapseHandlers();
|
|
807
|
+
}, { restoreOnNextFrame: true });
|
|
808
|
+
}
|
|
809
|
+
else {
|
|
810
|
+
renderDetail();
|
|
811
|
+
attachCollapseHandlers();
|
|
812
|
+
}
|
|
376
813
|
// Only show reply box for paused runs - replies to other states won't be seen
|
|
377
|
-
const replyBoxEl = document.querySelector(".messages-
|
|
814
|
+
const replyBoxEl = document.querySelector(".messages-compose");
|
|
378
815
|
if (replyBoxEl) {
|
|
379
816
|
replyBoxEl.classList.toggle("hidden", !isPaused);
|
|
380
817
|
}
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
818
|
+
if (!preserve) {
|
|
819
|
+
requestAnimationFrame(() => {
|
|
820
|
+
if (detailEl) {
|
|
821
|
+
detailEl.scrollTop = detailEl.scrollHeight;
|
|
822
|
+
}
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
function refreshAttachSummary() {
|
|
827
|
+
if (!replyAttachSummary || !replyFilesEl)
|
|
828
|
+
return;
|
|
829
|
+
const files = Array.from(replyFilesEl.files || []);
|
|
830
|
+
if (!files.length) {
|
|
831
|
+
replyAttachSummary.textContent = "";
|
|
832
|
+
replyAttachSummary.classList.add("hidden");
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
const label = files.length > 2 ? `${files.length} files` : files.map((f) => f.name).join(", ");
|
|
836
|
+
replyAttachSummary.textContent = label;
|
|
837
|
+
replyAttachSummary.classList.remove("hidden");
|
|
387
838
|
}
|
|
388
839
|
async function sendReply() {
|
|
389
840
|
const runId = selectedRunId;
|
|
@@ -410,6 +861,7 @@ async function sendReply() {
|
|
|
410
861
|
replyBodyEl.value = "";
|
|
411
862
|
if (replyFilesEl)
|
|
412
863
|
replyFilesEl.value = "";
|
|
864
|
+
refreshAttachSummary();
|
|
413
865
|
flash("Reply sent", "success");
|
|
414
866
|
// Always resume after sending
|
|
415
867
|
await api(`/api/flows/${encodeURIComponent(runId)}/resume`, { method: "POST" });
|
|
@@ -427,44 +879,73 @@ export function initMessages() {
|
|
|
427
879
|
if (!threadsEl || !detailEl)
|
|
428
880
|
return;
|
|
429
881
|
messagesInitialized = true;
|
|
882
|
+
initFileBox();
|
|
883
|
+
backBtn?.addEventListener("click", showThreadList);
|
|
884
|
+
window.addEventListener("resize", () => {
|
|
885
|
+
if (!isMobileViewport()) {
|
|
886
|
+
layoutEl?.classList.remove("viewing-detail");
|
|
887
|
+
}
|
|
888
|
+
});
|
|
430
889
|
refreshEl?.addEventListener("click", () => {
|
|
431
|
-
void loadThreads();
|
|
890
|
+
void loadThreads("manual");
|
|
432
891
|
const runId = selectedRunId;
|
|
433
892
|
if (runId)
|
|
434
|
-
void loadThread(runId);
|
|
893
|
+
void loadThread(runId, "manual");
|
|
435
894
|
});
|
|
436
895
|
replySendEl?.addEventListener("click", () => {
|
|
437
896
|
void sendReply();
|
|
438
897
|
});
|
|
898
|
+
replyAttachBtn?.addEventListener("click", () => {
|
|
899
|
+
replyFilesEl?.click();
|
|
900
|
+
});
|
|
901
|
+
replyFilesEl?.addEventListener("change", () => {
|
|
902
|
+
refreshAttachSummary();
|
|
903
|
+
});
|
|
904
|
+
refreshAttachSummary();
|
|
439
905
|
// Load threads immediately, and try to open run_id from URL if present.
|
|
440
|
-
void loadThreads().then(() => {
|
|
906
|
+
void loadThreads("initial").then(() => {
|
|
441
907
|
const params = getUrlParams();
|
|
442
908
|
const runId = params.get("run_id");
|
|
443
909
|
if (runId) {
|
|
444
910
|
selectedRunId = runId;
|
|
445
|
-
|
|
911
|
+
showThreadDetail();
|
|
912
|
+
void loadThread(runId, "initial");
|
|
446
913
|
return;
|
|
447
914
|
}
|
|
448
915
|
// Fall back to active message if any.
|
|
449
916
|
if (activeRunId) {
|
|
450
917
|
selectedRunId = activeRunId;
|
|
451
918
|
updateUrlParams({ run_id: activeRunId });
|
|
452
|
-
|
|
919
|
+
showThreadDetail();
|
|
920
|
+
void loadThread(activeRunId, "initial");
|
|
453
921
|
}
|
|
454
922
|
});
|
|
455
923
|
subscribe("tab:change", (tabId) => {
|
|
456
924
|
if (tabId === "inbox") {
|
|
457
925
|
void refreshBell();
|
|
458
|
-
void loadThreads();
|
|
926
|
+
void loadThreads("manual");
|
|
459
927
|
const params = getUrlParams();
|
|
460
928
|
const runId = params.get("run_id");
|
|
461
929
|
if (runId) {
|
|
462
930
|
selectedRunId = runId;
|
|
463
|
-
|
|
931
|
+
showThreadDetail();
|
|
932
|
+
void loadThread(runId, "manual");
|
|
464
933
|
}
|
|
465
934
|
}
|
|
466
935
|
});
|
|
467
936
|
subscribe("state:update", () => {
|
|
468
937
|
void refreshBell();
|
|
469
938
|
});
|
|
939
|
+
subscribe("repo:health", (payload) => {
|
|
940
|
+
const status = payload?.status || "";
|
|
941
|
+
if (status === "ok" || status === "degraded" || status === "offline") {
|
|
942
|
+
void loadThreads("background");
|
|
943
|
+
if (selectedRunId) {
|
|
944
|
+
void loadThread(selectedRunId, "background");
|
|
945
|
+
}
|
|
946
|
+
if (status === "ok" || status === "degraded") {
|
|
947
|
+
void fileBoxCtrl?.refresh();
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
});
|
|
470
951
|
}
|