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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// GENERATED FILE - do not edit directly. Source: static_src/
|
|
2
|
-
import { api, flash, getUrlParams, resolvePath, statusPill, getAuthToken, openModal } from "./utils.js";
|
|
3
|
-
|
|
2
|
+
import { api, confirmModal, flash, getUrlParams, resolvePath, statusPill, getAuthToken, openModal, inputModal, setButtonLoading, } from "./utils.js";
|
|
3
|
+
// Note: activateTab removed - header now used for collapse, not inbox navigation
|
|
4
4
|
import { registerAutoRefresh } from "./autoRefresh.js";
|
|
5
5
|
import { CONSTANTS } from "./constants.js";
|
|
6
6
|
import { subscribe } from "./bus.js";
|
|
@@ -9,15 +9,55 @@ import { closeTicketEditor, initTicketEditor, openTicketEditor } from "./ticketE
|
|
|
9
9
|
import { parseAppServerEvent } from "./agentEvents.js";
|
|
10
10
|
import { summarizeEvents, renderCompactSummary, COMPACT_MAX_TEXT_LENGTH } from "./eventSummarizer.js";
|
|
11
11
|
import { refreshBell, renderMarkdown } from "./messages.js";
|
|
12
|
+
import { preserveScroll } from "./preserve.js";
|
|
13
|
+
import { createSmartRefresh } from "./smartRefresh.js";
|
|
14
|
+
function formatDispatchTime(ts) {
|
|
15
|
+
if (!ts)
|
|
16
|
+
return "";
|
|
17
|
+
const date = new Date(ts);
|
|
18
|
+
if (Number.isNaN(date.getTime()))
|
|
19
|
+
return "";
|
|
20
|
+
const now = new Date();
|
|
21
|
+
const diffMs = now.getTime() - date.getTime();
|
|
22
|
+
const diffSecs = Math.floor(diffMs / 1000);
|
|
23
|
+
if (diffSecs < 60)
|
|
24
|
+
return "now";
|
|
25
|
+
const diffMins = Math.floor(diffSecs / 60);
|
|
26
|
+
if (diffMins < 60)
|
|
27
|
+
return `${diffMins}m`;
|
|
28
|
+
const diffHours = Math.floor(diffMins / 60);
|
|
29
|
+
if (diffHours < 24)
|
|
30
|
+
return `${diffHours}h`;
|
|
31
|
+
const diffDays = Math.floor(diffHours / 24);
|
|
32
|
+
if (diffDays < 7)
|
|
33
|
+
return `${diffDays}d`;
|
|
34
|
+
return date.toLocaleDateString([], { month: "short", day: "numeric" });
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Format a number for compact display (e.g., 1200 -> "1.2k").
|
|
38
|
+
*/
|
|
39
|
+
function formatNumber(n) {
|
|
40
|
+
if (n >= 1000000) {
|
|
41
|
+
return `${(n / 1000000).toFixed(1).replace(/\.0$/, "")}M`;
|
|
42
|
+
}
|
|
43
|
+
if (n >= 1000) {
|
|
44
|
+
return `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`;
|
|
45
|
+
}
|
|
46
|
+
return n.toString();
|
|
47
|
+
}
|
|
12
48
|
let currentRunId = null;
|
|
13
49
|
let ticketsExist = false;
|
|
14
50
|
let currentActiveTicket = null;
|
|
15
51
|
let currentFlowStatus = null;
|
|
52
|
+
let selectedTicketPath = null;
|
|
16
53
|
let elapsedTimerId = null;
|
|
17
54
|
let flowStartedAt = null;
|
|
18
55
|
let eventSource = null;
|
|
56
|
+
let eventSourceRunId = null;
|
|
19
57
|
let lastActivityTime = null;
|
|
20
58
|
let lastActivityTimerId = null;
|
|
59
|
+
let lastKnownEventSeq = null;
|
|
60
|
+
let lastKnownEventAt = null;
|
|
21
61
|
let liveOutputDetailExpanded = false; // Start with summary view, one click for full
|
|
22
62
|
let liveOutputBuffer = [];
|
|
23
63
|
const MAX_OUTPUT_LINES = 200;
|
|
@@ -25,12 +65,73 @@ const LIVE_EVENT_MAX = 50;
|
|
|
25
65
|
let liveOutputEvents = [];
|
|
26
66
|
let liveOutputEventIndex = {};
|
|
27
67
|
let currentReasonFull = null; // Full reason text for modal display
|
|
68
|
+
let dispatchHistoryRunId = null;
|
|
69
|
+
let eventSourceRetryAttempt = 0;
|
|
70
|
+
let eventSourceRetryTimerId = null;
|
|
71
|
+
const lastSeenSeqByRun = {};
|
|
72
|
+
let ticketListCache = null;
|
|
73
|
+
let ticketFlowLoaded = false;
|
|
74
|
+
function isFlowActiveStatus(status) {
|
|
75
|
+
// Mirror backend FlowRunStatus.is_active(): pending | running | stopping
|
|
76
|
+
return status === "pending" || status === "running" || status === "stopping";
|
|
77
|
+
}
|
|
28
78
|
// Dispatch panel collapse state (persisted to localStorage)
|
|
29
79
|
const DISPATCH_PANEL_COLLAPSED_KEY = "car-dispatch-panel-collapsed";
|
|
30
80
|
let dispatchPanelCollapsed = false;
|
|
81
|
+
const LAST_SEEN_SEQ_KEY_PREFIX = "car-ticket-flow-last-seq:";
|
|
82
|
+
const EVENT_STREAM_RETRY_DELAYS_MS = [500, 1000, 2000, 5000, 10000];
|
|
83
|
+
const STALE_THRESHOLD_MS = 30000;
|
|
31
84
|
// Throttling state
|
|
32
85
|
let liveOutputRenderPending = false;
|
|
33
86
|
let liveOutputTextPending = false;
|
|
87
|
+
const ticketListRefresh = createSmartRefresh({
|
|
88
|
+
getSignature: (payload) => {
|
|
89
|
+
const list = (payload.tickets || []);
|
|
90
|
+
const pieces = list.map((ticket) => {
|
|
91
|
+
const fm = (ticket.frontmatter || {});
|
|
92
|
+
const title = fm?.title ? String(fm.title) : "";
|
|
93
|
+
const done = fm?.done ? "1" : "0";
|
|
94
|
+
const agent = fm?.agent ? String(fm.agent) : "";
|
|
95
|
+
const mtime = ticket.mtime ?? "";
|
|
96
|
+
const errors = Array.isArray(ticket.errors) ? ticket.errors.join(",") : "";
|
|
97
|
+
return [ticket.path ?? "", ticket.index ?? "", title, done, agent, mtime, errors].join("|");
|
|
98
|
+
});
|
|
99
|
+
return [
|
|
100
|
+
payload.ticket_dir ?? "",
|
|
101
|
+
payload.activeTicket ?? "",
|
|
102
|
+
payload.flowStatus ?? "",
|
|
103
|
+
pieces.join(";"),
|
|
104
|
+
].join("::");
|
|
105
|
+
},
|
|
106
|
+
render: (payload) => {
|
|
107
|
+
const { tickets } = els();
|
|
108
|
+
preserveScroll(tickets, () => {
|
|
109
|
+
renderTickets({
|
|
110
|
+
ticket_dir: payload.ticket_dir,
|
|
111
|
+
tickets: payload.tickets,
|
|
112
|
+
});
|
|
113
|
+
}, { restoreOnNextFrame: true });
|
|
114
|
+
},
|
|
115
|
+
onSkip: () => {
|
|
116
|
+
updateScrollFade();
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
const dispatchHistoryRefresh = createSmartRefresh({
|
|
120
|
+
getSignature: (payload) => {
|
|
121
|
+
const entries = payload.history || [];
|
|
122
|
+
const latestSeq = entries[0]?.seq ?? "";
|
|
123
|
+
return [payload.runId ?? "", latestSeq, entries.length].join("::");
|
|
124
|
+
},
|
|
125
|
+
render: (payload) => {
|
|
126
|
+
const { history } = els();
|
|
127
|
+
preserveScroll(history, () => {
|
|
128
|
+
renderDispatchHistory(payload.runId, { history: payload.history });
|
|
129
|
+
}, { restoreOnNextFrame: true });
|
|
130
|
+
},
|
|
131
|
+
onSkip: () => {
|
|
132
|
+
updateScrollFade();
|
|
133
|
+
},
|
|
134
|
+
});
|
|
34
135
|
function scheduleLiveOutputRender() {
|
|
35
136
|
if (liveOutputRenderPending)
|
|
36
137
|
return;
|
|
@@ -194,6 +295,77 @@ function stopLastActivityTimer() {
|
|
|
194
295
|
lastActivityTimerId = null;
|
|
195
296
|
}
|
|
196
297
|
}
|
|
298
|
+
function updateLastActivityFromTimestamp(timestamp) {
|
|
299
|
+
if (timestamp) {
|
|
300
|
+
const parsed = new Date(timestamp);
|
|
301
|
+
if (!Number.isNaN(parsed.getTime())) {
|
|
302
|
+
lastActivityTime = parsed;
|
|
303
|
+
lastKnownEventAt = parsed;
|
|
304
|
+
startLastActivityTimer();
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
lastActivityTime = null;
|
|
309
|
+
lastKnownEventAt = null;
|
|
310
|
+
stopLastActivityTimer();
|
|
311
|
+
const { lastActivity } = els();
|
|
312
|
+
if (lastActivity)
|
|
313
|
+
lastActivity.textContent = "–";
|
|
314
|
+
}
|
|
315
|
+
function getLastSeenSeq(runId) {
|
|
316
|
+
if (lastSeenSeqByRun[runId] !== undefined) {
|
|
317
|
+
return lastSeenSeqByRun[runId];
|
|
318
|
+
}
|
|
319
|
+
const stored = localStorage.getItem(`${LAST_SEEN_SEQ_KEY_PREFIX}${runId}`);
|
|
320
|
+
if (!stored)
|
|
321
|
+
return null;
|
|
322
|
+
const parsed = Number.parseInt(stored, 10);
|
|
323
|
+
if (Number.isNaN(parsed))
|
|
324
|
+
return null;
|
|
325
|
+
lastSeenSeqByRun[runId] = parsed;
|
|
326
|
+
return parsed;
|
|
327
|
+
}
|
|
328
|
+
function setLastSeenSeq(runId, seq) {
|
|
329
|
+
if (!Number.isFinite(seq))
|
|
330
|
+
return;
|
|
331
|
+
const current = lastSeenSeqByRun[runId];
|
|
332
|
+
if (current !== undefined && seq <= current)
|
|
333
|
+
return;
|
|
334
|
+
lastSeenSeqByRun[runId] = seq;
|
|
335
|
+
localStorage.setItem(`${LAST_SEEN_SEQ_KEY_PREFIX}${runId}`, String(seq));
|
|
336
|
+
}
|
|
337
|
+
function parseEventSeq(event, lastEventId) {
|
|
338
|
+
if (typeof event.seq === "number" && Number.isFinite(event.seq)) {
|
|
339
|
+
return event.seq;
|
|
340
|
+
}
|
|
341
|
+
if (lastEventId) {
|
|
342
|
+
const parsed = Number.parseInt(lastEventId, 10);
|
|
343
|
+
if (!Number.isNaN(parsed))
|
|
344
|
+
return parsed;
|
|
345
|
+
}
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
function clearEventStreamRetry() {
|
|
349
|
+
if (eventSourceRetryTimerId) {
|
|
350
|
+
clearTimeout(eventSourceRetryTimerId);
|
|
351
|
+
eventSourceRetryTimerId = null;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
function scheduleEventStreamReconnect(runId) {
|
|
355
|
+
if (eventSourceRetryTimerId)
|
|
356
|
+
return;
|
|
357
|
+
const index = Math.min(eventSourceRetryAttempt, EVENT_STREAM_RETRY_DELAYS_MS.length - 1);
|
|
358
|
+
const delay = EVENT_STREAM_RETRY_DELAYS_MS[index];
|
|
359
|
+
eventSourceRetryAttempt += 1;
|
|
360
|
+
eventSourceRetryTimerId = setTimeout(() => {
|
|
361
|
+
eventSourceRetryTimerId = null;
|
|
362
|
+
if (currentRunId !== runId)
|
|
363
|
+
return;
|
|
364
|
+
if (currentFlowStatus !== "running" && currentFlowStatus !== "pending")
|
|
365
|
+
return;
|
|
366
|
+
connectEventStream(runId);
|
|
367
|
+
}, delay);
|
|
368
|
+
}
|
|
197
369
|
function appendToLiveOutput(text) {
|
|
198
370
|
if (!text)
|
|
199
371
|
return;
|
|
@@ -418,6 +590,7 @@ function setLiveOutputStatus(status) {
|
|
|
418
590
|
function handleFlowEvent(event) {
|
|
419
591
|
// Update last activity time
|
|
420
592
|
lastActivityTime = new Date(event.timestamp);
|
|
593
|
+
lastKnownEventAt = lastActivityTime;
|
|
421
594
|
updateLastActivityDisplay();
|
|
422
595
|
// Handle agent stream delta events
|
|
423
596
|
if (event.event_type === "agent_stream_delta") {
|
|
@@ -435,6 +608,20 @@ function handleFlowEvent(event) {
|
|
|
435
608
|
scheduleLiveOutputRender();
|
|
436
609
|
}
|
|
437
610
|
}
|
|
611
|
+
// Handle step progress events carrying ticket selection so UI can highlight immediately
|
|
612
|
+
if (event.event_type === "step_progress") {
|
|
613
|
+
const nextTicket = event.data?.current_ticket;
|
|
614
|
+
if (nextTicket) {
|
|
615
|
+
currentActiveTicket = nextTicket;
|
|
616
|
+
// Don't force flow status here; it comes from the runs endpoint.
|
|
617
|
+
const { current } = els();
|
|
618
|
+
if (current)
|
|
619
|
+
current.textContent = currentActiveTicket;
|
|
620
|
+
if (ticketListCache) {
|
|
621
|
+
renderTickets(ticketListCache);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
438
625
|
// Handle flow lifecycle events
|
|
439
626
|
if (event.event_type === "flow_completed" ||
|
|
440
627
|
event.event_type === "flow_failed" ||
|
|
@@ -451,20 +638,38 @@ function handleFlowEvent(event) {
|
|
|
451
638
|
}
|
|
452
639
|
}
|
|
453
640
|
}
|
|
454
|
-
function connectEventStream(runId) {
|
|
641
|
+
function connectEventStream(runId, afterSeq) {
|
|
455
642
|
disconnectEventStream();
|
|
643
|
+
clearEventStreamRetry();
|
|
644
|
+
eventSourceRunId = runId;
|
|
456
645
|
const token = getAuthToken();
|
|
457
|
-
|
|
646
|
+
const url = new URL(resolvePath(`/api/flows/${runId}/events`), window.location.origin);
|
|
458
647
|
if (token) {
|
|
459
|
-
url
|
|
648
|
+
url.searchParams.set("token", token);
|
|
649
|
+
}
|
|
650
|
+
if (typeof afterSeq === "number") {
|
|
651
|
+
url.searchParams.set("after", String(afterSeq));
|
|
652
|
+
}
|
|
653
|
+
else {
|
|
654
|
+
const lastSeenSeq = getLastSeenSeq(runId);
|
|
655
|
+
if (typeof lastSeenSeq === "number") {
|
|
656
|
+
url.searchParams.set("after", String(lastSeenSeq));
|
|
657
|
+
}
|
|
460
658
|
}
|
|
461
|
-
eventSource = new EventSource(url);
|
|
659
|
+
eventSource = new EventSource(url.toString());
|
|
462
660
|
eventSource.onopen = () => {
|
|
463
661
|
setLiveOutputStatus("connected");
|
|
662
|
+
eventSourceRetryAttempt = 0;
|
|
663
|
+
clearEventStreamRetry();
|
|
464
664
|
};
|
|
465
665
|
eventSource.onmessage = (event) => {
|
|
466
666
|
try {
|
|
467
667
|
const data = JSON.parse(event.data);
|
|
668
|
+
const seq = parseEventSeq(data, event.lastEventId);
|
|
669
|
+
if (currentRunId && typeof seq === "number") {
|
|
670
|
+
setLastSeenSeq(currentRunId, seq);
|
|
671
|
+
lastKnownEventSeq = seq;
|
|
672
|
+
}
|
|
468
673
|
handleFlowEvent(data);
|
|
469
674
|
}
|
|
470
675
|
catch (err) {
|
|
@@ -473,7 +678,11 @@ function connectEventStream(runId) {
|
|
|
473
678
|
};
|
|
474
679
|
eventSource.onerror = () => {
|
|
475
680
|
setLiveOutputStatus("disconnected");
|
|
476
|
-
|
|
681
|
+
if (eventSource) {
|
|
682
|
+
eventSource.close();
|
|
683
|
+
eventSource = null;
|
|
684
|
+
}
|
|
685
|
+
scheduleEventStreamReconnect(runId);
|
|
477
686
|
};
|
|
478
687
|
}
|
|
479
688
|
function disconnectEventStream() {
|
|
@@ -481,6 +690,8 @@ function disconnectEventStream() {
|
|
|
481
690
|
eventSource.close();
|
|
482
691
|
eventSource = null;
|
|
483
692
|
}
|
|
693
|
+
clearEventStreamRetry();
|
|
694
|
+
eventSourceRunId = null;
|
|
484
695
|
setLiveOutputStatus("disconnected");
|
|
485
696
|
}
|
|
486
697
|
function initLiveOutputPanel() {
|
|
@@ -537,6 +748,12 @@ function els() {
|
|
|
537
748
|
progress: document.getElementById("ticket-flow-progress"),
|
|
538
749
|
reason: document.getElementById("ticket-flow-reason"),
|
|
539
750
|
lastActivity: document.getElementById("ticket-flow-last-activity"),
|
|
751
|
+
stalePill: document.getElementById("ticket-flow-stale"),
|
|
752
|
+
reconnectBtn: document.getElementById("ticket-flow-reconnect"),
|
|
753
|
+
workerStatus: document.getElementById("ticket-flow-worker"),
|
|
754
|
+
workerPill: document.getElementById("ticket-flow-worker-pill"),
|
|
755
|
+
recoverBtn: document.getElementById("ticket-flow-recover"),
|
|
756
|
+
metaDetails: document.getElementById("ticket-meta-details"),
|
|
540
757
|
dir: document.getElementById("ticket-flow-dir"),
|
|
541
758
|
tickets: document.getElementById("ticket-flow-tickets"),
|
|
542
759
|
history: document.getElementById("ticket-dispatch-history"),
|
|
@@ -550,41 +767,106 @@ function els() {
|
|
|
550
767
|
stopBtn: document.getElementById("ticket-flow-stop"),
|
|
551
768
|
restartBtn: document.getElementById("ticket-flow-restart"),
|
|
552
769
|
archiveBtn: document.getElementById("ticket-flow-archive"),
|
|
770
|
+
overflowToggle: document.getElementById("ticket-overflow-toggle"),
|
|
771
|
+
overflowDropdown: document.getElementById("ticket-overflow-dropdown"),
|
|
772
|
+
overflowNew: document.getElementById("ticket-overflow-new"),
|
|
773
|
+
overflowRestart: document.getElementById("ticket-overflow-restart"),
|
|
774
|
+
overflowArchive: document.getElementById("ticket-overflow-archive"),
|
|
553
775
|
};
|
|
554
776
|
}
|
|
555
777
|
function setButtonsDisabled(disabled) {
|
|
556
|
-
const { bootstrapBtn, resumeBtn, refreshBtn, stopBtn, restartBtn, archiveBtn } = els();
|
|
557
|
-
[bootstrapBtn, resumeBtn, refreshBtn, stopBtn, restartBtn, archiveBtn].forEach((btn) => {
|
|
778
|
+
const { bootstrapBtn, resumeBtn, refreshBtn, stopBtn, restartBtn, archiveBtn, reconnectBtn, recoverBtn } = els();
|
|
779
|
+
[bootstrapBtn, resumeBtn, refreshBtn, stopBtn, restartBtn, archiveBtn, reconnectBtn, recoverBtn].forEach((btn) => {
|
|
558
780
|
if (btn)
|
|
559
781
|
btn.disabled = disabled;
|
|
560
782
|
});
|
|
561
783
|
}
|
|
784
|
+
/**
|
|
785
|
+
* Updates the selected class on ticket items based on selectedTicketPath.
|
|
786
|
+
*/
|
|
787
|
+
function updateSelectedTicket(path) {
|
|
788
|
+
selectedTicketPath = path;
|
|
789
|
+
const ticketList = document.getElementById("ticket-flow-tickets");
|
|
790
|
+
if (!ticketList)
|
|
791
|
+
return;
|
|
792
|
+
const items = ticketList.querySelectorAll(".ticket-item");
|
|
793
|
+
items.forEach((item) => {
|
|
794
|
+
const ticketPath = item.getAttribute("data-ticket-path");
|
|
795
|
+
if (ticketPath === path) {
|
|
796
|
+
item.classList.add("selected");
|
|
797
|
+
}
|
|
798
|
+
else {
|
|
799
|
+
item.classList.remove("selected");
|
|
800
|
+
}
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
/**
|
|
804
|
+
* Updates the scroll fade indicator on ticket panels.
|
|
805
|
+
* Adds 'has-scroll-bottom' class when content is scrollable and not at bottom.
|
|
806
|
+
*/
|
|
807
|
+
function updateScrollFade() {
|
|
808
|
+
const ticketList = document.getElementById("ticket-flow-tickets");
|
|
809
|
+
const dispatchHistory = document.getElementById("ticket-dispatch-history");
|
|
810
|
+
[ticketList, dispatchHistory].forEach((list) => {
|
|
811
|
+
if (!list)
|
|
812
|
+
return;
|
|
813
|
+
const panel = list.closest(".ticket-panel");
|
|
814
|
+
if (!panel)
|
|
815
|
+
return;
|
|
816
|
+
// Check if scrollable and not scrolled to bottom
|
|
817
|
+
const hasScrollableContent = list.scrollHeight > list.clientHeight;
|
|
818
|
+
const isNotAtBottom = list.scrollTop + list.clientHeight < list.scrollHeight - 10;
|
|
819
|
+
if (hasScrollableContent && isNotAtBottom) {
|
|
820
|
+
panel.classList.add("has-scroll-bottom");
|
|
821
|
+
}
|
|
822
|
+
else {
|
|
823
|
+
panel.classList.remove("has-scroll-bottom");
|
|
824
|
+
}
|
|
825
|
+
});
|
|
826
|
+
}
|
|
562
827
|
function truncate(text, max = 100) {
|
|
563
828
|
if (text.length <= max)
|
|
564
829
|
return text;
|
|
565
830
|
return `${text.slice(0, max).trim()}…`;
|
|
566
831
|
}
|
|
567
832
|
function renderTickets(data) {
|
|
568
|
-
|
|
833
|
+
ticketListCache = data;
|
|
834
|
+
const { tickets, dir } = els();
|
|
569
835
|
if (dir)
|
|
570
836
|
dir.textContent = data?.ticket_dir || "–";
|
|
571
837
|
if (!tickets)
|
|
572
838
|
return;
|
|
573
839
|
tickets.innerHTML = "";
|
|
840
|
+
// Display lint errors if present
|
|
841
|
+
if (data?.lint_errors && data.lint_errors.length > 0) {
|
|
842
|
+
const lintBanner = document.createElement("div");
|
|
843
|
+
lintBanner.className = "ticket-lint-errors";
|
|
844
|
+
data.lint_errors.forEach((error) => {
|
|
845
|
+
const errorLine = document.createElement("div");
|
|
846
|
+
errorLine.textContent = error;
|
|
847
|
+
lintBanner.appendChild(errorLine);
|
|
848
|
+
});
|
|
849
|
+
tickets.appendChild(lintBanner);
|
|
850
|
+
}
|
|
574
851
|
const list = (data?.tickets || []);
|
|
575
852
|
ticketsExist = list.length > 0;
|
|
576
|
-
//
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
853
|
+
// Update progress bar
|
|
854
|
+
const progressBar = document.getElementById("ticket-progress-bar");
|
|
855
|
+
const progressFill = document.getElementById("ticket-progress-fill");
|
|
856
|
+
if (progressBar && progressFill) {
|
|
857
|
+
if (list.length === 0) {
|
|
858
|
+
progressBar.classList.add("hidden");
|
|
581
859
|
}
|
|
582
860
|
else {
|
|
583
|
-
|
|
861
|
+
progressBar.classList.remove("hidden");
|
|
862
|
+
const doneCount = list.filter((t) => Boolean((t.frontmatter || {})?.done)).length;
|
|
863
|
+
const percent = Math.round((doneCount / list.length) * 100);
|
|
864
|
+
progressFill.style.width = `${percent}%`;
|
|
865
|
+
progressBar.title = `${doneCount} of ${list.length} tickets done`;
|
|
584
866
|
}
|
|
585
867
|
}
|
|
586
868
|
if (!list.length) {
|
|
587
|
-
tickets.textContent = "No tickets found.
|
|
869
|
+
tickets.textContent = "No tickets found. Start the ticket flow to create TICKET-001.md.";
|
|
588
870
|
return;
|
|
589
871
|
}
|
|
590
872
|
list.forEach((ticket) => {
|
|
@@ -592,12 +874,26 @@ function renderTickets(data) {
|
|
|
592
874
|
const fm = (ticket.frontmatter || {});
|
|
593
875
|
const done = Boolean(fm?.done);
|
|
594
876
|
// Check if this ticket is currently being worked on
|
|
595
|
-
const isActive = currentActiveTicket &&
|
|
596
|
-
|
|
877
|
+
const isActive = Boolean(currentActiveTicket &&
|
|
878
|
+
ticket.path === currentActiveTicket &&
|
|
879
|
+
isFlowActiveStatus(currentFlowStatus));
|
|
880
|
+
item.className = `ticket-item ${done ? "done" : ""} ${isActive ? "active" : ""} ${selectedTicketPath === ticket.path ? "selected" : ""} clickable`;
|
|
597
881
|
item.title = "Click to edit";
|
|
882
|
+
item.setAttribute("data-ticket-path", ticket.path || "");
|
|
598
883
|
// Make ticket item clickable to open editor
|
|
599
|
-
item.addEventListener("click", () => {
|
|
600
|
-
|
|
884
|
+
item.addEventListener("click", async () => {
|
|
885
|
+
updateSelectedTicket(ticket.path || null);
|
|
886
|
+
try {
|
|
887
|
+
if (ticket.index == null) {
|
|
888
|
+
flash("Invalid ticket: missing index", "error");
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
const data = (await api(`/api/flows/ticket_flow/tickets/${ticket.index}`));
|
|
892
|
+
openTicketEditor(data);
|
|
893
|
+
}
|
|
894
|
+
catch (err) {
|
|
895
|
+
flash(`Failed to load ticket: ${err.message}`, "error");
|
|
896
|
+
}
|
|
601
897
|
});
|
|
602
898
|
const head = document.createElement("div");
|
|
603
899
|
head.className = "ticket-item-head";
|
|
@@ -631,20 +927,39 @@ function renderTickets(data) {
|
|
|
631
927
|
if (isActive) {
|
|
632
928
|
const workingBadge = document.createElement("span");
|
|
633
929
|
workingBadge.className = "ticket-working-badge";
|
|
634
|
-
|
|
930
|
+
// Text content used on middle responsive view; CSS hides text on desktop/mobile
|
|
931
|
+
const workingText = document.createElement("span");
|
|
932
|
+
workingText.className = "badge-text";
|
|
933
|
+
workingText.textContent = "Working";
|
|
934
|
+
workingBadge.appendChild(workingText);
|
|
635
935
|
badges.appendChild(workingBadge);
|
|
636
936
|
}
|
|
637
937
|
// Add DONE badge for completed tickets
|
|
638
938
|
if (done && !isActive) {
|
|
639
939
|
const doneBadge = document.createElement("span");
|
|
640
940
|
doneBadge.className = "ticket-done-badge";
|
|
641
|
-
|
|
941
|
+
// Text content used on middle responsive view; CSS hides text on desktop/mobile
|
|
942
|
+
const doneText = document.createElement("span");
|
|
943
|
+
doneText.className = "badge-text";
|
|
944
|
+
doneText.textContent = "Done";
|
|
945
|
+
doneBadge.appendChild(doneText);
|
|
642
946
|
badges.appendChild(doneBadge);
|
|
643
947
|
}
|
|
644
948
|
const agent = document.createElement("span");
|
|
645
949
|
agent.className = "ticket-agent";
|
|
646
950
|
agent.textContent = fm?.agent || "codex";
|
|
647
951
|
badges.appendChild(agent);
|
|
952
|
+
// Cumulative diff stats (from FlowStore DIFF_UPDATED aggregation).
|
|
953
|
+
const diffStats = ticket.diff_stats || null;
|
|
954
|
+
if (diffStats && (diffStats.insertions > 0 || diffStats.deletions > 0)) {
|
|
955
|
+
const statsEl = document.createElement("span");
|
|
956
|
+
statsEl.className = "ticket-diff-stats";
|
|
957
|
+
const ins = diffStats.insertions || 0;
|
|
958
|
+
const del = diffStats.deletions || 0;
|
|
959
|
+
statsEl.innerHTML = `<span class="diff-add">+${formatNumber(ins)}</span><span class="diff-del">-${formatNumber(del)}</span>`;
|
|
960
|
+
statsEl.title = `${ins} insertions, ${del} deletions${diffStats.files_changed ? `, ${diffStats.files_changed} files` : ""}`;
|
|
961
|
+
badges.appendChild(statsEl);
|
|
962
|
+
}
|
|
648
963
|
head.appendChild(badges);
|
|
649
964
|
item.appendChild(head);
|
|
650
965
|
if (ticket.errors && ticket.errors.length) {
|
|
@@ -661,6 +976,8 @@ function renderTickets(data) {
|
|
|
661
976
|
}
|
|
662
977
|
tickets.appendChild(item);
|
|
663
978
|
});
|
|
979
|
+
// Update scroll fade indicator after rendering
|
|
980
|
+
updateScrollFade();
|
|
664
981
|
}
|
|
665
982
|
function renderDispatchHistory(runId, data) {
|
|
666
983
|
const { history, dispatchNote } = els();
|
|
@@ -689,26 +1006,58 @@ function renderDispatchHistory(runId, data) {
|
|
|
689
1006
|
dispatchNote.textContent = `Latest #${entries[0]?.seq ?? "–"}`;
|
|
690
1007
|
// Also render mini list for collapsed panel view
|
|
691
1008
|
renderDispatchMiniList(entries);
|
|
692
|
-
entries.forEach((entry) => {
|
|
1009
|
+
entries.forEach((entry, index) => {
|
|
693
1010
|
const dispatch = entry.dispatch;
|
|
694
1011
|
const isTurnSummary = dispatch?.mode === "turn_summary" || dispatch?.extra?.is_turn_summary;
|
|
695
1012
|
const isHandoff = dispatch?.mode === "pause";
|
|
1013
|
+
const isNotify = dispatch?.mode === "notify";
|
|
1014
|
+
// Expand only the first (newest) dispatch by default - entries are newest-first
|
|
1015
|
+
const isFirst = index === 0;
|
|
1016
|
+
const isCollapsed = !isFirst;
|
|
696
1017
|
const container = document.createElement("div");
|
|
697
|
-
container.className = `dispatch-item${isTurnSummary ? " turn-summary" : ""}
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
1018
|
+
container.className = `dispatch-item${isTurnSummary ? " turn-summary" : ""}${isHandoff ? " pause" : ""}${isNotify ? " notify" : ""}${isCollapsed ? " collapsed" : ""}`;
|
|
1019
|
+
// Reddit-style thin collapse bar on the left
|
|
1020
|
+
const collapseBar = document.createElement("div");
|
|
1021
|
+
collapseBar.className = "dispatch-collapse-bar";
|
|
1022
|
+
collapseBar.title = isCollapsed ? "Click to expand" : "Click to collapse";
|
|
1023
|
+
collapseBar.setAttribute("role", "button");
|
|
1024
|
+
collapseBar.setAttribute("tabindex", "0");
|
|
1025
|
+
collapseBar.setAttribute("aria-label", isCollapsed ? "Expand dispatch" : "Collapse dispatch");
|
|
1026
|
+
collapseBar.setAttribute("aria-expanded", String(!isCollapsed));
|
|
1027
|
+
const toggleCollapse = () => {
|
|
1028
|
+
container.classList.toggle("collapsed");
|
|
1029
|
+
const isNowCollapsed = container.classList.contains("collapsed");
|
|
1030
|
+
collapseBar.title = isNowCollapsed ? "Click to expand" : "Click to collapse";
|
|
1031
|
+
collapseBar.setAttribute("aria-expanded", String(!isNowCollapsed));
|
|
1032
|
+
collapseBar.setAttribute("aria-label", isNowCollapsed ? "Expand dispatch" : "Collapse dispatch");
|
|
1033
|
+
};
|
|
1034
|
+
collapseBar.addEventListener("click", (e) => {
|
|
1035
|
+
e.stopPropagation();
|
|
1036
|
+
toggleCollapse();
|
|
1037
|
+
});
|
|
1038
|
+
collapseBar.addEventListener("keydown", (e) => {
|
|
1039
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
1040
|
+
e.preventDefault();
|
|
1041
|
+
toggleCollapse();
|
|
1042
|
+
}
|
|
1043
|
+
});
|
|
1044
|
+
// Content wrapper for header and body
|
|
1045
|
+
const contentWrapper = document.createElement("div");
|
|
1046
|
+
contentWrapper.className = "dispatch-content-wrapper";
|
|
1047
|
+
// Create collapsible structure
|
|
1048
|
+
const header = document.createElement("div");
|
|
1049
|
+
header.className = "dispatch-header";
|
|
1050
|
+
// Make header clickable to toggle collapse
|
|
1051
|
+
header.addEventListener("click", (e) => {
|
|
1052
|
+
// Don't toggle if clicking on a link or navigating to inbox
|
|
1053
|
+
if (e.target.closest("a"))
|
|
1054
|
+
return;
|
|
1055
|
+
toggleCollapse();
|
|
1056
|
+
});
|
|
1057
|
+
// Header content area
|
|
1058
|
+
const headerContent = document.createElement("div");
|
|
1059
|
+
headerContent.className = "dispatch-header-content";
|
|
1060
|
+
headerContent.title = isTurnSummary ? "Agent turn output" : "Click header to expand/collapse";
|
|
712
1061
|
// Determine mode label
|
|
713
1062
|
let modeLabel;
|
|
714
1063
|
if (isTurnSummary) {
|
|
@@ -729,6 +1078,24 @@ function renderDispatchHistory(runId, data) {
|
|
|
729
1078
|
mode.className = `ticket-agent${isTurnSummary ? " turn-summary-badge" : ""}`;
|
|
730
1079
|
mode.textContent = modeLabel;
|
|
731
1080
|
head.append(seq, mode);
|
|
1081
|
+
headerContent.appendChild(head);
|
|
1082
|
+
header.appendChild(headerContent);
|
|
1083
|
+
contentWrapper.appendChild(header);
|
|
1084
|
+
container.append(collapseBar, contentWrapper);
|
|
1085
|
+
// Add diff stats if present (for turn summaries)
|
|
1086
|
+
// New path: dispatch.diff_stats (from FlowStore DIFF_UPDATED merge)
|
|
1087
|
+
// Legacy fallback: dispatch.extra.diff_stats (DISPATCH.md frontmatter)
|
|
1088
|
+
const diffStats = (dispatch?.diff_stats ||
|
|
1089
|
+
dispatch?.extra?.diff_stats);
|
|
1090
|
+
if (diffStats && (diffStats.insertions || diffStats.deletions)) {
|
|
1091
|
+
const statsEl = document.createElement("span");
|
|
1092
|
+
statsEl.className = "dispatch-diff-stats";
|
|
1093
|
+
const ins = diffStats.insertions || 0;
|
|
1094
|
+
const del = diffStats.deletions || 0;
|
|
1095
|
+
statsEl.innerHTML = `<span class="diff-add">+${formatNumber(ins)}</span><span class="diff-del">-${formatNumber(del)}</span>`;
|
|
1096
|
+
statsEl.title = `${ins} insertions, ${del} deletions${diffStats.files_changed ? `, ${diffStats.files_changed} files` : ""}`;
|
|
1097
|
+
head.appendChild(statsEl);
|
|
1098
|
+
}
|
|
732
1099
|
// Add ticket reference if present
|
|
733
1100
|
const ticketId = dispatch?.extra?.ticket_id;
|
|
734
1101
|
if (ticketId) {
|
|
@@ -742,26 +1109,36 @@ function renderDispatchHistory(runId, data) {
|
|
|
742
1109
|
head.appendChild(ticketLabel);
|
|
743
1110
|
}
|
|
744
1111
|
}
|
|
745
|
-
|
|
1112
|
+
// Add timestamp
|
|
1113
|
+
const timeAgo = formatDispatchTime(entry.created_at);
|
|
1114
|
+
if (timeAgo) {
|
|
1115
|
+
const timeLabel = document.createElement("span");
|
|
1116
|
+
timeLabel.className = "dispatch-time";
|
|
1117
|
+
timeLabel.textContent = timeAgo;
|
|
1118
|
+
head.appendChild(timeLabel);
|
|
1119
|
+
}
|
|
1120
|
+
// Create collapsible body content
|
|
1121
|
+
const bodyWrapper = document.createElement("div");
|
|
1122
|
+
bodyWrapper.className = "dispatch-body-wrapper";
|
|
746
1123
|
if (entry.errors && entry.errors.length) {
|
|
747
1124
|
const err = document.createElement("div");
|
|
748
1125
|
err.className = "ticket-errors";
|
|
749
1126
|
err.textContent = entry.errors.join("; ");
|
|
750
|
-
|
|
1127
|
+
bodyWrapper.appendChild(err);
|
|
751
1128
|
}
|
|
752
1129
|
const title = dispatch?.title;
|
|
753
1130
|
if (title) {
|
|
754
1131
|
const titleEl = document.createElement("div");
|
|
755
1132
|
titleEl.className = "ticket-body ticket-dispatch-title";
|
|
756
1133
|
titleEl.textContent = title;
|
|
757
|
-
|
|
1134
|
+
bodyWrapper.appendChild(titleEl);
|
|
758
1135
|
}
|
|
759
1136
|
const bodyText = dispatch?.body;
|
|
760
1137
|
if (bodyText) {
|
|
761
1138
|
const body = document.createElement("div");
|
|
762
1139
|
body.className = "ticket-body ticket-dispatch-body messages-markdown";
|
|
763
1140
|
body.innerHTML = renderMarkdown(bodyText);
|
|
764
|
-
|
|
1141
|
+
bodyWrapper.appendChild(body);
|
|
765
1142
|
}
|
|
766
1143
|
const attachments = (entry.attachments || []);
|
|
767
1144
|
if (attachments.length) {
|
|
@@ -771,17 +1148,28 @@ function renderDispatchHistory(runId, data) {
|
|
|
771
1148
|
if (!att.url)
|
|
772
1149
|
return;
|
|
773
1150
|
const link = document.createElement("a");
|
|
774
|
-
|
|
1151
|
+
const resolved = new URL(resolvePath(att.url), window.location.origin);
|
|
1152
|
+
link.href = resolved.toString();
|
|
775
1153
|
link.textContent = att.name || att.rel_path || "attachment";
|
|
776
|
-
|
|
777
|
-
|
|
1154
|
+
// Prefer direct downloads for same-origin attachments.
|
|
1155
|
+
if (resolved.origin === window.location.origin) {
|
|
1156
|
+
link.download = "";
|
|
1157
|
+
link.rel = "noopener";
|
|
1158
|
+
}
|
|
1159
|
+
else {
|
|
1160
|
+
link.target = "_blank";
|
|
1161
|
+
link.rel = "noreferrer noopener";
|
|
1162
|
+
}
|
|
778
1163
|
link.title = att.path || "";
|
|
779
1164
|
wrap.appendChild(link);
|
|
780
1165
|
});
|
|
781
|
-
|
|
1166
|
+
bodyWrapper.appendChild(wrap);
|
|
782
1167
|
}
|
|
1168
|
+
contentWrapper.appendChild(bodyWrapper);
|
|
783
1169
|
history.appendChild(container);
|
|
784
1170
|
});
|
|
1171
|
+
// Update scroll fade indicator after rendering
|
|
1172
|
+
updateScrollFade();
|
|
785
1173
|
}
|
|
786
1174
|
const MAX_REASON_LENGTH = 60;
|
|
787
1175
|
/**
|
|
@@ -814,7 +1202,10 @@ function summarizeReason(run) {
|
|
|
814
1202
|
const engine = (state.ticket_engine || {});
|
|
815
1203
|
const fullReason = getFullReason(run);
|
|
816
1204
|
currentReasonFull = fullReason;
|
|
817
|
-
const
|
|
1205
|
+
const reasonSummary = typeof run.reason_summary === "string" ? run.reason_summary : "";
|
|
1206
|
+
const useSummary = run.status === "paused" || run.status === "failed" || run.status === "stopped";
|
|
1207
|
+
const shortReason = (useSummary && reasonSummary ? reasonSummary : "") ||
|
|
1208
|
+
engine.reason ||
|
|
818
1209
|
run.error_message ||
|
|
819
1210
|
(engine.current_ticket ? `Working on ${engine.current_ticket}` : "") ||
|
|
820
1211
|
run.status ||
|
|
@@ -825,16 +1216,30 @@ function summarizeReason(run) {
|
|
|
825
1216
|
}
|
|
826
1217
|
return shortReason;
|
|
827
1218
|
}
|
|
828
|
-
async function loadTicketFiles() {
|
|
1219
|
+
async function loadTicketFiles(ctx) {
|
|
829
1220
|
const { tickets } = els();
|
|
830
|
-
|
|
1221
|
+
const isInitial = ticketListRefresh.getSignature() === null;
|
|
1222
|
+
if (tickets && isInitial) {
|
|
831
1223
|
tickets.textContent = "Loading tickets…";
|
|
1224
|
+
}
|
|
832
1225
|
try {
|
|
833
|
-
|
|
834
|
-
|
|
1226
|
+
await ticketListRefresh.refresh(async () => {
|
|
1227
|
+
const data = (await api("/api/flows/ticket_flow/tickets"));
|
|
1228
|
+
return {
|
|
1229
|
+
ticket_dir: data.ticket_dir,
|
|
1230
|
+
tickets: data.tickets,
|
|
1231
|
+
lint_errors: data.lint_errors,
|
|
1232
|
+
activeTicket: currentActiveTicket,
|
|
1233
|
+
flowStatus: currentFlowStatus,
|
|
1234
|
+
};
|
|
1235
|
+
}, { reason: ctx?.reason === "manual" ? "manual" : "background" });
|
|
835
1236
|
}
|
|
836
1237
|
catch (err) {
|
|
837
|
-
|
|
1238
|
+
ticketListRefresh.reset();
|
|
1239
|
+
ticketListCache = null;
|
|
1240
|
+
preserveScroll(tickets, () => {
|
|
1241
|
+
renderTickets(null);
|
|
1242
|
+
}, { restoreOnNextFrame: true });
|
|
838
1243
|
flash(err.message || "Failed to load tickets", "error");
|
|
839
1244
|
}
|
|
840
1245
|
}
|
|
@@ -843,10 +1248,9 @@ async function loadTicketFiles() {
|
|
|
843
1248
|
*/
|
|
844
1249
|
async function openTicketByIndex(index) {
|
|
845
1250
|
try {
|
|
846
|
-
const data = (await api(
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
openTicketEditor(ticket);
|
|
1251
|
+
const data = (await api(`/api/flows/ticket_flow/tickets/${index}`));
|
|
1252
|
+
if (data) {
|
|
1253
|
+
openTicketEditor(data);
|
|
850
1254
|
}
|
|
851
1255
|
else {
|
|
852
1256
|
flash(`Ticket TICKET-${String(index).padStart(3, "0")} not found`, "error");
|
|
@@ -856,26 +1260,45 @@ async function openTicketByIndex(index) {
|
|
|
856
1260
|
flash(`Failed to open ticket: ${err.message}`, "error");
|
|
857
1261
|
}
|
|
858
1262
|
}
|
|
859
|
-
async function loadDispatchHistory(runId) {
|
|
1263
|
+
async function loadDispatchHistory(runId, ctx) {
|
|
860
1264
|
const { history } = els();
|
|
861
|
-
|
|
862
|
-
history.textContent = "Loading dispatch history…";
|
|
1265
|
+
const runChanged = dispatchHistoryRunId !== runId;
|
|
863
1266
|
if (!runId) {
|
|
864
1267
|
renderDispatchHistory(null, null);
|
|
1268
|
+
dispatchHistoryRefresh.reset();
|
|
1269
|
+
dispatchHistoryRunId = null;
|
|
865
1270
|
return;
|
|
866
1271
|
}
|
|
1272
|
+
if (runChanged) {
|
|
1273
|
+
dispatchHistoryRunId = runId;
|
|
1274
|
+
dispatchHistoryRefresh.reset();
|
|
1275
|
+
}
|
|
1276
|
+
const isInitial = dispatchHistoryRefresh.getSignature() === null;
|
|
1277
|
+
if (history && isInitial) {
|
|
1278
|
+
history.textContent = "Loading dispatch history…";
|
|
1279
|
+
}
|
|
867
1280
|
try {
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
1281
|
+
await dispatchHistoryRefresh.refresh(async () => {
|
|
1282
|
+
const data = (await api(`/api/flows/${runId}/dispatch_history`));
|
|
1283
|
+
return {
|
|
1284
|
+
runId,
|
|
1285
|
+
history: data.history,
|
|
1286
|
+
};
|
|
1287
|
+
}, {
|
|
1288
|
+
reason: ctx?.reason === "manual" ? "manual" : "background",
|
|
1289
|
+
force: runChanged,
|
|
1290
|
+
});
|
|
871
1291
|
}
|
|
872
1292
|
catch (err) {
|
|
873
|
-
|
|
1293
|
+
dispatchHistoryRefresh.reset();
|
|
1294
|
+
preserveScroll(history, () => {
|
|
1295
|
+
renderDispatchHistory(runId, null);
|
|
1296
|
+
}, { restoreOnNextFrame: true });
|
|
874
1297
|
flash(err.message || "Failed to load dispatch history", "error");
|
|
875
1298
|
}
|
|
876
1299
|
}
|
|
877
|
-
async function loadTicketFlow() {
|
|
878
|
-
const { status, run, current, turn, elapsed, progress, reason, lastActivity, resumeBtn, bootstrapBtn, stopBtn, archiveBtn } = els();
|
|
1300
|
+
async function loadTicketFlow(ctx) {
|
|
1301
|
+
const { status, run, current, turn, elapsed, progress, reason, lastActivity, stalePill, reconnectBtn, workerStatus, workerPill, recoverBtn, resumeBtn, bootstrapBtn, stopBtn, archiveBtn, refreshBtn, } = els();
|
|
879
1302
|
if (!isRepoHealthy()) {
|
|
880
1303
|
if (status)
|
|
881
1304
|
statusPill(status, "error");
|
|
@@ -891,14 +1314,29 @@ async function loadTicketFlow() {
|
|
|
891
1314
|
progress.textContent = "–";
|
|
892
1315
|
if (lastActivity)
|
|
893
1316
|
lastActivity.textContent = "–";
|
|
1317
|
+
if (stalePill)
|
|
1318
|
+
stalePill.style.display = "none";
|
|
1319
|
+
if (reconnectBtn)
|
|
1320
|
+
reconnectBtn.style.display = "none";
|
|
1321
|
+
if (workerStatus)
|
|
1322
|
+
workerStatus.textContent = "–";
|
|
1323
|
+
if (workerPill)
|
|
1324
|
+
workerPill.style.display = "none";
|
|
1325
|
+
if (recoverBtn)
|
|
1326
|
+
recoverBtn.style.display = "none";
|
|
894
1327
|
if (reason)
|
|
895
1328
|
reason.textContent = "Repo offline or uninitialized.";
|
|
896
1329
|
setButtonsDisabled(true);
|
|
1330
|
+
setButtonLoading(refreshBtn, false);
|
|
897
1331
|
stopElapsedTimer();
|
|
898
1332
|
stopLastActivityTimer();
|
|
899
1333
|
disconnectEventStream();
|
|
900
1334
|
return;
|
|
901
1335
|
}
|
|
1336
|
+
const showRefreshIndicator = ticketFlowLoaded;
|
|
1337
|
+
if (showRefreshIndicator) {
|
|
1338
|
+
setButtonLoading(refreshBtn, true);
|
|
1339
|
+
}
|
|
902
1340
|
try {
|
|
903
1341
|
const runs = (await api("/api/flows/runs?flow_type=ticket_flow"));
|
|
904
1342
|
// Only consider the newest run - if it's terminal, flow is idle.
|
|
@@ -911,7 +1349,10 @@ async function loadTicketFlow() {
|
|
|
911
1349
|
currentFlowStatus = latest?.status || null;
|
|
912
1350
|
// Extract ticket engine state
|
|
913
1351
|
const ticketEngine = latest?.state?.ticket_engine;
|
|
914
|
-
|
|
1352
|
+
// The server now provides an effective current_ticket during in-flight steps.
|
|
1353
|
+
// Trust the API value even when null so we don't show stale DONE+WORKING between steps.
|
|
1354
|
+
const apiActiveTicket = ticketEngine?.current_ticket || null;
|
|
1355
|
+
currentActiveTicket = apiActiveTicket;
|
|
915
1356
|
const ticketTurns = ticketEngine?.ticket_turns ?? null;
|
|
916
1357
|
const totalTurns = ticketEngine?.total_turns ?? null;
|
|
917
1358
|
if (status)
|
|
@@ -922,7 +1363,7 @@ async function loadTicketFlow() {
|
|
|
922
1363
|
current.textContent = currentActiveTicket || "–";
|
|
923
1364
|
// Display turn counter
|
|
924
1365
|
if (turn) {
|
|
925
|
-
if (ticketTurns !== null && currentFlowStatus
|
|
1366
|
+
if (ticketTurns !== null && isFlowActiveStatus(currentFlowStatus)) {
|
|
926
1367
|
turn.textContent = `${ticketTurns}${totalTurns !== null ? ` (${totalTurns} total)` : ""}`;
|
|
927
1368
|
}
|
|
928
1369
|
else {
|
|
@@ -949,6 +1390,37 @@ async function loadTicketFlow() {
|
|
|
949
1390
|
(currentReasonFull && currentReasonFull.length > MAX_REASON_LENGTH));
|
|
950
1391
|
reason.classList.toggle("has-details", hasDetails);
|
|
951
1392
|
}
|
|
1393
|
+
lastKnownEventSeq = typeof latest?.last_event_seq === "number" ? latest.last_event_seq : null;
|
|
1394
|
+
if (currentRunId && typeof lastKnownEventSeq === "number") {
|
|
1395
|
+
setLastSeenSeq(currentRunId, lastKnownEventSeq);
|
|
1396
|
+
}
|
|
1397
|
+
updateLastActivityFromTimestamp(latest?.last_event_at || null);
|
|
1398
|
+
const isActive = latest?.status === "running" || latest?.status === "pending";
|
|
1399
|
+
const isStale = Boolean(isActive &&
|
|
1400
|
+
lastKnownEventAt &&
|
|
1401
|
+
Date.now() - lastKnownEventAt.getTime() > STALE_THRESHOLD_MS);
|
|
1402
|
+
if (stalePill)
|
|
1403
|
+
stalePill.style.display = isStale ? "" : "none";
|
|
1404
|
+
if (reconnectBtn) {
|
|
1405
|
+
reconnectBtn.style.display = isStale ? "" : "none";
|
|
1406
|
+
reconnectBtn.disabled = !currentRunId;
|
|
1407
|
+
}
|
|
1408
|
+
const worker = latest?.worker_health;
|
|
1409
|
+
const workerLabel = worker?.status
|
|
1410
|
+
? `${worker.status}${worker.pid ? ` (pid ${worker.pid})` : ""}`
|
|
1411
|
+
: "–";
|
|
1412
|
+
if (workerStatus)
|
|
1413
|
+
workerStatus.textContent = workerLabel;
|
|
1414
|
+
const workerDead = Boolean(isActive &&
|
|
1415
|
+
worker &&
|
|
1416
|
+
worker.is_alive === false &&
|
|
1417
|
+
worker.status !== "absent");
|
|
1418
|
+
if (workerPill)
|
|
1419
|
+
workerPill.style.display = workerDead ? "" : "none";
|
|
1420
|
+
if (recoverBtn) {
|
|
1421
|
+
recoverBtn.style.display = workerDead ? "" : "none";
|
|
1422
|
+
recoverBtn.disabled = !currentRunId;
|
|
1423
|
+
}
|
|
952
1424
|
if (resumeBtn) {
|
|
953
1425
|
resumeBtn.disabled = !latest?.id || latest.status !== "paused";
|
|
954
1426
|
}
|
|
@@ -956,7 +1428,7 @@ async function loadTicketFlow() {
|
|
|
956
1428
|
const stoppable = latest?.status === "running" || latest?.status === "pending";
|
|
957
1429
|
stopBtn.disabled = !latest?.id || !stoppable;
|
|
958
1430
|
}
|
|
959
|
-
await loadTicketFiles();
|
|
1431
|
+
await loadTicketFiles(ctx);
|
|
960
1432
|
// Calculate and display ticket progress (scoped to tickets container only)
|
|
961
1433
|
if (progress) {
|
|
962
1434
|
const ticketsContainer = document.getElementById("ticket-flow-tickets");
|
|
@@ -972,41 +1444,45 @@ async function loadTicketFlow() {
|
|
|
972
1444
|
// Connect/disconnect event stream based on flow status
|
|
973
1445
|
if (currentRunId && (latest?.status === "running" || latest?.status === "pending")) {
|
|
974
1446
|
// Only connect if not already connected to this run
|
|
975
|
-
|
|
1447
|
+
const isSameRun = eventSourceRunId === currentRunId;
|
|
1448
|
+
const isClosed = eventSource?.readyState === EventSource.CLOSED;
|
|
1449
|
+
if (!eventSource || !isSameRun || isClosed) {
|
|
976
1450
|
connectEventStream(currentRunId);
|
|
977
1451
|
startLastActivityTimer();
|
|
978
1452
|
}
|
|
979
1453
|
}
|
|
980
1454
|
else {
|
|
981
1455
|
disconnectEventStream();
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
lastActivity
|
|
985
|
-
|
|
1456
|
+
if (!lastKnownEventAt) {
|
|
1457
|
+
stopLastActivityTimer();
|
|
1458
|
+
if (lastActivity)
|
|
1459
|
+
lastActivity.textContent = "–";
|
|
1460
|
+
lastActivityTime = null;
|
|
1461
|
+
}
|
|
986
1462
|
}
|
|
987
1463
|
if (bootstrapBtn) {
|
|
988
1464
|
const busy = latest?.status === "running" || latest?.status === "pending";
|
|
989
|
-
// Disable if busy
|
|
990
|
-
bootstrapBtn.disabled = busy
|
|
1465
|
+
// Disable only if busy; bootstrap will create initial ticket when missing
|
|
1466
|
+
bootstrapBtn.disabled = busy;
|
|
991
1467
|
bootstrapBtn.textContent = busy ? "Running…" : "Start Ticket Flow";
|
|
992
|
-
|
|
993
|
-
bootstrapBtn.title = "Create a ticket first";
|
|
994
|
-
}
|
|
995
|
-
else {
|
|
996
|
-
bootstrapBtn.title = "";
|
|
997
|
-
}
|
|
1468
|
+
bootstrapBtn.title = busy ? "Ticket flow in progress" : "";
|
|
998
1469
|
}
|
|
999
1470
|
// Show restart button when flow is paused, stopping, or in terminal state (allows starting fresh)
|
|
1000
|
-
const { restartBtn } = els();
|
|
1471
|
+
const { restartBtn, overflowRestart } = els();
|
|
1001
1472
|
if (restartBtn) {
|
|
1002
1473
|
const isPaused = latest?.status === "paused";
|
|
1003
1474
|
const isStopping = latest?.status === "stopping";
|
|
1004
1475
|
const isTerminal = latest?.status === "completed" ||
|
|
1005
1476
|
latest?.status === "stopped" ||
|
|
1006
1477
|
latest?.status === "failed";
|
|
1007
|
-
const canRestart = (isPaused || isStopping || isTerminal
|
|
1478
|
+
const canRestart = (isPaused || isStopping || isTerminal || workerDead) &&
|
|
1479
|
+
ticketsExist &&
|
|
1480
|
+
Boolean(currentRunId);
|
|
1008
1481
|
restartBtn.style.display = canRestart ? "" : "none";
|
|
1009
1482
|
restartBtn.disabled = !canRestart;
|
|
1483
|
+
if (overflowRestart) {
|
|
1484
|
+
overflowRestart.style.display = canRestart ? "" : "none";
|
|
1485
|
+
}
|
|
1010
1486
|
}
|
|
1011
1487
|
// Show archive button when flow is paused, stopping, or in terminal state and has tickets
|
|
1012
1488
|
if (archiveBtn) {
|
|
@@ -1018,14 +1494,24 @@ async function loadTicketFlow() {
|
|
|
1018
1494
|
const canArchive = (isPaused || isStopping || isTerminal) && ticketsExist && Boolean(currentRunId);
|
|
1019
1495
|
archiveBtn.style.display = canArchive ? "" : "none";
|
|
1020
1496
|
archiveBtn.disabled = !canArchive;
|
|
1497
|
+
const { overflowArchive } = els();
|
|
1498
|
+
if (overflowArchive) {
|
|
1499
|
+
overflowArchive.style.display = canArchive ? "" : "none";
|
|
1500
|
+
}
|
|
1021
1501
|
}
|
|
1022
|
-
await loadDispatchHistory(currentRunId);
|
|
1502
|
+
await loadDispatchHistory(currentRunId, ctx);
|
|
1023
1503
|
}
|
|
1024
1504
|
catch (err) {
|
|
1025
1505
|
if (reason)
|
|
1026
1506
|
reason.textContent = err.message || "Ticket flow unavailable";
|
|
1027
1507
|
flash(err.message || "Failed to load ticket flow state", "error");
|
|
1028
1508
|
}
|
|
1509
|
+
finally {
|
|
1510
|
+
ticketFlowLoaded = true;
|
|
1511
|
+
if (showRefreshIndicator) {
|
|
1512
|
+
setButtonLoading(refreshBtn, false);
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1029
1515
|
}
|
|
1030
1516
|
async function bootstrapTicketFlow() {
|
|
1031
1517
|
const { bootstrapBtn } = els();
|
|
@@ -1035,13 +1521,9 @@ async function bootstrapTicketFlow() {
|
|
|
1035
1521
|
flash("Repo offline; cannot start ticket flow.", "error");
|
|
1036
1522
|
return;
|
|
1037
1523
|
}
|
|
1038
|
-
if (!ticketsExist) {
|
|
1039
|
-
flash("Create a ticket first before starting the flow.", "error");
|
|
1040
|
-
return;
|
|
1041
|
-
}
|
|
1042
1524
|
setButtonsDisabled(true);
|
|
1043
|
-
bootstrapBtn.textContent = "
|
|
1044
|
-
|
|
1525
|
+
bootstrapBtn.textContent = "Checking…";
|
|
1526
|
+
const startFlow = async () => {
|
|
1045
1527
|
const res = (await api("/api/flows/ticket_flow/bootstrap", {
|
|
1046
1528
|
method: "POST",
|
|
1047
1529
|
body: {},
|
|
@@ -1055,6 +1537,113 @@ async function bootstrapTicketFlow() {
|
|
|
1055
1537
|
clearLiveOutput(); // Clear output for new run
|
|
1056
1538
|
}
|
|
1057
1539
|
await loadTicketFlow();
|
|
1540
|
+
};
|
|
1541
|
+
const seedIssueFromGithub = async (issueRef) => {
|
|
1542
|
+
await api("/api/flows/ticket_flow/seed-issue", {
|
|
1543
|
+
method: "POST",
|
|
1544
|
+
body: { issue_ref: issueRef },
|
|
1545
|
+
});
|
|
1546
|
+
flash("ISSUE.md created from GitHub", "success");
|
|
1547
|
+
};
|
|
1548
|
+
const seedIssueFromPlan = async (planText) => {
|
|
1549
|
+
await api("/api/flows/ticket_flow/seed-issue", {
|
|
1550
|
+
method: "POST",
|
|
1551
|
+
body: { plan_text: planText },
|
|
1552
|
+
});
|
|
1553
|
+
flash("ISSUE.md created from your input", "success");
|
|
1554
|
+
};
|
|
1555
|
+
const promptIssueRef = async (repo) => {
|
|
1556
|
+
const message = repo
|
|
1557
|
+
? `Enter GitHub issue number or URL for ${repo}`
|
|
1558
|
+
: "Enter GitHub issue number or URL";
|
|
1559
|
+
const input = await inputModal(message, {
|
|
1560
|
+
placeholder: "#123 or https://github.com/org/repo/issues/123",
|
|
1561
|
+
confirmText: "Fetch issue",
|
|
1562
|
+
});
|
|
1563
|
+
const value = (input || "").trim();
|
|
1564
|
+
return value || null;
|
|
1565
|
+
};
|
|
1566
|
+
const promptPlanText = async () => {
|
|
1567
|
+
// Build a simple textarea modal dynamically to avoid new HTML templates.
|
|
1568
|
+
const overlay = document.createElement("div");
|
|
1569
|
+
overlay.className = "modal-overlay";
|
|
1570
|
+
overlay.hidden = true;
|
|
1571
|
+
const dialog = document.createElement("div");
|
|
1572
|
+
dialog.className = "modal-dialog";
|
|
1573
|
+
dialog.setAttribute("role", "dialog");
|
|
1574
|
+
dialog.setAttribute("aria-modal", "true");
|
|
1575
|
+
dialog.tabIndex = -1;
|
|
1576
|
+
const title = document.createElement("h3");
|
|
1577
|
+
title.textContent = "Describe the work";
|
|
1578
|
+
const textarea = document.createElement("textarea");
|
|
1579
|
+
textarea.placeholder = "Describe the scope/requirements to seed ISSUE.md";
|
|
1580
|
+
textarea.rows = 6;
|
|
1581
|
+
textarea.style.width = "100%";
|
|
1582
|
+
textarea.style.resize = "vertical";
|
|
1583
|
+
const actions = document.createElement("div");
|
|
1584
|
+
actions.className = "modal-actions";
|
|
1585
|
+
const cancel = document.createElement("button");
|
|
1586
|
+
cancel.className = "ghost";
|
|
1587
|
+
cancel.textContent = "Cancel";
|
|
1588
|
+
const submit = document.createElement("button");
|
|
1589
|
+
submit.className = "primary";
|
|
1590
|
+
submit.textContent = "Create ISSUE.md";
|
|
1591
|
+
actions.append(cancel, submit);
|
|
1592
|
+
dialog.append(title, textarea, actions);
|
|
1593
|
+
overlay.append(dialog);
|
|
1594
|
+
document.body.append(overlay);
|
|
1595
|
+
return await new Promise((resolve) => {
|
|
1596
|
+
let closeModal = null;
|
|
1597
|
+
const cleanup = () => {
|
|
1598
|
+
if (closeModal)
|
|
1599
|
+
closeModal();
|
|
1600
|
+
overlay.remove();
|
|
1601
|
+
};
|
|
1602
|
+
const finalize = (value) => {
|
|
1603
|
+
cleanup();
|
|
1604
|
+
resolve(value);
|
|
1605
|
+
};
|
|
1606
|
+
closeModal = openModal(overlay, {
|
|
1607
|
+
initialFocus: textarea,
|
|
1608
|
+
returnFocusTo: bootstrapBtn,
|
|
1609
|
+
onRequestClose: () => finalize(null),
|
|
1610
|
+
});
|
|
1611
|
+
submit.addEventListener("click", () => {
|
|
1612
|
+
finalize(textarea.value.trim() || null);
|
|
1613
|
+
});
|
|
1614
|
+
cancel.addEventListener("click", () => finalize(null));
|
|
1615
|
+
});
|
|
1616
|
+
};
|
|
1617
|
+
try {
|
|
1618
|
+
const check = (await api("/api/flows/ticket_flow/bootstrap-check", {
|
|
1619
|
+
method: "GET",
|
|
1620
|
+
}));
|
|
1621
|
+
if (check.status === "ready") {
|
|
1622
|
+
await startFlow();
|
|
1623
|
+
return;
|
|
1624
|
+
}
|
|
1625
|
+
if (check.status === "needs_issue") {
|
|
1626
|
+
if (check.github_available) {
|
|
1627
|
+
const issueRef = await promptIssueRef(check.repo);
|
|
1628
|
+
if (!issueRef) {
|
|
1629
|
+
flash("Bootstrap cancelled (no issue provided)", "info");
|
|
1630
|
+
return;
|
|
1631
|
+
}
|
|
1632
|
+
await seedIssueFromGithub(issueRef);
|
|
1633
|
+
}
|
|
1634
|
+
else {
|
|
1635
|
+
const planText = await promptPlanText();
|
|
1636
|
+
if (!planText) {
|
|
1637
|
+
flash("Bootstrap cancelled (no description provided)", "info");
|
|
1638
|
+
return;
|
|
1639
|
+
}
|
|
1640
|
+
await seedIssueFromPlan(planText);
|
|
1641
|
+
}
|
|
1642
|
+
await startFlow();
|
|
1643
|
+
return;
|
|
1644
|
+
}
|
|
1645
|
+
// Fallback: start normally
|
|
1646
|
+
await startFlow();
|
|
1058
1647
|
}
|
|
1059
1648
|
catch (err) {
|
|
1060
1649
|
flash(err.message || "Failed to start ticket flow", "error");
|
|
@@ -1091,6 +1680,17 @@ async function resumeTicketFlow() {
|
|
|
1091
1680
|
setButtonsDisabled(false);
|
|
1092
1681
|
}
|
|
1093
1682
|
}
|
|
1683
|
+
function reconnectTicketFlowStream() {
|
|
1684
|
+
if (!currentRunId) {
|
|
1685
|
+
flash("No ticket flow run to reconnect", "info");
|
|
1686
|
+
return;
|
|
1687
|
+
}
|
|
1688
|
+
const afterSeq = typeof lastKnownEventSeq === "number"
|
|
1689
|
+
? lastKnownEventSeq
|
|
1690
|
+
: getLastSeenSeq(currentRunId);
|
|
1691
|
+
connectEventStream(currentRunId, afterSeq ?? undefined);
|
|
1692
|
+
flash("Reconnecting event stream", "info");
|
|
1693
|
+
}
|
|
1094
1694
|
async function stopTicketFlow() {
|
|
1095
1695
|
const { stopBtn } = els();
|
|
1096
1696
|
if (!stopBtn)
|
|
@@ -1118,6 +1718,33 @@ async function stopTicketFlow() {
|
|
|
1118
1718
|
setButtonsDisabled(false);
|
|
1119
1719
|
}
|
|
1120
1720
|
}
|
|
1721
|
+
async function recoverTicketFlow() {
|
|
1722
|
+
const { recoverBtn } = els();
|
|
1723
|
+
if (!recoverBtn)
|
|
1724
|
+
return;
|
|
1725
|
+
if (!isRepoHealthy()) {
|
|
1726
|
+
flash("Repo offline; cannot recover ticket flow.", "error");
|
|
1727
|
+
return;
|
|
1728
|
+
}
|
|
1729
|
+
if (!currentRunId) {
|
|
1730
|
+
flash("No ticket flow run to recover", "info");
|
|
1731
|
+
return;
|
|
1732
|
+
}
|
|
1733
|
+
setButtonsDisabled(true);
|
|
1734
|
+
recoverBtn.textContent = "Recovering…";
|
|
1735
|
+
try {
|
|
1736
|
+
await api(`/api/flows/${currentRunId}/reconcile`, { method: "POST", body: {} });
|
|
1737
|
+
flash("Flow reconciled");
|
|
1738
|
+
await loadTicketFlow();
|
|
1739
|
+
}
|
|
1740
|
+
catch (err) {
|
|
1741
|
+
flash(err.message || "Failed to recover ticket flow", "error");
|
|
1742
|
+
}
|
|
1743
|
+
finally {
|
|
1744
|
+
recoverBtn.textContent = "Recover";
|
|
1745
|
+
setButtonsDisabled(false);
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1121
1748
|
async function restartTicketFlow() {
|
|
1122
1749
|
const { restartBtn } = els();
|
|
1123
1750
|
if (!restartBtn)
|
|
@@ -1130,7 +1757,8 @@ async function restartTicketFlow() {
|
|
|
1130
1757
|
flash("Create a ticket first before restarting the flow.", "error");
|
|
1131
1758
|
return;
|
|
1132
1759
|
}
|
|
1133
|
-
|
|
1760
|
+
const confirmed = await confirmModal("Restart ticket flow? This will stop the current run and start a new one.");
|
|
1761
|
+
if (!confirmed) {
|
|
1134
1762
|
return;
|
|
1135
1763
|
}
|
|
1136
1764
|
setButtonsDisabled(true);
|
|
@@ -1170,7 +1798,8 @@ async function archiveTicketFlow() {
|
|
|
1170
1798
|
flash("No ticket flow run to archive", "info");
|
|
1171
1799
|
return;
|
|
1172
1800
|
}
|
|
1173
|
-
|
|
1801
|
+
const confirmed = await confirmModal("Archive all tickets from this flow? They will be moved to the run's artifact directory.");
|
|
1802
|
+
if (!confirmed) {
|
|
1174
1803
|
return;
|
|
1175
1804
|
}
|
|
1176
1805
|
setButtonsDisabled(true);
|
|
@@ -1191,7 +1820,7 @@ async function archiveTicketFlow() {
|
|
|
1191
1820
|
currentActiveTicket = null;
|
|
1192
1821
|
currentReasonFull = null;
|
|
1193
1822
|
// Reset all UI elements to idle state directly (avoid re-fetching stale data)
|
|
1194
|
-
const { status, run, current, turn, elapsed, progress, lastActivity, bootstrapBtn, resumeBtn, stopBtn, restartBtn } = els();
|
|
1823
|
+
const { status, run, current, turn, elapsed, progress, lastActivity, stalePill, reconnectBtn, workerStatus, workerPill, recoverBtn, bootstrapBtn, resumeBtn, stopBtn, restartBtn, archiveBtn } = els();
|
|
1195
1824
|
if (status)
|
|
1196
1825
|
statusPill(status, "idle");
|
|
1197
1826
|
if (run)
|
|
@@ -1206,6 +1835,16 @@ async function archiveTicketFlow() {
|
|
|
1206
1835
|
progress.textContent = "–";
|
|
1207
1836
|
if (lastActivity)
|
|
1208
1837
|
lastActivity.textContent = "–";
|
|
1838
|
+
if (stalePill)
|
|
1839
|
+
stalePill.style.display = "none";
|
|
1840
|
+
if (reconnectBtn)
|
|
1841
|
+
reconnectBtn.style.display = "none";
|
|
1842
|
+
if (workerStatus)
|
|
1843
|
+
workerStatus.textContent = "–";
|
|
1844
|
+
if (workerPill)
|
|
1845
|
+
workerPill.style.display = "none";
|
|
1846
|
+
if (recoverBtn)
|
|
1847
|
+
recoverBtn.style.display = "none";
|
|
1209
1848
|
if (reason) {
|
|
1210
1849
|
reason.textContent = "No ticket flow run yet.";
|
|
1211
1850
|
reason.classList.remove("has-details");
|
|
@@ -1228,8 +1867,13 @@ async function archiveTicketFlow() {
|
|
|
1228
1867
|
stopBtn.disabled = true;
|
|
1229
1868
|
if (restartBtn)
|
|
1230
1869
|
restartBtn.style.display = "none";
|
|
1870
|
+
const { overflowRestart, overflowArchive } = els();
|
|
1871
|
+
if (overflowRestart)
|
|
1872
|
+
overflowRestart.style.display = "none";
|
|
1231
1873
|
if (archiveBtn)
|
|
1232
1874
|
archiveBtn.style.display = "none";
|
|
1875
|
+
if (overflowArchive)
|
|
1876
|
+
overflowArchive.style.display = "none";
|
|
1233
1877
|
// Refresh inbox badge and ticket list (tickets were archived/moved)
|
|
1234
1878
|
void refreshBell();
|
|
1235
1879
|
await loadTicketFiles();
|
|
@@ -1245,7 +1889,7 @@ async function archiveTicketFlow() {
|
|
|
1245
1889
|
}
|
|
1246
1890
|
}
|
|
1247
1891
|
export function initTicketFlow() {
|
|
1248
|
-
const { card, bootstrapBtn, resumeBtn, refreshBtn, stopBtn, restartBtn, archiveBtn } = els();
|
|
1892
|
+
const { card, bootstrapBtn, resumeBtn, refreshBtn, stopBtn, restartBtn, archiveBtn, reconnectBtn, recoverBtn } = els();
|
|
1249
1893
|
if (!card || card.dataset.ticketInitialized === "1")
|
|
1250
1894
|
return;
|
|
1251
1895
|
card.dataset.ticketInitialized = "1";
|
|
@@ -1259,14 +1903,73 @@ export function initTicketFlow() {
|
|
|
1259
1903
|
restartBtn.addEventListener("click", restartTicketFlow);
|
|
1260
1904
|
if (archiveBtn)
|
|
1261
1905
|
archiveBtn.addEventListener("click", archiveTicketFlow);
|
|
1906
|
+
if (reconnectBtn)
|
|
1907
|
+
reconnectBtn.addEventListener("click", reconnectTicketFlowStream);
|
|
1908
|
+
if (recoverBtn)
|
|
1909
|
+
recoverBtn.addEventListener("click", recoverTicketFlow);
|
|
1262
1910
|
if (refreshBtn)
|
|
1263
|
-
refreshBtn.addEventListener("click",
|
|
1911
|
+
refreshBtn.addEventListener("click", () => {
|
|
1912
|
+
void loadTicketFlow({ reason: "manual" });
|
|
1913
|
+
});
|
|
1914
|
+
const { overflowToggle, overflowDropdown, overflowNew, overflowRestart, overflowArchive } = els();
|
|
1915
|
+
if (overflowToggle && overflowDropdown) {
|
|
1916
|
+
const toggleMenu = (e) => {
|
|
1917
|
+
e.preventDefault();
|
|
1918
|
+
e.stopPropagation();
|
|
1919
|
+
const isHidden = overflowDropdown.classList.contains("hidden");
|
|
1920
|
+
overflowDropdown.classList.toggle("hidden", !isHidden);
|
|
1921
|
+
};
|
|
1922
|
+
const closeMenu = () => overflowDropdown.classList.add("hidden");
|
|
1923
|
+
overflowToggle.addEventListener("pointerdown", toggleMenu);
|
|
1924
|
+
overflowToggle.addEventListener("click", (e) => {
|
|
1925
|
+
e.preventDefault(); // swallow synthetic click after pointerdown
|
|
1926
|
+
});
|
|
1927
|
+
overflowToggle.addEventListener("keydown", (e) => {
|
|
1928
|
+
if (e.key === "Enter" || e.key === " ")
|
|
1929
|
+
toggleMenu(e);
|
|
1930
|
+
});
|
|
1931
|
+
// Close on outside click
|
|
1932
|
+
document.addEventListener("pointerdown", (e) => {
|
|
1933
|
+
if (!overflowDropdown.classList.contains("hidden") &&
|
|
1934
|
+
!overflowToggle.contains(e.target) &&
|
|
1935
|
+
!overflowDropdown.contains(e.target)) {
|
|
1936
|
+
closeMenu();
|
|
1937
|
+
}
|
|
1938
|
+
});
|
|
1939
|
+
}
|
|
1940
|
+
if (overflowNew) {
|
|
1941
|
+
overflowNew.addEventListener("click", () => {
|
|
1942
|
+
const newBtn = document.getElementById("ticket-new-btn");
|
|
1943
|
+
newBtn?.click();
|
|
1944
|
+
overflowDropdown?.classList.add("hidden");
|
|
1945
|
+
});
|
|
1946
|
+
}
|
|
1947
|
+
if (overflowRestart) {
|
|
1948
|
+
overflowRestart.addEventListener("click", () => {
|
|
1949
|
+
void restartTicketFlow();
|
|
1950
|
+
overflowDropdown?.classList.add("hidden");
|
|
1951
|
+
});
|
|
1952
|
+
}
|
|
1953
|
+
if (overflowArchive) {
|
|
1954
|
+
overflowArchive.addEventListener("click", () => {
|
|
1955
|
+
void archiveTicketFlow();
|
|
1956
|
+
overflowDropdown?.classList.add("hidden");
|
|
1957
|
+
});
|
|
1958
|
+
}
|
|
1264
1959
|
// Initialize reason click handler for modal
|
|
1265
1960
|
initReasonModal();
|
|
1266
1961
|
// Initialize live output panel
|
|
1267
1962
|
initLiveOutputPanel();
|
|
1268
1963
|
// Initialize dispatch panel toggle for medium screens
|
|
1269
1964
|
initDispatchPanelToggle();
|
|
1965
|
+
// Set up scroll listeners for fade indicator
|
|
1966
|
+
const ticketList = document.getElementById("ticket-flow-tickets");
|
|
1967
|
+
const dispatchHistory = document.getElementById("ticket-dispatch-history");
|
|
1968
|
+
[ticketList, dispatchHistory].forEach((el) => {
|
|
1969
|
+
if (el) {
|
|
1970
|
+
el.addEventListener("scroll", updateScrollFade, { passive: true });
|
|
1971
|
+
}
|
|
1972
|
+
});
|
|
1270
1973
|
const newThreadBtn = document.getElementById("ticket-chat-new-thread");
|
|
1271
1974
|
if (newThreadBtn) {
|
|
1272
1975
|
newThreadBtn.addEventListener("click", async () => {
|
|
@@ -1278,8 +1981,10 @@ export function initTicketFlow() {
|
|
|
1278
1981
|
initTicketEditor();
|
|
1279
1982
|
loadTicketFlow();
|
|
1280
1983
|
registerAutoRefresh("ticket-flow", {
|
|
1281
|
-
callback:
|
|
1282
|
-
|
|
1984
|
+
callback: async (ctx) => {
|
|
1985
|
+
await loadTicketFlow(ctx);
|
|
1986
|
+
},
|
|
1987
|
+
tabId: "tickets",
|
|
1283
1988
|
interval: CONSTANTS.UI?.AUTO_REFRESH_INTERVAL ||
|
|
1284
1989
|
15000,
|
|
1285
1990
|
refreshOnActivation: true,
|
|
@@ -1295,6 +2000,24 @@ export function initTicketFlow() {
|
|
|
1295
2000
|
subscribe("tickets:updated", () => {
|
|
1296
2001
|
void loadTicketFiles();
|
|
1297
2002
|
});
|
|
2003
|
+
// Update selection when editor opens a ticket
|
|
2004
|
+
subscribe("ticket-editor:opened", (payload) => {
|
|
2005
|
+
const data = payload;
|
|
2006
|
+
if (data?.path) {
|
|
2007
|
+
updateSelectedTicket(data.path);
|
|
2008
|
+
return;
|
|
2009
|
+
}
|
|
2010
|
+
if (data?.index != null && ticketListCache?.tickets?.length) {
|
|
2011
|
+
const match = ticketListCache.tickets.find((ticket) => ticket.index === data.index);
|
|
2012
|
+
if (match?.path) {
|
|
2013
|
+
updateSelectedTicket(match.path);
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
});
|
|
2017
|
+
// Clear selection when editor is closed
|
|
2018
|
+
subscribe("ticket-editor:closed", () => {
|
|
2019
|
+
updateSelectedTicket(null);
|
|
2020
|
+
});
|
|
1298
2021
|
// Handle browser navigation (back/forward)
|
|
1299
2022
|
window.addEventListener("popstate", () => {
|
|
1300
2023
|
const params = getUrlParams();
|