codex-autorunner 0.1.2__py3-none-any.whl → 1.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codex_autorunner/__init__.py +12 -1
- codex_autorunner/__main__.py +4 -0
- codex_autorunner/agents/codex/harness.py +1 -1
- codex_autorunner/agents/opencode/client.py +68 -35
- codex_autorunner/agents/opencode/constants.py +3 -0
- codex_autorunner/agents/opencode/harness.py +6 -1
- codex_autorunner/agents/opencode/logging.py +21 -5
- codex_autorunner/agents/opencode/run_prompt.py +1 -0
- codex_autorunner/agents/opencode/runtime.py +176 -47
- codex_autorunner/agents/opencode/supervisor.py +36 -48
- codex_autorunner/agents/registry.py +155 -8
- codex_autorunner/api.py +25 -0
- codex_autorunner/bootstrap.py +22 -37
- codex_autorunner/cli.py +5 -1156
- codex_autorunner/codex_cli.py +20 -84
- codex_autorunner/core/__init__.py +4 -0
- codex_autorunner/core/about_car.py +49 -32
- codex_autorunner/core/adapter_utils.py +21 -0
- codex_autorunner/core/app_server_ids.py +59 -0
- codex_autorunner/core/app_server_logging.py +7 -3
- codex_autorunner/core/app_server_prompts.py +27 -260
- codex_autorunner/core/app_server_threads.py +26 -28
- codex_autorunner/core/app_server_utils.py +165 -0
- codex_autorunner/core/archive.py +349 -0
- codex_autorunner/core/codex_runner.py +12 -2
- codex_autorunner/core/config.py +587 -103
- codex_autorunner/core/docs.py +10 -2
- codex_autorunner/core/drafts.py +136 -0
- codex_autorunner/core/engine.py +1531 -866
- codex_autorunner/core/exceptions.py +4 -0
- codex_autorunner/core/flows/__init__.py +25 -0
- codex_autorunner/core/flows/controller.py +202 -0
- codex_autorunner/core/flows/definition.py +82 -0
- codex_autorunner/core/flows/models.py +88 -0
- codex_autorunner/core/flows/reasons.py +52 -0
- codex_autorunner/core/flows/reconciler.py +131 -0
- codex_autorunner/core/flows/runtime.py +382 -0
- codex_autorunner/core/flows/store.py +568 -0
- codex_autorunner/core/flows/transition.py +138 -0
- codex_autorunner/core/flows/ux_helpers.py +257 -0
- codex_autorunner/core/flows/worker_process.py +242 -0
- codex_autorunner/core/git_utils.py +62 -0
- codex_autorunner/core/hub.py +136 -16
- codex_autorunner/core/locks.py +4 -0
- codex_autorunner/core/notifications.py +14 -2
- codex_autorunner/core/ports/__init__.py +28 -0
- codex_autorunner/core/ports/agent_backend.py +150 -0
- codex_autorunner/core/ports/backend_orchestrator.py +41 -0
- codex_autorunner/core/ports/run_event.py +91 -0
- codex_autorunner/core/prompt.py +15 -7
- codex_autorunner/core/redaction.py +29 -0
- codex_autorunner/core/review_context.py +5 -8
- codex_autorunner/core/run_index.py +6 -0
- codex_autorunner/core/runner_process.py +5 -2
- codex_autorunner/core/state.py +0 -88
- codex_autorunner/core/state_roots.py +57 -0
- codex_autorunner/core/supervisor_protocol.py +15 -0
- codex_autorunner/core/supervisor_utils.py +67 -0
- codex_autorunner/core/text_delta_coalescer.py +54 -0
- codex_autorunner/core/ticket_linter_cli.py +201 -0
- codex_autorunner/core/ticket_manager_cli.py +432 -0
- codex_autorunner/core/update.py +24 -16
- codex_autorunner/core/update_paths.py +28 -0
- codex_autorunner/core/update_runner.py +2 -0
- codex_autorunner/core/usage.py +164 -12
- codex_autorunner/core/utils.py +120 -11
- codex_autorunner/discovery.py +2 -4
- codex_autorunner/flows/review/__init__.py +17 -0
- codex_autorunner/{core/review.py → flows/review/service.py} +15 -10
- codex_autorunner/flows/ticket_flow/__init__.py +3 -0
- codex_autorunner/flows/ticket_flow/definition.py +98 -0
- codex_autorunner/integrations/agents/__init__.py +17 -0
- codex_autorunner/integrations/agents/backend_orchestrator.py +284 -0
- codex_autorunner/integrations/agents/codex_adapter.py +90 -0
- codex_autorunner/integrations/agents/codex_backend.py +448 -0
- codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
- codex_autorunner/integrations/agents/opencode_backend.py +598 -0
- codex_autorunner/integrations/agents/runner.py +91 -0
- codex_autorunner/integrations/agents/wiring.py +271 -0
- codex_autorunner/integrations/app_server/client.py +583 -152
- codex_autorunner/integrations/app_server/env.py +2 -107
- codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
- codex_autorunner/integrations/app_server/supervisor.py +59 -33
- codex_autorunner/integrations/telegram/adapter.py +204 -165
- codex_autorunner/integrations/telegram/api_schemas.py +120 -0
- codex_autorunner/integrations/telegram/config.py +221 -0
- codex_autorunner/integrations/telegram/constants.py +17 -2
- codex_autorunner/integrations/telegram/dispatch.py +17 -0
- codex_autorunner/integrations/telegram/doctor.py +47 -0
- codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -4
- codex_autorunner/integrations/telegram/handlers/commands/__init__.py +2 -0
- codex_autorunner/integrations/telegram/handlers/commands/execution.py +53 -57
- codex_autorunner/integrations/telegram/handlers/commands/files.py +2 -6
- codex_autorunner/integrations/telegram/handlers/commands/flows.py +1364 -0
- codex_autorunner/integrations/telegram/handlers/commands/formatting.py +1 -1
- codex_autorunner/integrations/telegram/handlers/commands/github.py +41 -582
- codex_autorunner/integrations/telegram/handlers/commands/workspace.py +8 -8
- codex_autorunner/integrations/telegram/handlers/commands_runtime.py +137 -478
- codex_autorunner/integrations/telegram/handlers/commands_spec.py +17 -4
- codex_autorunner/integrations/telegram/handlers/messages.py +121 -9
- codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
- codex_autorunner/integrations/telegram/helpers.py +111 -16
- codex_autorunner/integrations/telegram/outbox.py +208 -37
- codex_autorunner/integrations/telegram/progress_stream.py +3 -10
- codex_autorunner/integrations/telegram/service.py +221 -42
- codex_autorunner/integrations/telegram/state.py +100 -2
- codex_autorunner/integrations/telegram/ticket_flow_bridge.py +611 -0
- codex_autorunner/integrations/telegram/transport.py +39 -4
- codex_autorunner/integrations/telegram/trigger_mode.py +53 -0
- codex_autorunner/manifest.py +2 -0
- codex_autorunner/plugin_api.py +22 -0
- codex_autorunner/routes/__init__.py +37 -67
- codex_autorunner/routes/agents.py +2 -137
- codex_autorunner/routes/analytics.py +3 -0
- codex_autorunner/routes/app_server.py +2 -131
- codex_autorunner/routes/base.py +2 -624
- codex_autorunner/routes/file_chat.py +7 -0
- codex_autorunner/routes/flows.py +7 -0
- codex_autorunner/routes/messages.py +7 -0
- codex_autorunner/routes/repos.py +2 -196
- codex_autorunner/routes/review.py +2 -147
- codex_autorunner/routes/sessions.py +2 -175
- codex_autorunner/routes/settings.py +2 -168
- codex_autorunner/routes/shared.py +2 -275
- codex_autorunner/routes/system.py +4 -188
- codex_autorunner/routes/usage.py +3 -0
- codex_autorunner/routes/voice.py +2 -119
- codex_autorunner/routes/workspace.py +3 -0
- codex_autorunner/server.py +3 -2
- codex_autorunner/static/agentControls.js +41 -11
- codex_autorunner/static/agentEvents.js +248 -0
- codex_autorunner/static/app.js +35 -24
- codex_autorunner/static/archive.js +826 -0
- codex_autorunner/static/archiveApi.js +37 -0
- codex_autorunner/static/autoRefresh.js +36 -8
- codex_autorunner/static/bootstrap.js +1 -0
- codex_autorunner/static/bus.js +1 -0
- codex_autorunner/static/cache.js +1 -0
- codex_autorunner/static/constants.js +20 -4
- codex_autorunner/static/dashboard.js +344 -325
- codex_autorunner/static/diffRenderer.js +37 -0
- codex_autorunner/static/docChatCore.js +324 -0
- codex_autorunner/static/docChatStorage.js +65 -0
- codex_autorunner/static/docChatVoice.js +65 -0
- codex_autorunner/static/docEditor.js +133 -0
- codex_autorunner/static/env.js +1 -0
- codex_autorunner/static/eventSummarizer.js +166 -0
- codex_autorunner/static/fileChat.js +182 -0
- codex_autorunner/static/health.js +155 -0
- codex_autorunner/static/hub.js +126 -185
- codex_autorunner/static/index.html +839 -863
- codex_autorunner/static/liveUpdates.js +1 -0
- codex_autorunner/static/loader.js +1 -0
- codex_autorunner/static/messages.js +873 -0
- codex_autorunner/static/mobileCompact.js +2 -1
- codex_autorunner/static/preserve.js +17 -0
- codex_autorunner/static/settings.js +149 -217
- codex_autorunner/static/smartRefresh.js +52 -0
- codex_autorunner/static/styles.css +8850 -3876
- codex_autorunner/static/tabs.js +175 -11
- codex_autorunner/static/terminal.js +32 -0
- codex_autorunner/static/terminalManager.js +34 -59
- codex_autorunner/static/ticketChatActions.js +333 -0
- codex_autorunner/static/ticketChatEvents.js +16 -0
- codex_autorunner/static/ticketChatStorage.js +16 -0
- codex_autorunner/static/ticketChatStream.js +264 -0
- codex_autorunner/static/ticketEditor.js +844 -0
- codex_autorunner/static/ticketVoice.js +9 -0
- codex_autorunner/static/tickets.js +1988 -0
- codex_autorunner/static/utils.js +43 -3
- codex_autorunner/static/voice.js +1 -0
- codex_autorunner/static/workspace.js +765 -0
- codex_autorunner/static/workspaceApi.js +53 -0
- codex_autorunner/static/workspaceFileBrowser.js +504 -0
- codex_autorunner/surfaces/__init__.py +5 -0
- codex_autorunner/surfaces/cli/__init__.py +6 -0
- codex_autorunner/surfaces/cli/cli.py +1224 -0
- codex_autorunner/surfaces/cli/codex_cli.py +20 -0
- codex_autorunner/surfaces/telegram/__init__.py +3 -0
- codex_autorunner/surfaces/web/__init__.py +1 -0
- codex_autorunner/surfaces/web/app.py +2019 -0
- codex_autorunner/surfaces/web/hub_jobs.py +192 -0
- codex_autorunner/surfaces/web/middleware.py +587 -0
- codex_autorunner/surfaces/web/pty_session.py +370 -0
- codex_autorunner/surfaces/web/review.py +6 -0
- codex_autorunner/surfaces/web/routes/__init__.py +78 -0
- codex_autorunner/surfaces/web/routes/agents.py +138 -0
- codex_autorunner/surfaces/web/routes/analytics.py +277 -0
- codex_autorunner/surfaces/web/routes/app_server.py +132 -0
- codex_autorunner/surfaces/web/routes/archive.py +357 -0
- codex_autorunner/surfaces/web/routes/base.py +615 -0
- codex_autorunner/surfaces/web/routes/file_chat.py +836 -0
- codex_autorunner/surfaces/web/routes/flows.py +1164 -0
- codex_autorunner/surfaces/web/routes/messages.py +459 -0
- codex_autorunner/surfaces/web/routes/repos.py +197 -0
- codex_autorunner/surfaces/web/routes/review.py +148 -0
- codex_autorunner/surfaces/web/routes/sessions.py +176 -0
- codex_autorunner/surfaces/web/routes/settings.py +169 -0
- codex_autorunner/surfaces/web/routes/shared.py +280 -0
- codex_autorunner/surfaces/web/routes/system.py +196 -0
- codex_autorunner/surfaces/web/routes/usage.py +89 -0
- codex_autorunner/surfaces/web/routes/voice.py +120 -0
- codex_autorunner/surfaces/web/routes/workspace.py +271 -0
- codex_autorunner/surfaces/web/runner_manager.py +25 -0
- codex_autorunner/surfaces/web/schemas.py +417 -0
- codex_autorunner/surfaces/web/static_assets.py +490 -0
- codex_autorunner/surfaces/web/static_refresh.py +86 -0
- codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
- codex_autorunner/tickets/__init__.py +27 -0
- codex_autorunner/tickets/agent_pool.py +399 -0
- codex_autorunner/tickets/files.py +89 -0
- codex_autorunner/tickets/frontmatter.py +55 -0
- codex_autorunner/tickets/lint.py +102 -0
- codex_autorunner/tickets/models.py +97 -0
- codex_autorunner/tickets/outbox.py +244 -0
- codex_autorunner/tickets/replies.py +179 -0
- codex_autorunner/tickets/runner.py +881 -0
- codex_autorunner/tickets/spec_ingest.py +77 -0
- codex_autorunner/web/__init__.py +5 -1
- codex_autorunner/web/app.py +2 -1771
- codex_autorunner/web/hub_jobs.py +2 -191
- codex_autorunner/web/middleware.py +2 -587
- codex_autorunner/web/pty_session.py +2 -369
- codex_autorunner/web/runner_manager.py +2 -24
- codex_autorunner/web/schemas.py +2 -396
- codex_autorunner/web/static_assets.py +4 -484
- codex_autorunner/web/static_refresh.py +2 -85
- codex_autorunner/web/terminal_sessions.py +2 -77
- codex_autorunner/workspace/__init__.py +40 -0
- codex_autorunner/workspace/paths.py +335 -0
- codex_autorunner-1.1.0.dist-info/METADATA +154 -0
- codex_autorunner-1.1.0.dist-info/RECORD +308 -0
- {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/WHEEL +1 -1
- codex_autorunner/agents/execution/policy.py +0 -292
- codex_autorunner/agents/factory.py +0 -52
- codex_autorunner/agents/orchestrator.py +0 -358
- codex_autorunner/core/doc_chat.py +0 -1446
- codex_autorunner/core/snapshot.py +0 -580
- codex_autorunner/integrations/github/chatops.py +0 -268
- codex_autorunner/integrations/github/pr_flow.py +0 -1314
- codex_autorunner/routes/docs.py +0 -381
- codex_autorunner/routes/github.py +0 -327
- codex_autorunner/routes/runs.py +0 -250
- codex_autorunner/spec_ingest.py +0 -812
- codex_autorunner/static/docChatActions.js +0 -287
- codex_autorunner/static/docChatEvents.js +0 -300
- codex_autorunner/static/docChatRender.js +0 -205
- codex_autorunner/static/docChatStream.js +0 -361
- codex_autorunner/static/docs.js +0 -20
- codex_autorunner/static/docsClipboard.js +0 -69
- codex_autorunner/static/docsCrud.js +0 -257
- codex_autorunner/static/docsDocUpdates.js +0 -62
- codex_autorunner/static/docsDrafts.js +0 -16
- codex_autorunner/static/docsElements.js +0 -69
- codex_autorunner/static/docsInit.js +0 -285
- codex_autorunner/static/docsParse.js +0 -160
- codex_autorunner/static/docsSnapshot.js +0 -87
- codex_autorunner/static/docsSpecIngest.js +0 -263
- codex_autorunner/static/docsState.js +0 -127
- codex_autorunner/static/docsThreadRegistry.js +0 -44
- codex_autorunner/static/docsUi.js +0 -153
- codex_autorunner/static/docsVoice.js +0 -56
- codex_autorunner/static/github.js +0 -504
- codex_autorunner/static/logs.js +0 -678
- codex_autorunner/static/review.js +0 -157
- codex_autorunner/static/runs.js +0 -418
- codex_autorunner/static/snapshot.js +0 -124
- codex_autorunner/static/state.js +0 -94
- codex_autorunner/static/todoPreview.js +0 -27
- codex_autorunner/workspace.py +0 -16
- codex_autorunner-0.1.2.dist-info/METADATA +0 -249
- codex_autorunner-0.1.2.dist-info/RECORD +0 -222
- /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
- {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/entry_points.txt +0 -0
- {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/licenses/LICENSE +0 -0
- {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,1988 @@
|
|
|
1
|
+
// GENERATED FILE - do not edit directly. Source: static_src/
|
|
2
|
+
import { api, flash, getUrlParams, resolvePath, statusPill, getAuthToken, openModal, inputModal, setButtonLoading, } from "./utils.js";
|
|
3
|
+
// Note: activateTab removed - header now used for collapse, not inbox navigation
|
|
4
|
+
import { registerAutoRefresh } from "./autoRefresh.js";
|
|
5
|
+
import { CONSTANTS } from "./constants.js";
|
|
6
|
+
import { subscribe } from "./bus.js";
|
|
7
|
+
import { isRepoHealthy } from "./health.js";
|
|
8
|
+
import { closeTicketEditor, initTicketEditor, openTicketEditor } from "./ticketEditor.js";
|
|
9
|
+
import { parseAppServerEvent } from "./agentEvents.js";
|
|
10
|
+
import { summarizeEvents, renderCompactSummary, COMPACT_MAX_TEXT_LENGTH } from "./eventSummarizer.js";
|
|
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
|
+
}
|
|
48
|
+
let currentRunId = null;
|
|
49
|
+
let ticketsExist = false;
|
|
50
|
+
let currentActiveTicket = null;
|
|
51
|
+
let currentFlowStatus = null;
|
|
52
|
+
let selectedTicketPath = null;
|
|
53
|
+
let elapsedTimerId = null;
|
|
54
|
+
let flowStartedAt = null;
|
|
55
|
+
let eventSource = null;
|
|
56
|
+
let eventSourceRunId = null;
|
|
57
|
+
let lastActivityTime = null;
|
|
58
|
+
let lastActivityTimerId = null;
|
|
59
|
+
let lastKnownEventSeq = null;
|
|
60
|
+
let lastKnownEventAt = null;
|
|
61
|
+
let liveOutputDetailExpanded = false; // Start with summary view, one click for full
|
|
62
|
+
let liveOutputBuffer = [];
|
|
63
|
+
const MAX_OUTPUT_LINES = 200;
|
|
64
|
+
const LIVE_EVENT_MAX = 50;
|
|
65
|
+
let liveOutputEvents = [];
|
|
66
|
+
let liveOutputEventIndex = {};
|
|
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
|
+
}
|
|
78
|
+
// Dispatch panel collapse state (persisted to localStorage)
|
|
79
|
+
const DISPATCH_PANEL_COLLAPSED_KEY = "car-dispatch-panel-collapsed";
|
|
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;
|
|
84
|
+
// Throttling state
|
|
85
|
+
let liveOutputRenderPending = false;
|
|
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
|
+
});
|
|
135
|
+
function scheduleLiveOutputRender() {
|
|
136
|
+
if (liveOutputRenderPending)
|
|
137
|
+
return;
|
|
138
|
+
liveOutputRenderPending = true;
|
|
139
|
+
requestAnimationFrame(() => {
|
|
140
|
+
renderLiveOutputView();
|
|
141
|
+
liveOutputRenderPending = false;
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
function scheduleLiveOutputTextUpdate() {
|
|
145
|
+
if (liveOutputTextPending)
|
|
146
|
+
return;
|
|
147
|
+
liveOutputTextPending = true;
|
|
148
|
+
requestAnimationFrame(() => {
|
|
149
|
+
const outputEl = document.getElementById("ticket-live-output-text");
|
|
150
|
+
if (outputEl) {
|
|
151
|
+
const newText = liveOutputBuffer.join("\n");
|
|
152
|
+
if (outputEl.textContent !== newText) {
|
|
153
|
+
outputEl.textContent = newText;
|
|
154
|
+
}
|
|
155
|
+
// Auto-scroll to bottom when detail view is showing
|
|
156
|
+
const detailEl = document.getElementById("ticket-live-output-detail");
|
|
157
|
+
if (detailEl && liveOutputDetailExpanded) {
|
|
158
|
+
detailEl.scrollTop = detailEl.scrollHeight;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
liveOutputTextPending = false;
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Initialize dispatch panel collapse state from localStorage
|
|
166
|
+
*/
|
|
167
|
+
function initDispatchPanelToggle() {
|
|
168
|
+
const { dispatchPanel, dispatchPanelToggle } = els();
|
|
169
|
+
if (!dispatchPanel || !dispatchPanelToggle)
|
|
170
|
+
return;
|
|
171
|
+
// Restore collapsed state from localStorage
|
|
172
|
+
const stored = localStorage.getItem(DISPATCH_PANEL_COLLAPSED_KEY);
|
|
173
|
+
dispatchPanelCollapsed = stored === "true";
|
|
174
|
+
if (dispatchPanelCollapsed) {
|
|
175
|
+
dispatchPanel.classList.add("collapsed");
|
|
176
|
+
}
|
|
177
|
+
// Handle toggle click
|
|
178
|
+
dispatchPanelToggle.addEventListener("click", () => {
|
|
179
|
+
dispatchPanelCollapsed = !dispatchPanelCollapsed;
|
|
180
|
+
dispatchPanel.classList.toggle("collapsed", dispatchPanelCollapsed);
|
|
181
|
+
localStorage.setItem(DISPATCH_PANEL_COLLAPSED_KEY, String(dispatchPanelCollapsed));
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Render mini dispatch items for collapsed panel view.
|
|
186
|
+
* Shows compact dispatch indicators that can be clicked to expand.
|
|
187
|
+
*/
|
|
188
|
+
function renderDispatchMiniList(entries) {
|
|
189
|
+
const { dispatchMiniList, dispatchPanel } = els();
|
|
190
|
+
if (!dispatchMiniList)
|
|
191
|
+
return;
|
|
192
|
+
dispatchMiniList.innerHTML = "";
|
|
193
|
+
// Only show first 8 items in mini view
|
|
194
|
+
const maxMiniItems = 8;
|
|
195
|
+
entries.slice(0, maxMiniItems).forEach((entry) => {
|
|
196
|
+
const dispatch = entry.dispatch;
|
|
197
|
+
const isTurnSummary = dispatch?.mode === "turn_summary" || dispatch?.extra?.is_turn_summary;
|
|
198
|
+
const isNotify = dispatch?.mode === "notify";
|
|
199
|
+
const mini = document.createElement("div");
|
|
200
|
+
mini.className = `dispatch-mini-item${isNotify ? " notify" : ""}`;
|
|
201
|
+
mini.textContent = `#${entry.seq || "?"}`;
|
|
202
|
+
mini.title = isTurnSummary
|
|
203
|
+
? "Agent turn output"
|
|
204
|
+
: dispatch?.title || `Dispatch #${entry.seq}`;
|
|
205
|
+
// Click to expand panel and scroll to this item
|
|
206
|
+
mini.addEventListener("click", () => {
|
|
207
|
+
if (dispatchPanel && dispatchPanelCollapsed) {
|
|
208
|
+
dispatchPanelCollapsed = false;
|
|
209
|
+
dispatchPanel.classList.remove("collapsed");
|
|
210
|
+
localStorage.setItem(DISPATCH_PANEL_COLLAPSED_KEY, "false");
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
dispatchMiniList.appendChild(mini);
|
|
214
|
+
});
|
|
215
|
+
// Show overflow indicator if more items
|
|
216
|
+
if (entries.length > maxMiniItems) {
|
|
217
|
+
const more = document.createElement("div");
|
|
218
|
+
more.className = "dispatch-mini-item";
|
|
219
|
+
more.textContent = `+${entries.length - maxMiniItems}`;
|
|
220
|
+
more.title = `${entries.length - maxMiniItems} more dispatches`;
|
|
221
|
+
more.addEventListener("click", () => {
|
|
222
|
+
if (dispatchPanel && dispatchPanelCollapsed) {
|
|
223
|
+
dispatchPanelCollapsed = false;
|
|
224
|
+
dispatchPanel.classList.remove("collapsed");
|
|
225
|
+
localStorage.setItem(DISPATCH_PANEL_COLLAPSED_KEY, "false");
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
dispatchMiniList.appendChild(more);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
function formatElapsed(startTime) {
|
|
232
|
+
const now = new Date();
|
|
233
|
+
const diffMs = now.getTime() - startTime.getTime();
|
|
234
|
+
const diffSecs = Math.floor(diffMs / 1000);
|
|
235
|
+
if (diffSecs < 60) {
|
|
236
|
+
return `${diffSecs}s`;
|
|
237
|
+
}
|
|
238
|
+
const mins = Math.floor(diffSecs / 60);
|
|
239
|
+
const secs = diffSecs % 60;
|
|
240
|
+
if (mins < 60) {
|
|
241
|
+
return `${mins}m ${secs}s`;
|
|
242
|
+
}
|
|
243
|
+
const hours = Math.floor(mins / 60);
|
|
244
|
+
const remainingMins = mins % 60;
|
|
245
|
+
return `${hours}h ${remainingMins}m`;
|
|
246
|
+
}
|
|
247
|
+
function startElapsedTimer() {
|
|
248
|
+
stopElapsedTimer();
|
|
249
|
+
if (!flowStartedAt)
|
|
250
|
+
return;
|
|
251
|
+
const update = () => {
|
|
252
|
+
const { elapsed } = els();
|
|
253
|
+
if (elapsed && flowStartedAt) {
|
|
254
|
+
elapsed.textContent = formatElapsed(flowStartedAt);
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
update(); // Update immediately
|
|
258
|
+
elapsedTimerId = setInterval(update, 1000);
|
|
259
|
+
}
|
|
260
|
+
function stopElapsedTimer() {
|
|
261
|
+
if (elapsedTimerId) {
|
|
262
|
+
clearInterval(elapsedTimerId);
|
|
263
|
+
elapsedTimerId = null;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
// ---- SSE Event Stream Functions ----
|
|
267
|
+
function formatTimeAgo(timestamp) {
|
|
268
|
+
const now = new Date();
|
|
269
|
+
const diffMs = now.getTime() - timestamp.getTime();
|
|
270
|
+
const diffSecs = Math.floor(diffMs / 1000);
|
|
271
|
+
if (diffSecs < 5)
|
|
272
|
+
return "just now";
|
|
273
|
+
if (diffSecs < 60)
|
|
274
|
+
return `${diffSecs}s ago`;
|
|
275
|
+
const mins = Math.floor(diffSecs / 60);
|
|
276
|
+
if (mins < 60)
|
|
277
|
+
return `${mins}m ago`;
|
|
278
|
+
const hours = Math.floor(mins / 60);
|
|
279
|
+
return `${hours}h ago`;
|
|
280
|
+
}
|
|
281
|
+
function updateLastActivityDisplay() {
|
|
282
|
+
const el = document.getElementById("ticket-flow-last-activity");
|
|
283
|
+
if (el && lastActivityTime) {
|
|
284
|
+
el.textContent = formatTimeAgo(lastActivityTime);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
function startLastActivityTimer() {
|
|
288
|
+
stopLastActivityTimer();
|
|
289
|
+
updateLastActivityDisplay();
|
|
290
|
+
lastActivityTimerId = setInterval(updateLastActivityDisplay, 1000);
|
|
291
|
+
}
|
|
292
|
+
function stopLastActivityTimer() {
|
|
293
|
+
if (lastActivityTimerId) {
|
|
294
|
+
clearInterval(lastActivityTimerId);
|
|
295
|
+
lastActivityTimerId = null;
|
|
296
|
+
}
|
|
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
|
+
}
|
|
369
|
+
function appendToLiveOutput(text) {
|
|
370
|
+
if (!text)
|
|
371
|
+
return;
|
|
372
|
+
const segments = text.split("\n");
|
|
373
|
+
// Merge first segment into the last buffered line to avoid artificial newlines between deltas
|
|
374
|
+
if (liveOutputBuffer.length === 0) {
|
|
375
|
+
liveOutputBuffer.push(segments[0]);
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
liveOutputBuffer[liveOutputBuffer.length - 1] += segments[0];
|
|
379
|
+
}
|
|
380
|
+
// Remaining segments represent real new lines
|
|
381
|
+
for (let i = 1; i < segments.length; i++) {
|
|
382
|
+
liveOutputBuffer.push(segments[i]);
|
|
383
|
+
}
|
|
384
|
+
// Trim buffer if it exceeds max lines
|
|
385
|
+
while (liveOutputBuffer.length > MAX_OUTPUT_LINES) {
|
|
386
|
+
liveOutputBuffer.shift();
|
|
387
|
+
}
|
|
388
|
+
scheduleLiveOutputTextUpdate();
|
|
389
|
+
}
|
|
390
|
+
function addLiveOutputEvent(parsed) {
|
|
391
|
+
const { event, mergeStrategy } = parsed;
|
|
392
|
+
const itemId = event.itemId;
|
|
393
|
+
if (mergeStrategy && itemId && liveOutputEventIndex[itemId] !== undefined) {
|
|
394
|
+
const existingIndex = liveOutputEventIndex[itemId];
|
|
395
|
+
const existing = liveOutputEvents[existingIndex];
|
|
396
|
+
if (mergeStrategy === "append") {
|
|
397
|
+
existing.summary = `${existing.summary || ""}${event.summary}`;
|
|
398
|
+
}
|
|
399
|
+
else if (mergeStrategy === "newline") {
|
|
400
|
+
existing.summary = `${existing.summary || ""}\n\n`;
|
|
401
|
+
}
|
|
402
|
+
existing.time = event.time;
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
liveOutputEvents.push(event);
|
|
406
|
+
if (liveOutputEvents.length > LIVE_EVENT_MAX) {
|
|
407
|
+
liveOutputEvents = liveOutputEvents.slice(-LIVE_EVENT_MAX);
|
|
408
|
+
liveOutputEventIndex = {};
|
|
409
|
+
liveOutputEvents.forEach((evt, idx) => {
|
|
410
|
+
if (evt.itemId)
|
|
411
|
+
liveOutputEventIndex[evt.itemId] = idx;
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
else if (itemId) {
|
|
415
|
+
liveOutputEventIndex[itemId] = liveOutputEvents.length - 1;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
function renderLiveOutputEvents() {
|
|
419
|
+
const container = document.getElementById("ticket-live-output-events");
|
|
420
|
+
const list = document.getElementById("ticket-live-output-events-list");
|
|
421
|
+
const count = document.getElementById("ticket-live-output-events-count");
|
|
422
|
+
if (!container || !list || !count)
|
|
423
|
+
return;
|
|
424
|
+
const hasEvents = liveOutputEvents.length > 0;
|
|
425
|
+
if (count.textContent !== String(liveOutputEvents.length)) {
|
|
426
|
+
count.textContent = String(liveOutputEvents.length);
|
|
427
|
+
}
|
|
428
|
+
const shouldHide = !hasEvents || !liveOutputDetailExpanded;
|
|
429
|
+
if (container.classList.contains("hidden") !== shouldHide) {
|
|
430
|
+
container.classList.toggle("hidden", shouldHide);
|
|
431
|
+
}
|
|
432
|
+
if (shouldHide) {
|
|
433
|
+
if (list.innerHTML !== "")
|
|
434
|
+
list.innerHTML = "";
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
// Track which IDs are currently in the list to remove stale ones
|
|
438
|
+
const currentIds = new Set();
|
|
439
|
+
liveOutputEvents.forEach((entry) => {
|
|
440
|
+
const id = entry.id;
|
|
441
|
+
currentIds.add(id);
|
|
442
|
+
// Safer lookup than querySelector with arbitrary ID
|
|
443
|
+
let wrapper = null;
|
|
444
|
+
for (let i = 0; i < list.children.length; i++) {
|
|
445
|
+
const child = list.children[i];
|
|
446
|
+
if (child.dataset.eventId === id) {
|
|
447
|
+
wrapper = child;
|
|
448
|
+
break;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
if (!wrapper) {
|
|
452
|
+
wrapper = document.createElement("div");
|
|
453
|
+
wrapper.className = `ticket-chat-event ${entry.kind || ""}`.trim();
|
|
454
|
+
wrapper.dataset.eventId = id;
|
|
455
|
+
const title = document.createElement("div");
|
|
456
|
+
title.className = "ticket-chat-event-title";
|
|
457
|
+
wrapper.appendChild(title);
|
|
458
|
+
const summary = document.createElement("div");
|
|
459
|
+
summary.className = "ticket-chat-event-summary";
|
|
460
|
+
wrapper.appendChild(summary);
|
|
461
|
+
const detail = document.createElement("div");
|
|
462
|
+
detail.className = "ticket-chat-event-detail";
|
|
463
|
+
wrapper.appendChild(detail);
|
|
464
|
+
const meta = document.createElement("div");
|
|
465
|
+
meta.className = "ticket-chat-event-meta";
|
|
466
|
+
wrapper.appendChild(meta);
|
|
467
|
+
list.appendChild(wrapper);
|
|
468
|
+
}
|
|
469
|
+
// Efficiently update content only if changed
|
|
470
|
+
const titleEl = wrapper.querySelector(".ticket-chat-event-title");
|
|
471
|
+
const newTitle = entry.title || entry.method || "Update";
|
|
472
|
+
if (titleEl && titleEl.textContent !== newTitle) {
|
|
473
|
+
titleEl.textContent = newTitle;
|
|
474
|
+
}
|
|
475
|
+
const summaryEl = wrapper.querySelector(".ticket-chat-event-summary");
|
|
476
|
+
const newSummary = entry.summary || "";
|
|
477
|
+
if (summaryEl && summaryEl.textContent !== newSummary) {
|
|
478
|
+
summaryEl.textContent = newSummary;
|
|
479
|
+
}
|
|
480
|
+
const detailEl = wrapper.querySelector(".ticket-chat-event-detail");
|
|
481
|
+
const newDetail = entry.detail || "";
|
|
482
|
+
if (detailEl && detailEl.textContent !== newDetail) {
|
|
483
|
+
detailEl.textContent = newDetail;
|
|
484
|
+
}
|
|
485
|
+
const metaEl = wrapper.querySelector(".ticket-chat-event-meta");
|
|
486
|
+
if (metaEl) {
|
|
487
|
+
const newMeta = entry.time
|
|
488
|
+
? new Date(entry.time).toLocaleTimeString([], {
|
|
489
|
+
hour: "2-digit",
|
|
490
|
+
minute: "2-digit",
|
|
491
|
+
})
|
|
492
|
+
: "";
|
|
493
|
+
if (metaEl.textContent !== newMeta) {
|
|
494
|
+
metaEl.textContent = newMeta;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
// Remove stale events
|
|
499
|
+
Array.from(list.children).forEach((child) => {
|
|
500
|
+
const el = child;
|
|
501
|
+
if (el.dataset.eventId && !currentIds.has(el.dataset.eventId)) {
|
|
502
|
+
el.remove();
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
// Only scroll if near bottom or if height changed significantly?
|
|
506
|
+
// For now, just scroll as it's the expected behavior for live logs
|
|
507
|
+
list.scrollTop = list.scrollHeight;
|
|
508
|
+
}
|
|
509
|
+
function renderLiveOutputCompact() {
|
|
510
|
+
const compactEl = document.getElementById("ticket-live-output-compact");
|
|
511
|
+
if (!compactEl)
|
|
512
|
+
return;
|
|
513
|
+
const summary = summarizeEvents(liveOutputEvents, {
|
|
514
|
+
maxActions: 1, // Show only 1 action + thinking to fit in 3-line compact view
|
|
515
|
+
maxTextLength: COMPACT_MAX_TEXT_LENGTH,
|
|
516
|
+
startTime: flowStartedAt?.getTime(),
|
|
517
|
+
});
|
|
518
|
+
const text = liveOutputEvents.length ? renderCompactSummary(summary) : "";
|
|
519
|
+
const newText = text || "Waiting for agent output...";
|
|
520
|
+
if (compactEl.textContent !== newText) {
|
|
521
|
+
compactEl.textContent = newText;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
function updateLiveOutputViewToggle() {
|
|
525
|
+
const viewToggle = document.getElementById("ticket-live-output-view-toggle");
|
|
526
|
+
if (!viewToggle)
|
|
527
|
+
return;
|
|
528
|
+
if (liveOutputDetailExpanded) {
|
|
529
|
+
if (!viewToggle.classList.contains("active"))
|
|
530
|
+
viewToggle.classList.add("active");
|
|
531
|
+
if (viewToggle.textContent !== "≡")
|
|
532
|
+
viewToggle.textContent = "≡";
|
|
533
|
+
if (viewToggle.title !== "Show summary")
|
|
534
|
+
viewToggle.title = "Show summary";
|
|
535
|
+
}
|
|
536
|
+
else {
|
|
537
|
+
if (viewToggle.classList.contains("active"))
|
|
538
|
+
viewToggle.classList.remove("active");
|
|
539
|
+
if (viewToggle.textContent !== "⋯")
|
|
540
|
+
viewToggle.textContent = "⋯";
|
|
541
|
+
if (viewToggle.title !== "Show full output")
|
|
542
|
+
viewToggle.title = "Show full output";
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
function renderLiveOutputView() {
|
|
546
|
+
const compactEl = document.getElementById("ticket-live-output-compact");
|
|
547
|
+
const detailEl = document.getElementById("ticket-live-output-detail");
|
|
548
|
+
const eventsEl = document.getElementById("ticket-live-output-events");
|
|
549
|
+
if (compactEl) {
|
|
550
|
+
compactEl.classList.toggle("hidden", liveOutputDetailExpanded);
|
|
551
|
+
}
|
|
552
|
+
if (detailEl) {
|
|
553
|
+
detailEl.classList.toggle("hidden", !liveOutputDetailExpanded);
|
|
554
|
+
}
|
|
555
|
+
if (eventsEl) {
|
|
556
|
+
eventsEl.classList.toggle("hidden", !liveOutputDetailExpanded);
|
|
557
|
+
}
|
|
558
|
+
renderLiveOutputCompact();
|
|
559
|
+
renderLiveOutputEvents();
|
|
560
|
+
updateLiveOutputViewToggle();
|
|
561
|
+
}
|
|
562
|
+
function clearLiveOutput() {
|
|
563
|
+
liveOutputBuffer = [];
|
|
564
|
+
const outputEl = document.getElementById("ticket-live-output-text");
|
|
565
|
+
if (outputEl)
|
|
566
|
+
outputEl.textContent = "";
|
|
567
|
+
liveOutputEvents = [];
|
|
568
|
+
liveOutputEventIndex = {};
|
|
569
|
+
scheduleLiveOutputRender();
|
|
570
|
+
}
|
|
571
|
+
function setLiveOutputStatus(status) {
|
|
572
|
+
const statusEl = document.getElementById("ticket-live-output-status");
|
|
573
|
+
if (!statusEl)
|
|
574
|
+
return;
|
|
575
|
+
statusEl.className = "ticket-live-output-status";
|
|
576
|
+
switch (status) {
|
|
577
|
+
case "disconnected":
|
|
578
|
+
statusEl.textContent = "Disconnected";
|
|
579
|
+
break;
|
|
580
|
+
case "connected":
|
|
581
|
+
statusEl.textContent = "Connected";
|
|
582
|
+
statusEl.classList.add("connected");
|
|
583
|
+
break;
|
|
584
|
+
case "streaming":
|
|
585
|
+
statusEl.textContent = "Streaming";
|
|
586
|
+
statusEl.classList.add("streaming");
|
|
587
|
+
break;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
function handleFlowEvent(event) {
|
|
591
|
+
// Update last activity time
|
|
592
|
+
lastActivityTime = new Date(event.timestamp);
|
|
593
|
+
lastKnownEventAt = lastActivityTime;
|
|
594
|
+
updateLastActivityDisplay();
|
|
595
|
+
// Handle agent stream delta events
|
|
596
|
+
if (event.event_type === "agent_stream_delta") {
|
|
597
|
+
setLiveOutputStatus("streaming");
|
|
598
|
+
const delta = event.data?.delta || "";
|
|
599
|
+
if (delta) {
|
|
600
|
+
appendToLiveOutput(delta);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
// Handle rich app-server events (tools, commands, files, thinking, etc.)
|
|
604
|
+
if (event.event_type === "app_server_event") {
|
|
605
|
+
const parsed = parseAppServerEvent(event.data);
|
|
606
|
+
if (parsed) {
|
|
607
|
+
addLiveOutputEvent(parsed);
|
|
608
|
+
scheduleLiveOutputRender();
|
|
609
|
+
}
|
|
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
|
+
}
|
|
625
|
+
// Handle flow lifecycle events
|
|
626
|
+
if (event.event_type === "flow_completed" ||
|
|
627
|
+
event.event_type === "flow_failed" ||
|
|
628
|
+
event.event_type === "flow_stopped") {
|
|
629
|
+
setLiveOutputStatus("connected");
|
|
630
|
+
// Refresh the flow state
|
|
631
|
+
void loadTicketFlow();
|
|
632
|
+
}
|
|
633
|
+
// Handle step events
|
|
634
|
+
if (event.event_type === "step_started") {
|
|
635
|
+
const stepName = event.data?.step_name || "";
|
|
636
|
+
if (stepName) {
|
|
637
|
+
appendToLiveOutput(`\n--- Step: ${stepName} ---\n`);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
function connectEventStream(runId, afterSeq) {
|
|
642
|
+
disconnectEventStream();
|
|
643
|
+
clearEventStreamRetry();
|
|
644
|
+
eventSourceRunId = runId;
|
|
645
|
+
const token = getAuthToken();
|
|
646
|
+
const url = new URL(resolvePath(`/api/flows/${runId}/events`), window.location.origin);
|
|
647
|
+
if (token) {
|
|
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
|
+
}
|
|
658
|
+
}
|
|
659
|
+
eventSource = new EventSource(url.toString());
|
|
660
|
+
eventSource.onopen = () => {
|
|
661
|
+
setLiveOutputStatus("connected");
|
|
662
|
+
eventSourceRetryAttempt = 0;
|
|
663
|
+
clearEventStreamRetry();
|
|
664
|
+
};
|
|
665
|
+
eventSource.onmessage = (event) => {
|
|
666
|
+
try {
|
|
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
|
+
}
|
|
673
|
+
handleFlowEvent(data);
|
|
674
|
+
}
|
|
675
|
+
catch (err) {
|
|
676
|
+
// Ignore parse errors
|
|
677
|
+
}
|
|
678
|
+
};
|
|
679
|
+
eventSource.onerror = () => {
|
|
680
|
+
setLiveOutputStatus("disconnected");
|
|
681
|
+
if (eventSource) {
|
|
682
|
+
eventSource.close();
|
|
683
|
+
eventSource = null;
|
|
684
|
+
}
|
|
685
|
+
scheduleEventStreamReconnect(runId);
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
function disconnectEventStream() {
|
|
689
|
+
if (eventSource) {
|
|
690
|
+
eventSource.close();
|
|
691
|
+
eventSource = null;
|
|
692
|
+
}
|
|
693
|
+
clearEventStreamRetry();
|
|
694
|
+
eventSourceRunId = null;
|
|
695
|
+
setLiveOutputStatus("disconnected");
|
|
696
|
+
}
|
|
697
|
+
function initLiveOutputPanel() {
|
|
698
|
+
const viewToggleBtn = document.getElementById("ticket-live-output-view-toggle");
|
|
699
|
+
// Toggle between summary and full view (one click)
|
|
700
|
+
const toggleView = () => {
|
|
701
|
+
liveOutputDetailExpanded = !liveOutputDetailExpanded;
|
|
702
|
+
renderLiveOutputView();
|
|
703
|
+
};
|
|
704
|
+
if (viewToggleBtn) {
|
|
705
|
+
viewToggleBtn.addEventListener("click", toggleView);
|
|
706
|
+
}
|
|
707
|
+
// Initial render
|
|
708
|
+
updateLiveOutputViewToggle();
|
|
709
|
+
renderLiveOutputView();
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* Initialize the reason modal click handler.
|
|
713
|
+
*/
|
|
714
|
+
function initReasonModal() {
|
|
715
|
+
const reasonEl = document.getElementById("ticket-flow-reason");
|
|
716
|
+
const modalOverlay = document.getElementById("reason-modal");
|
|
717
|
+
const modalContent = document.getElementById("reason-modal-content");
|
|
718
|
+
const closeBtn = document.getElementById("reason-modal-close");
|
|
719
|
+
if (!reasonEl || !modalOverlay || !modalContent)
|
|
720
|
+
return;
|
|
721
|
+
let closeModal = null;
|
|
722
|
+
const showReasonModal = () => {
|
|
723
|
+
if (!currentReasonFull || !reasonEl.classList.contains("has-details"))
|
|
724
|
+
return;
|
|
725
|
+
modalContent.textContent = currentReasonFull;
|
|
726
|
+
closeModal = openModal(modalOverlay, {
|
|
727
|
+
closeOnEscape: true,
|
|
728
|
+
closeOnOverlay: true,
|
|
729
|
+
returnFocusTo: reasonEl,
|
|
730
|
+
});
|
|
731
|
+
};
|
|
732
|
+
reasonEl.addEventListener("click", showReasonModal);
|
|
733
|
+
if (closeBtn) {
|
|
734
|
+
closeBtn.addEventListener("click", () => {
|
|
735
|
+
if (closeModal)
|
|
736
|
+
closeModal();
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
function els() {
|
|
741
|
+
return {
|
|
742
|
+
card: document.getElementById("ticket-card"),
|
|
743
|
+
status: document.getElementById("ticket-flow-status"),
|
|
744
|
+
run: document.getElementById("ticket-flow-run"),
|
|
745
|
+
current: document.getElementById("ticket-flow-current"),
|
|
746
|
+
turn: document.getElementById("ticket-flow-turn"),
|
|
747
|
+
elapsed: document.getElementById("ticket-flow-elapsed"),
|
|
748
|
+
progress: document.getElementById("ticket-flow-progress"),
|
|
749
|
+
reason: document.getElementById("ticket-flow-reason"),
|
|
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"),
|
|
757
|
+
dir: document.getElementById("ticket-flow-dir"),
|
|
758
|
+
tickets: document.getElementById("ticket-flow-tickets"),
|
|
759
|
+
history: document.getElementById("ticket-dispatch-history"),
|
|
760
|
+
dispatchNote: document.getElementById("ticket-dispatch-note"),
|
|
761
|
+
dispatchPanel: document.getElementById("dispatch-panel"),
|
|
762
|
+
dispatchPanelToggle: document.getElementById("dispatch-panel-toggle"),
|
|
763
|
+
dispatchMiniList: document.getElementById("dispatch-mini-list"),
|
|
764
|
+
bootstrapBtn: document.getElementById("ticket-flow-bootstrap"),
|
|
765
|
+
resumeBtn: document.getElementById("ticket-flow-resume"),
|
|
766
|
+
refreshBtn: document.getElementById("ticket-flow-refresh"),
|
|
767
|
+
stopBtn: document.getElementById("ticket-flow-stop"),
|
|
768
|
+
restartBtn: document.getElementById("ticket-flow-restart"),
|
|
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"),
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
function setButtonsDisabled(disabled) {
|
|
778
|
+
const { bootstrapBtn, resumeBtn, refreshBtn, stopBtn, restartBtn, archiveBtn, reconnectBtn, recoverBtn } = els();
|
|
779
|
+
[bootstrapBtn, resumeBtn, refreshBtn, stopBtn, restartBtn, archiveBtn, reconnectBtn, recoverBtn].forEach((btn) => {
|
|
780
|
+
if (btn)
|
|
781
|
+
btn.disabled = disabled;
|
|
782
|
+
});
|
|
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
|
+
}
|
|
827
|
+
function truncate(text, max = 100) {
|
|
828
|
+
if (text.length <= max)
|
|
829
|
+
return text;
|
|
830
|
+
return `${text.slice(0, max).trim()}…`;
|
|
831
|
+
}
|
|
832
|
+
function renderTickets(data) {
|
|
833
|
+
ticketListCache = data;
|
|
834
|
+
const { tickets, dir } = els();
|
|
835
|
+
if (dir)
|
|
836
|
+
dir.textContent = data?.ticket_dir || "–";
|
|
837
|
+
if (!tickets)
|
|
838
|
+
return;
|
|
839
|
+
tickets.innerHTML = "";
|
|
840
|
+
const list = (data?.tickets || []);
|
|
841
|
+
ticketsExist = list.length > 0;
|
|
842
|
+
// Update progress bar
|
|
843
|
+
const progressBar = document.getElementById("ticket-progress-bar");
|
|
844
|
+
const progressFill = document.getElementById("ticket-progress-fill");
|
|
845
|
+
if (progressBar && progressFill) {
|
|
846
|
+
if (list.length === 0) {
|
|
847
|
+
progressBar.classList.add("hidden");
|
|
848
|
+
}
|
|
849
|
+
else {
|
|
850
|
+
progressBar.classList.remove("hidden");
|
|
851
|
+
const doneCount = list.filter((t) => Boolean((t.frontmatter || {})?.done)).length;
|
|
852
|
+
const percent = Math.round((doneCount / list.length) * 100);
|
|
853
|
+
progressFill.style.width = `${percent}%`;
|
|
854
|
+
progressBar.title = `${doneCount} of ${list.length} tickets done`;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
if (!list.length) {
|
|
858
|
+
tickets.textContent = "No tickets found. Start the ticket flow to create TICKET-001.md.";
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
list.forEach((ticket) => {
|
|
862
|
+
const item = document.createElement("div");
|
|
863
|
+
const fm = (ticket.frontmatter || {});
|
|
864
|
+
const done = Boolean(fm?.done);
|
|
865
|
+
// Check if this ticket is currently being worked on
|
|
866
|
+
const isActive = Boolean(currentActiveTicket &&
|
|
867
|
+
ticket.path === currentActiveTicket &&
|
|
868
|
+
isFlowActiveStatus(currentFlowStatus));
|
|
869
|
+
item.className = `ticket-item ${done ? "done" : ""} ${isActive ? "active" : ""} ${selectedTicketPath === ticket.path ? "selected" : ""} clickable`;
|
|
870
|
+
item.title = "Click to edit";
|
|
871
|
+
item.setAttribute("data-ticket-path", ticket.path || "");
|
|
872
|
+
// Make ticket item clickable to open editor
|
|
873
|
+
item.addEventListener("click", () => {
|
|
874
|
+
updateSelectedTicket(ticket.path || null);
|
|
875
|
+
openTicketEditor(ticket);
|
|
876
|
+
});
|
|
877
|
+
const head = document.createElement("div");
|
|
878
|
+
head.className = "ticket-item-head";
|
|
879
|
+
// Extract ticket number from path (e.g., "TICKET-001" from ".codex-autorunner/tickets/TICKET-001.md")
|
|
880
|
+
const ticketPath = ticket.path || "";
|
|
881
|
+
const ticketMatch = ticketPath.match(/TICKET-\d+/);
|
|
882
|
+
const ticketNumber = ticketMatch ? ticketMatch[0] : "TICKET";
|
|
883
|
+
const ticketTitle = fm?.title ? String(fm.title) : "";
|
|
884
|
+
const name = document.createElement("span");
|
|
885
|
+
name.className = "ticket-name";
|
|
886
|
+
// Split number and title into separate spans for responsive control
|
|
887
|
+
const numSpan = document.createElement("span");
|
|
888
|
+
numSpan.className = "ticket-num";
|
|
889
|
+
// Extract just the number (e.g., "001" from "TICKET-001")
|
|
890
|
+
const numMatch = ticketNumber.match(/\d+/);
|
|
891
|
+
numSpan.textContent = numMatch ? numMatch[0] : ticketNumber;
|
|
892
|
+
name.appendChild(numSpan);
|
|
893
|
+
if (ticketTitle) {
|
|
894
|
+
const titleSpan = document.createElement("span");
|
|
895
|
+
titleSpan.className = "ticket-title-text";
|
|
896
|
+
titleSpan.textContent = `: ${ticketTitle}`;
|
|
897
|
+
name.appendChild(titleSpan);
|
|
898
|
+
}
|
|
899
|
+
// Set full text as title attribute for tooltip on hover
|
|
900
|
+
item.title = ticketTitle ? `${ticketNumber}: ${ticketTitle}` : ticketNumber;
|
|
901
|
+
head.appendChild(name);
|
|
902
|
+
// Badge container for status + agent badges
|
|
903
|
+
const badges = document.createElement("span");
|
|
904
|
+
badges.className = "ticket-badges";
|
|
905
|
+
// Add WORKING badge for active ticket (to the left of agent badge)
|
|
906
|
+
if (isActive) {
|
|
907
|
+
const workingBadge = document.createElement("span");
|
|
908
|
+
workingBadge.className = "ticket-working-badge";
|
|
909
|
+
// Text content used on middle responsive view; CSS hides text on desktop/mobile
|
|
910
|
+
const workingText = document.createElement("span");
|
|
911
|
+
workingText.className = "badge-text";
|
|
912
|
+
workingText.textContent = "Working";
|
|
913
|
+
workingBadge.appendChild(workingText);
|
|
914
|
+
badges.appendChild(workingBadge);
|
|
915
|
+
}
|
|
916
|
+
// Add DONE badge for completed tickets
|
|
917
|
+
if (done && !isActive) {
|
|
918
|
+
const doneBadge = document.createElement("span");
|
|
919
|
+
doneBadge.className = "ticket-done-badge";
|
|
920
|
+
// Text content used on middle responsive view; CSS hides text on desktop/mobile
|
|
921
|
+
const doneText = document.createElement("span");
|
|
922
|
+
doneText.className = "badge-text";
|
|
923
|
+
doneText.textContent = "Done";
|
|
924
|
+
doneBadge.appendChild(doneText);
|
|
925
|
+
badges.appendChild(doneBadge);
|
|
926
|
+
}
|
|
927
|
+
const agent = document.createElement("span");
|
|
928
|
+
agent.className = "ticket-agent";
|
|
929
|
+
agent.textContent = fm?.agent || "codex";
|
|
930
|
+
badges.appendChild(agent);
|
|
931
|
+
head.appendChild(badges);
|
|
932
|
+
item.appendChild(head);
|
|
933
|
+
if (ticket.errors && ticket.errors.length) {
|
|
934
|
+
const errors = document.createElement("div");
|
|
935
|
+
errors.className = "ticket-errors";
|
|
936
|
+
errors.textContent = `Frontmatter issues: ${ticket.errors.join("; ")}`;
|
|
937
|
+
item.appendChild(errors);
|
|
938
|
+
}
|
|
939
|
+
if (ticket.body) {
|
|
940
|
+
const body = document.createElement("div");
|
|
941
|
+
body.className = "ticket-body";
|
|
942
|
+
body.textContent = truncate(ticket.body.replace(/\s+/g, " ").trim());
|
|
943
|
+
item.appendChild(body);
|
|
944
|
+
}
|
|
945
|
+
tickets.appendChild(item);
|
|
946
|
+
});
|
|
947
|
+
// Update scroll fade indicator after rendering
|
|
948
|
+
updateScrollFade();
|
|
949
|
+
}
|
|
950
|
+
function renderDispatchHistory(runId, data) {
|
|
951
|
+
const { history, dispatchNote } = els();
|
|
952
|
+
if (!history)
|
|
953
|
+
return;
|
|
954
|
+
history.innerHTML = "";
|
|
955
|
+
const { dispatchMiniList } = els();
|
|
956
|
+
if (!runId) {
|
|
957
|
+
history.textContent = "Start the ticket flow to see agent dispatches.";
|
|
958
|
+
if (dispatchNote)
|
|
959
|
+
dispatchNote.textContent = "–";
|
|
960
|
+
if (dispatchMiniList)
|
|
961
|
+
dispatchMiniList.innerHTML = "";
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
const entries = (data?.history || []);
|
|
965
|
+
if (!entries.length) {
|
|
966
|
+
history.textContent = "No dispatches yet.";
|
|
967
|
+
if (dispatchNote)
|
|
968
|
+
dispatchNote.textContent = "–";
|
|
969
|
+
if (dispatchMiniList)
|
|
970
|
+
dispatchMiniList.innerHTML = "";
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
if (dispatchNote)
|
|
974
|
+
dispatchNote.textContent = `Latest #${entries[0]?.seq ?? "–"}`;
|
|
975
|
+
// Also render mini list for collapsed panel view
|
|
976
|
+
renderDispatchMiniList(entries);
|
|
977
|
+
entries.forEach((entry, index) => {
|
|
978
|
+
const dispatch = entry.dispatch;
|
|
979
|
+
const isTurnSummary = dispatch?.mode === "turn_summary" || dispatch?.extra?.is_turn_summary;
|
|
980
|
+
const isHandoff = dispatch?.mode === "pause";
|
|
981
|
+
const isNotify = dispatch?.mode === "notify";
|
|
982
|
+
// Expand only the first (newest) dispatch by default - entries are newest-first
|
|
983
|
+
const isFirst = index === 0;
|
|
984
|
+
const isCollapsed = !isFirst;
|
|
985
|
+
const container = document.createElement("div");
|
|
986
|
+
container.className = `dispatch-item${isTurnSummary ? " turn-summary" : ""}${isHandoff ? " pause" : ""}${isNotify ? " notify" : ""}${isCollapsed ? " collapsed" : ""}`;
|
|
987
|
+
// Reddit-style thin collapse bar on the left
|
|
988
|
+
const collapseBar = document.createElement("div");
|
|
989
|
+
collapseBar.className = "dispatch-collapse-bar";
|
|
990
|
+
collapseBar.title = isCollapsed ? "Click to expand" : "Click to collapse";
|
|
991
|
+
collapseBar.setAttribute("role", "button");
|
|
992
|
+
collapseBar.setAttribute("tabindex", "0");
|
|
993
|
+
collapseBar.setAttribute("aria-label", isCollapsed ? "Expand dispatch" : "Collapse dispatch");
|
|
994
|
+
collapseBar.setAttribute("aria-expanded", String(!isCollapsed));
|
|
995
|
+
const toggleCollapse = () => {
|
|
996
|
+
container.classList.toggle("collapsed");
|
|
997
|
+
const isNowCollapsed = container.classList.contains("collapsed");
|
|
998
|
+
collapseBar.title = isNowCollapsed ? "Click to expand" : "Click to collapse";
|
|
999
|
+
collapseBar.setAttribute("aria-expanded", String(!isNowCollapsed));
|
|
1000
|
+
collapseBar.setAttribute("aria-label", isNowCollapsed ? "Expand dispatch" : "Collapse dispatch");
|
|
1001
|
+
};
|
|
1002
|
+
collapseBar.addEventListener("click", (e) => {
|
|
1003
|
+
e.stopPropagation();
|
|
1004
|
+
toggleCollapse();
|
|
1005
|
+
});
|
|
1006
|
+
collapseBar.addEventListener("keydown", (e) => {
|
|
1007
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
1008
|
+
e.preventDefault();
|
|
1009
|
+
toggleCollapse();
|
|
1010
|
+
}
|
|
1011
|
+
});
|
|
1012
|
+
// Content wrapper for header and body
|
|
1013
|
+
const contentWrapper = document.createElement("div");
|
|
1014
|
+
contentWrapper.className = "dispatch-content-wrapper";
|
|
1015
|
+
// Create collapsible structure
|
|
1016
|
+
const header = document.createElement("div");
|
|
1017
|
+
header.className = "dispatch-header";
|
|
1018
|
+
// Make header clickable to toggle collapse
|
|
1019
|
+
header.addEventListener("click", (e) => {
|
|
1020
|
+
// Don't toggle if clicking on a link or navigating to inbox
|
|
1021
|
+
if (e.target.closest("a"))
|
|
1022
|
+
return;
|
|
1023
|
+
toggleCollapse();
|
|
1024
|
+
});
|
|
1025
|
+
// Header content area
|
|
1026
|
+
const headerContent = document.createElement("div");
|
|
1027
|
+
headerContent.className = "dispatch-header-content";
|
|
1028
|
+
headerContent.title = isTurnSummary ? "Agent turn output" : "Click header to expand/collapse";
|
|
1029
|
+
// Determine mode label
|
|
1030
|
+
let modeLabel;
|
|
1031
|
+
if (isTurnSummary) {
|
|
1032
|
+
modeLabel = "TURN";
|
|
1033
|
+
}
|
|
1034
|
+
else if (isHandoff) {
|
|
1035
|
+
modeLabel = "HANDOFF";
|
|
1036
|
+
}
|
|
1037
|
+
else {
|
|
1038
|
+
modeLabel = (dispatch?.mode || "notify").toUpperCase();
|
|
1039
|
+
}
|
|
1040
|
+
const head = document.createElement("div");
|
|
1041
|
+
head.className = "dispatch-item-head";
|
|
1042
|
+
const seq = document.createElement("span");
|
|
1043
|
+
seq.className = "ticket-name";
|
|
1044
|
+
seq.textContent = `#${entry.seq || "?"}`;
|
|
1045
|
+
const mode = document.createElement("span");
|
|
1046
|
+
mode.className = `ticket-agent${isTurnSummary ? " turn-summary-badge" : ""}`;
|
|
1047
|
+
mode.textContent = modeLabel;
|
|
1048
|
+
head.append(seq, mode);
|
|
1049
|
+
headerContent.appendChild(head);
|
|
1050
|
+
header.appendChild(headerContent);
|
|
1051
|
+
contentWrapper.appendChild(header);
|
|
1052
|
+
container.append(collapseBar, contentWrapper);
|
|
1053
|
+
// Add diff stats if present (for turn summaries)
|
|
1054
|
+
const diffStats = dispatch?.extra?.diff_stats;
|
|
1055
|
+
if (diffStats && (diffStats.insertions || diffStats.deletions)) {
|
|
1056
|
+
const statsEl = document.createElement("span");
|
|
1057
|
+
statsEl.className = "dispatch-diff-stats";
|
|
1058
|
+
const ins = diffStats.insertions || 0;
|
|
1059
|
+
const del = diffStats.deletions || 0;
|
|
1060
|
+
statsEl.innerHTML = `<span class="diff-add">+${formatNumber(ins)}</span><span class="diff-del">-${formatNumber(del)}</span>`;
|
|
1061
|
+
statsEl.title = `${ins} insertions, ${del} deletions${diffStats.files_changed ? `, ${diffStats.files_changed} files` : ""}`;
|
|
1062
|
+
head.appendChild(statsEl);
|
|
1063
|
+
}
|
|
1064
|
+
// Add ticket reference if present
|
|
1065
|
+
const ticketId = dispatch?.extra?.ticket_id;
|
|
1066
|
+
if (ticketId) {
|
|
1067
|
+
// Extract ticket number from path (e.g., "TICKET-009" from ".codex-autorunner/tickets/TICKET-009.md")
|
|
1068
|
+
const ticketMatch = ticketId.match(/TICKET-\d+/);
|
|
1069
|
+
if (ticketMatch) {
|
|
1070
|
+
const ticketLabel = document.createElement("span");
|
|
1071
|
+
ticketLabel.className = "dispatch-ticket-ref";
|
|
1072
|
+
ticketLabel.textContent = ticketMatch[0];
|
|
1073
|
+
ticketLabel.title = ticketId;
|
|
1074
|
+
head.appendChild(ticketLabel);
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
// Add timestamp
|
|
1078
|
+
const timeAgo = formatDispatchTime(entry.created_at);
|
|
1079
|
+
if (timeAgo) {
|
|
1080
|
+
const timeLabel = document.createElement("span");
|
|
1081
|
+
timeLabel.className = "dispatch-time";
|
|
1082
|
+
timeLabel.textContent = timeAgo;
|
|
1083
|
+
head.appendChild(timeLabel);
|
|
1084
|
+
}
|
|
1085
|
+
// Create collapsible body content
|
|
1086
|
+
const bodyWrapper = document.createElement("div");
|
|
1087
|
+
bodyWrapper.className = "dispatch-body-wrapper";
|
|
1088
|
+
if (entry.errors && entry.errors.length) {
|
|
1089
|
+
const err = document.createElement("div");
|
|
1090
|
+
err.className = "ticket-errors";
|
|
1091
|
+
err.textContent = entry.errors.join("; ");
|
|
1092
|
+
bodyWrapper.appendChild(err);
|
|
1093
|
+
}
|
|
1094
|
+
const title = dispatch?.title;
|
|
1095
|
+
if (title) {
|
|
1096
|
+
const titleEl = document.createElement("div");
|
|
1097
|
+
titleEl.className = "ticket-body ticket-dispatch-title";
|
|
1098
|
+
titleEl.textContent = title;
|
|
1099
|
+
bodyWrapper.appendChild(titleEl);
|
|
1100
|
+
}
|
|
1101
|
+
const bodyText = dispatch?.body;
|
|
1102
|
+
if (bodyText) {
|
|
1103
|
+
const body = document.createElement("div");
|
|
1104
|
+
body.className = "ticket-body ticket-dispatch-body messages-markdown";
|
|
1105
|
+
body.innerHTML = renderMarkdown(bodyText);
|
|
1106
|
+
bodyWrapper.appendChild(body);
|
|
1107
|
+
}
|
|
1108
|
+
const attachments = (entry.attachments || []);
|
|
1109
|
+
if (attachments.length) {
|
|
1110
|
+
const wrap = document.createElement("div");
|
|
1111
|
+
wrap.className = "ticket-attachments";
|
|
1112
|
+
attachments.forEach((att) => {
|
|
1113
|
+
if (!att.url)
|
|
1114
|
+
return;
|
|
1115
|
+
const link = document.createElement("a");
|
|
1116
|
+
link.href = resolvePath(att.url);
|
|
1117
|
+
link.textContent = att.name || att.rel_path || "attachment";
|
|
1118
|
+
link.target = "_blank";
|
|
1119
|
+
link.rel = "noreferrer noopener";
|
|
1120
|
+
link.title = att.path || "";
|
|
1121
|
+
wrap.appendChild(link);
|
|
1122
|
+
});
|
|
1123
|
+
bodyWrapper.appendChild(wrap);
|
|
1124
|
+
}
|
|
1125
|
+
contentWrapper.appendChild(bodyWrapper);
|
|
1126
|
+
history.appendChild(container);
|
|
1127
|
+
});
|
|
1128
|
+
// Update scroll fade indicator after rendering
|
|
1129
|
+
updateScrollFade();
|
|
1130
|
+
}
|
|
1131
|
+
const MAX_REASON_LENGTH = 60;
|
|
1132
|
+
/**
|
|
1133
|
+
* Get the full reason text (summary + details) for modal display.
|
|
1134
|
+
*/
|
|
1135
|
+
function getFullReason(run) {
|
|
1136
|
+
if (!run)
|
|
1137
|
+
return null;
|
|
1138
|
+
const state = (run.state || {});
|
|
1139
|
+
const engine = (state.ticket_engine || {});
|
|
1140
|
+
const reason = engine.reason || run.error_message || "";
|
|
1141
|
+
const details = engine.reason_details || "";
|
|
1142
|
+
if (!reason && !details)
|
|
1143
|
+
return null;
|
|
1144
|
+
if (details) {
|
|
1145
|
+
return `${reason}\n\n${details}`.trim();
|
|
1146
|
+
}
|
|
1147
|
+
return reason;
|
|
1148
|
+
}
|
|
1149
|
+
/**
|
|
1150
|
+
* Get a truncated reason summary for display in the grid.
|
|
1151
|
+
* Also updates currentReasonFull for modal access.
|
|
1152
|
+
*/
|
|
1153
|
+
function summarizeReason(run) {
|
|
1154
|
+
if (!run) {
|
|
1155
|
+
currentReasonFull = null;
|
|
1156
|
+
return "No ticket flow run yet.";
|
|
1157
|
+
}
|
|
1158
|
+
const state = (run.state || {});
|
|
1159
|
+
const engine = (state.ticket_engine || {});
|
|
1160
|
+
const fullReason = getFullReason(run);
|
|
1161
|
+
currentReasonFull = fullReason;
|
|
1162
|
+
const reasonSummary = typeof run.reason_summary === "string" ? run.reason_summary : "";
|
|
1163
|
+
const useSummary = run.status === "paused" || run.status === "failed" || run.status === "stopped";
|
|
1164
|
+
const shortReason = (useSummary && reasonSummary ? reasonSummary : "") ||
|
|
1165
|
+
engine.reason ||
|
|
1166
|
+
run.error_message ||
|
|
1167
|
+
(engine.current_ticket ? `Working on ${engine.current_ticket}` : "") ||
|
|
1168
|
+
run.status ||
|
|
1169
|
+
"";
|
|
1170
|
+
// Truncate if too long
|
|
1171
|
+
if (shortReason.length > MAX_REASON_LENGTH) {
|
|
1172
|
+
return shortReason.slice(0, MAX_REASON_LENGTH - 3) + "...";
|
|
1173
|
+
}
|
|
1174
|
+
return shortReason;
|
|
1175
|
+
}
|
|
1176
|
+
async function loadTicketFiles(ctx) {
|
|
1177
|
+
const { tickets } = els();
|
|
1178
|
+
const isInitial = ticketListRefresh.getSignature() === null;
|
|
1179
|
+
if (tickets && isInitial) {
|
|
1180
|
+
tickets.textContent = "Loading tickets…";
|
|
1181
|
+
}
|
|
1182
|
+
try {
|
|
1183
|
+
await ticketListRefresh.refresh(async () => {
|
|
1184
|
+
const data = (await api("/api/flows/ticket_flow/tickets"));
|
|
1185
|
+
return {
|
|
1186
|
+
ticket_dir: data.ticket_dir,
|
|
1187
|
+
tickets: data.tickets,
|
|
1188
|
+
activeTicket: currentActiveTicket,
|
|
1189
|
+
flowStatus: currentFlowStatus,
|
|
1190
|
+
};
|
|
1191
|
+
}, { reason: ctx?.reason === "manual" ? "manual" : "background" });
|
|
1192
|
+
}
|
|
1193
|
+
catch (err) {
|
|
1194
|
+
ticketListRefresh.reset();
|
|
1195
|
+
ticketListCache = null;
|
|
1196
|
+
preserveScroll(tickets, () => {
|
|
1197
|
+
renderTickets(null);
|
|
1198
|
+
}, { restoreOnNextFrame: true });
|
|
1199
|
+
flash(err.message || "Failed to load tickets", "error");
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
/**
|
|
1203
|
+
* Open a ticket by its index
|
|
1204
|
+
*/
|
|
1205
|
+
async function openTicketByIndex(index) {
|
|
1206
|
+
try {
|
|
1207
|
+
const data = (await api("/api/flows/ticket_flow/tickets"));
|
|
1208
|
+
const ticket = data.tickets?.find((t) => t.index === index);
|
|
1209
|
+
if (ticket) {
|
|
1210
|
+
openTicketEditor(ticket);
|
|
1211
|
+
}
|
|
1212
|
+
else {
|
|
1213
|
+
flash(`Ticket TICKET-${String(index).padStart(3, "0")} not found`, "error");
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
catch (err) {
|
|
1217
|
+
flash(`Failed to open ticket: ${err.message}`, "error");
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
async function loadDispatchHistory(runId, ctx) {
|
|
1221
|
+
const { history } = els();
|
|
1222
|
+
const runChanged = dispatchHistoryRunId !== runId;
|
|
1223
|
+
if (!runId) {
|
|
1224
|
+
renderDispatchHistory(null, null);
|
|
1225
|
+
dispatchHistoryRefresh.reset();
|
|
1226
|
+
dispatchHistoryRunId = null;
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
if (runChanged) {
|
|
1230
|
+
dispatchHistoryRunId = runId;
|
|
1231
|
+
dispatchHistoryRefresh.reset();
|
|
1232
|
+
}
|
|
1233
|
+
const isInitial = dispatchHistoryRefresh.getSignature() === null;
|
|
1234
|
+
if (history && isInitial) {
|
|
1235
|
+
history.textContent = "Loading dispatch history…";
|
|
1236
|
+
}
|
|
1237
|
+
try {
|
|
1238
|
+
await dispatchHistoryRefresh.refresh(async () => {
|
|
1239
|
+
const data = (await api(`/api/flows/${runId}/dispatch_history`));
|
|
1240
|
+
return {
|
|
1241
|
+
runId,
|
|
1242
|
+
history: data.history,
|
|
1243
|
+
};
|
|
1244
|
+
}, {
|
|
1245
|
+
reason: ctx?.reason === "manual" ? "manual" : "background",
|
|
1246
|
+
force: runChanged,
|
|
1247
|
+
});
|
|
1248
|
+
}
|
|
1249
|
+
catch (err) {
|
|
1250
|
+
dispatchHistoryRefresh.reset();
|
|
1251
|
+
preserveScroll(history, () => {
|
|
1252
|
+
renderDispatchHistory(runId, null);
|
|
1253
|
+
}, { restoreOnNextFrame: true });
|
|
1254
|
+
flash(err.message || "Failed to load dispatch history", "error");
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
async function loadTicketFlow(ctx) {
|
|
1258
|
+
const { status, run, current, turn, elapsed, progress, reason, lastActivity, stalePill, reconnectBtn, workerStatus, workerPill, recoverBtn, resumeBtn, bootstrapBtn, stopBtn, archiveBtn, refreshBtn, } = els();
|
|
1259
|
+
if (!isRepoHealthy()) {
|
|
1260
|
+
if (status)
|
|
1261
|
+
statusPill(status, "error");
|
|
1262
|
+
if (run)
|
|
1263
|
+
run.textContent = "–";
|
|
1264
|
+
if (current)
|
|
1265
|
+
current.textContent = "–";
|
|
1266
|
+
if (turn)
|
|
1267
|
+
turn.textContent = "–";
|
|
1268
|
+
if (elapsed)
|
|
1269
|
+
elapsed.textContent = "–";
|
|
1270
|
+
if (progress)
|
|
1271
|
+
progress.textContent = "–";
|
|
1272
|
+
if (lastActivity)
|
|
1273
|
+
lastActivity.textContent = "–";
|
|
1274
|
+
if (stalePill)
|
|
1275
|
+
stalePill.style.display = "none";
|
|
1276
|
+
if (reconnectBtn)
|
|
1277
|
+
reconnectBtn.style.display = "none";
|
|
1278
|
+
if (workerStatus)
|
|
1279
|
+
workerStatus.textContent = "–";
|
|
1280
|
+
if (workerPill)
|
|
1281
|
+
workerPill.style.display = "none";
|
|
1282
|
+
if (recoverBtn)
|
|
1283
|
+
recoverBtn.style.display = "none";
|
|
1284
|
+
if (reason)
|
|
1285
|
+
reason.textContent = "Repo offline or uninitialized.";
|
|
1286
|
+
setButtonsDisabled(true);
|
|
1287
|
+
setButtonLoading(refreshBtn, false);
|
|
1288
|
+
stopElapsedTimer();
|
|
1289
|
+
stopLastActivityTimer();
|
|
1290
|
+
disconnectEventStream();
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
const showRefreshIndicator = ticketFlowLoaded;
|
|
1294
|
+
if (showRefreshIndicator) {
|
|
1295
|
+
setButtonLoading(refreshBtn, true);
|
|
1296
|
+
}
|
|
1297
|
+
try {
|
|
1298
|
+
const runs = (await api("/api/flows/runs?flow_type=ticket_flow"));
|
|
1299
|
+
// Only consider the newest run - if it's terminal, flow is idle.
|
|
1300
|
+
// This matches the backend's _active_or_paused_run() logic which only checks runs[0].
|
|
1301
|
+
// Using find() would incorrectly pick up older paused runs when a newer run has completed.
|
|
1302
|
+
const newest = runs?.[0] || null;
|
|
1303
|
+
// Keep the newest run even if terminal, so we can archive it or see its final state
|
|
1304
|
+
const latest = newest;
|
|
1305
|
+
currentRunId = latest?.id || null;
|
|
1306
|
+
currentFlowStatus = latest?.status || null;
|
|
1307
|
+
// Extract ticket engine state
|
|
1308
|
+
const ticketEngine = latest?.state?.ticket_engine;
|
|
1309
|
+
// The server now provides an effective current_ticket during in-flight steps.
|
|
1310
|
+
// Trust the API value even when null so we don't show stale DONE+WORKING between steps.
|
|
1311
|
+
const apiActiveTicket = ticketEngine?.current_ticket || null;
|
|
1312
|
+
currentActiveTicket = apiActiveTicket;
|
|
1313
|
+
const ticketTurns = ticketEngine?.ticket_turns ?? null;
|
|
1314
|
+
const totalTurns = ticketEngine?.total_turns ?? null;
|
|
1315
|
+
if (status)
|
|
1316
|
+
statusPill(status, latest?.status || "idle");
|
|
1317
|
+
if (run)
|
|
1318
|
+
run.textContent = latest?.id || "–";
|
|
1319
|
+
if (current)
|
|
1320
|
+
current.textContent = currentActiveTicket || "–";
|
|
1321
|
+
// Display turn counter
|
|
1322
|
+
if (turn) {
|
|
1323
|
+
if (ticketTurns !== null && isFlowActiveStatus(currentFlowStatus)) {
|
|
1324
|
+
turn.textContent = `${ticketTurns}${totalTurns !== null ? ` (${totalTurns} total)` : ""}`;
|
|
1325
|
+
}
|
|
1326
|
+
else {
|
|
1327
|
+
turn.textContent = "–";
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
// Handle elapsed time
|
|
1331
|
+
if (latest?.started_at && (latest.status === "running" || latest.status === "pending")) {
|
|
1332
|
+
flowStartedAt = new Date(latest.started_at);
|
|
1333
|
+
startElapsedTimer();
|
|
1334
|
+
}
|
|
1335
|
+
else {
|
|
1336
|
+
stopElapsedTimer();
|
|
1337
|
+
flowStartedAt = null;
|
|
1338
|
+
if (elapsed)
|
|
1339
|
+
elapsed.textContent = "–";
|
|
1340
|
+
}
|
|
1341
|
+
if (reason) {
|
|
1342
|
+
reason.textContent = summarizeReason(latest) || "–";
|
|
1343
|
+
// Add clickable class if there are details to show
|
|
1344
|
+
const state = (latest?.state || {});
|
|
1345
|
+
const engine = (state.ticket_engine || {});
|
|
1346
|
+
const hasDetails = Boolean(engine.reason_details ||
|
|
1347
|
+
(currentReasonFull && currentReasonFull.length > MAX_REASON_LENGTH));
|
|
1348
|
+
reason.classList.toggle("has-details", hasDetails);
|
|
1349
|
+
}
|
|
1350
|
+
lastKnownEventSeq = typeof latest?.last_event_seq === "number" ? latest.last_event_seq : null;
|
|
1351
|
+
if (currentRunId && typeof lastKnownEventSeq === "number") {
|
|
1352
|
+
setLastSeenSeq(currentRunId, lastKnownEventSeq);
|
|
1353
|
+
}
|
|
1354
|
+
updateLastActivityFromTimestamp(latest?.last_event_at || null);
|
|
1355
|
+
const isActive = latest?.status === "running" || latest?.status === "pending";
|
|
1356
|
+
const isStale = Boolean(isActive &&
|
|
1357
|
+
lastKnownEventAt &&
|
|
1358
|
+
Date.now() - lastKnownEventAt.getTime() > STALE_THRESHOLD_MS);
|
|
1359
|
+
if (stalePill)
|
|
1360
|
+
stalePill.style.display = isStale ? "" : "none";
|
|
1361
|
+
if (reconnectBtn) {
|
|
1362
|
+
reconnectBtn.style.display = isStale ? "" : "none";
|
|
1363
|
+
reconnectBtn.disabled = !currentRunId;
|
|
1364
|
+
}
|
|
1365
|
+
const worker = latest?.worker_health;
|
|
1366
|
+
const workerLabel = worker?.status
|
|
1367
|
+
? `${worker.status}${worker.pid ? ` (pid ${worker.pid})` : ""}`
|
|
1368
|
+
: "–";
|
|
1369
|
+
if (workerStatus)
|
|
1370
|
+
workerStatus.textContent = workerLabel;
|
|
1371
|
+
const workerDead = Boolean(isActive &&
|
|
1372
|
+
worker &&
|
|
1373
|
+
worker.is_alive === false &&
|
|
1374
|
+
worker.status !== "absent");
|
|
1375
|
+
if (workerPill)
|
|
1376
|
+
workerPill.style.display = workerDead ? "" : "none";
|
|
1377
|
+
if (recoverBtn) {
|
|
1378
|
+
recoverBtn.style.display = workerDead ? "" : "none";
|
|
1379
|
+
recoverBtn.disabled = !currentRunId;
|
|
1380
|
+
}
|
|
1381
|
+
if (resumeBtn) {
|
|
1382
|
+
resumeBtn.disabled = !latest?.id || latest.status !== "paused";
|
|
1383
|
+
}
|
|
1384
|
+
if (stopBtn) {
|
|
1385
|
+
const stoppable = latest?.status === "running" || latest?.status === "pending";
|
|
1386
|
+
stopBtn.disabled = !latest?.id || !stoppable;
|
|
1387
|
+
}
|
|
1388
|
+
await loadTicketFiles(ctx);
|
|
1389
|
+
// Calculate and display ticket progress (scoped to tickets container only)
|
|
1390
|
+
if (progress) {
|
|
1391
|
+
const ticketsContainer = document.getElementById("ticket-flow-tickets");
|
|
1392
|
+
const doneCount = ticketsContainer?.querySelectorAll(".ticket-item.done").length ?? 0;
|
|
1393
|
+
const totalCount = ticketsContainer?.querySelectorAll(".ticket-item").length ?? 0;
|
|
1394
|
+
if (totalCount > 0) {
|
|
1395
|
+
progress.textContent = `${doneCount} of ${totalCount} done`;
|
|
1396
|
+
}
|
|
1397
|
+
else {
|
|
1398
|
+
progress.textContent = "–";
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
// Connect/disconnect event stream based on flow status
|
|
1402
|
+
if (currentRunId && (latest?.status === "running" || latest?.status === "pending")) {
|
|
1403
|
+
// Only connect if not already connected to this run
|
|
1404
|
+
const isSameRun = eventSourceRunId === currentRunId;
|
|
1405
|
+
const isClosed = eventSource?.readyState === EventSource.CLOSED;
|
|
1406
|
+
if (!eventSource || !isSameRun || isClosed) {
|
|
1407
|
+
connectEventStream(currentRunId);
|
|
1408
|
+
startLastActivityTimer();
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
else {
|
|
1412
|
+
disconnectEventStream();
|
|
1413
|
+
if (!lastKnownEventAt) {
|
|
1414
|
+
stopLastActivityTimer();
|
|
1415
|
+
if (lastActivity)
|
|
1416
|
+
lastActivity.textContent = "–";
|
|
1417
|
+
lastActivityTime = null;
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
if (bootstrapBtn) {
|
|
1421
|
+
const busy = latest?.status === "running" || latest?.status === "pending";
|
|
1422
|
+
// Disable only if busy; bootstrap will create initial ticket when missing
|
|
1423
|
+
bootstrapBtn.disabled = busy;
|
|
1424
|
+
bootstrapBtn.textContent = busy ? "Running…" : "Start Ticket Flow";
|
|
1425
|
+
bootstrapBtn.title = busy ? "Ticket flow in progress" : "";
|
|
1426
|
+
}
|
|
1427
|
+
// Show restart button when flow is paused, stopping, or in terminal state (allows starting fresh)
|
|
1428
|
+
const { restartBtn, overflowRestart } = els();
|
|
1429
|
+
if (restartBtn) {
|
|
1430
|
+
const isPaused = latest?.status === "paused";
|
|
1431
|
+
const isStopping = latest?.status === "stopping";
|
|
1432
|
+
const isTerminal = latest?.status === "completed" ||
|
|
1433
|
+
latest?.status === "stopped" ||
|
|
1434
|
+
latest?.status === "failed";
|
|
1435
|
+
const canRestart = (isPaused || isStopping || isTerminal || workerDead) &&
|
|
1436
|
+
ticketsExist &&
|
|
1437
|
+
Boolean(currentRunId);
|
|
1438
|
+
restartBtn.style.display = canRestart ? "" : "none";
|
|
1439
|
+
restartBtn.disabled = !canRestart;
|
|
1440
|
+
if (overflowRestart) {
|
|
1441
|
+
overflowRestart.style.display = canRestart ? "" : "none";
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
// Show archive button when flow is paused, stopping, or in terminal state and has tickets
|
|
1445
|
+
if (archiveBtn) {
|
|
1446
|
+
const isPaused = latest?.status === "paused";
|
|
1447
|
+
const isStopping = latest?.status === "stopping";
|
|
1448
|
+
const isTerminal = latest?.status === "completed" ||
|
|
1449
|
+
latest?.status === "stopped" ||
|
|
1450
|
+
latest?.status === "failed";
|
|
1451
|
+
const canArchive = (isPaused || isStopping || isTerminal) && ticketsExist && Boolean(currentRunId);
|
|
1452
|
+
archiveBtn.style.display = canArchive ? "" : "none";
|
|
1453
|
+
archiveBtn.disabled = !canArchive;
|
|
1454
|
+
const { overflowArchive } = els();
|
|
1455
|
+
if (overflowArchive) {
|
|
1456
|
+
overflowArchive.style.display = canArchive ? "" : "none";
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
await loadDispatchHistory(currentRunId, ctx);
|
|
1460
|
+
}
|
|
1461
|
+
catch (err) {
|
|
1462
|
+
if (reason)
|
|
1463
|
+
reason.textContent = err.message || "Ticket flow unavailable";
|
|
1464
|
+
flash(err.message || "Failed to load ticket flow state", "error");
|
|
1465
|
+
}
|
|
1466
|
+
finally {
|
|
1467
|
+
ticketFlowLoaded = true;
|
|
1468
|
+
if (showRefreshIndicator) {
|
|
1469
|
+
setButtonLoading(refreshBtn, false);
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
async function bootstrapTicketFlow() {
|
|
1474
|
+
const { bootstrapBtn } = els();
|
|
1475
|
+
if (!bootstrapBtn)
|
|
1476
|
+
return;
|
|
1477
|
+
if (!isRepoHealthy()) {
|
|
1478
|
+
flash("Repo offline; cannot start ticket flow.", "error");
|
|
1479
|
+
return;
|
|
1480
|
+
}
|
|
1481
|
+
setButtonsDisabled(true);
|
|
1482
|
+
bootstrapBtn.textContent = "Checking…";
|
|
1483
|
+
const startFlow = async () => {
|
|
1484
|
+
const res = (await api("/api/flows/ticket_flow/bootstrap", {
|
|
1485
|
+
method: "POST",
|
|
1486
|
+
body: {},
|
|
1487
|
+
}));
|
|
1488
|
+
currentRunId = res?.id || null;
|
|
1489
|
+
if (res?.state?.hint === "active_run_reused") {
|
|
1490
|
+
flash("Ticket flow already running; continuing existing run", "info");
|
|
1491
|
+
}
|
|
1492
|
+
else {
|
|
1493
|
+
flash("Ticket flow started");
|
|
1494
|
+
clearLiveOutput(); // Clear output for new run
|
|
1495
|
+
}
|
|
1496
|
+
await loadTicketFlow();
|
|
1497
|
+
};
|
|
1498
|
+
const seedIssueFromGithub = async (issueRef) => {
|
|
1499
|
+
await api("/api/flows/ticket_flow/seed-issue", {
|
|
1500
|
+
method: "POST",
|
|
1501
|
+
body: { issue_ref: issueRef },
|
|
1502
|
+
});
|
|
1503
|
+
flash("ISSUE.md created from GitHub", "success");
|
|
1504
|
+
};
|
|
1505
|
+
const seedIssueFromPlan = async (planText) => {
|
|
1506
|
+
await api("/api/flows/ticket_flow/seed-issue", {
|
|
1507
|
+
method: "POST",
|
|
1508
|
+
body: { plan_text: planText },
|
|
1509
|
+
});
|
|
1510
|
+
flash("ISSUE.md created from your input", "success");
|
|
1511
|
+
};
|
|
1512
|
+
const promptIssueRef = async (repo) => {
|
|
1513
|
+
const message = repo
|
|
1514
|
+
? `Enter GitHub issue number or URL for ${repo}`
|
|
1515
|
+
: "Enter GitHub issue number or URL";
|
|
1516
|
+
const input = await inputModal(message, {
|
|
1517
|
+
placeholder: "#123 or https://github.com/org/repo/issues/123",
|
|
1518
|
+
confirmText: "Fetch issue",
|
|
1519
|
+
});
|
|
1520
|
+
const value = (input || "").trim();
|
|
1521
|
+
return value || null;
|
|
1522
|
+
};
|
|
1523
|
+
const promptPlanText = async () => {
|
|
1524
|
+
// Build a simple textarea modal dynamically to avoid new HTML templates.
|
|
1525
|
+
const overlay = document.createElement("div");
|
|
1526
|
+
overlay.className = "modal-overlay";
|
|
1527
|
+
overlay.hidden = true;
|
|
1528
|
+
const dialog = document.createElement("div");
|
|
1529
|
+
dialog.className = "modal-dialog";
|
|
1530
|
+
dialog.setAttribute("role", "dialog");
|
|
1531
|
+
dialog.setAttribute("aria-modal", "true");
|
|
1532
|
+
dialog.tabIndex = -1;
|
|
1533
|
+
const title = document.createElement("h3");
|
|
1534
|
+
title.textContent = "Describe the work";
|
|
1535
|
+
const textarea = document.createElement("textarea");
|
|
1536
|
+
textarea.placeholder = "Describe the scope/requirements to seed ISSUE.md";
|
|
1537
|
+
textarea.rows = 6;
|
|
1538
|
+
textarea.style.width = "100%";
|
|
1539
|
+
textarea.style.resize = "vertical";
|
|
1540
|
+
const actions = document.createElement("div");
|
|
1541
|
+
actions.className = "modal-actions";
|
|
1542
|
+
const cancel = document.createElement("button");
|
|
1543
|
+
cancel.className = "ghost";
|
|
1544
|
+
cancel.textContent = "Cancel";
|
|
1545
|
+
const submit = document.createElement("button");
|
|
1546
|
+
submit.className = "primary";
|
|
1547
|
+
submit.textContent = "Create ISSUE.md";
|
|
1548
|
+
actions.append(cancel, submit);
|
|
1549
|
+
dialog.append(title, textarea, actions);
|
|
1550
|
+
overlay.append(dialog);
|
|
1551
|
+
document.body.append(overlay);
|
|
1552
|
+
return await new Promise((resolve) => {
|
|
1553
|
+
let closeModal = null;
|
|
1554
|
+
const cleanup = () => {
|
|
1555
|
+
if (closeModal)
|
|
1556
|
+
closeModal();
|
|
1557
|
+
overlay.remove();
|
|
1558
|
+
};
|
|
1559
|
+
const finalize = (value) => {
|
|
1560
|
+
cleanup();
|
|
1561
|
+
resolve(value);
|
|
1562
|
+
};
|
|
1563
|
+
closeModal = openModal(overlay, {
|
|
1564
|
+
initialFocus: textarea,
|
|
1565
|
+
returnFocusTo: bootstrapBtn,
|
|
1566
|
+
onRequestClose: () => finalize(null),
|
|
1567
|
+
});
|
|
1568
|
+
submit.addEventListener("click", () => {
|
|
1569
|
+
finalize(textarea.value.trim() || null);
|
|
1570
|
+
});
|
|
1571
|
+
cancel.addEventListener("click", () => finalize(null));
|
|
1572
|
+
});
|
|
1573
|
+
};
|
|
1574
|
+
try {
|
|
1575
|
+
const check = (await api("/api/flows/ticket_flow/bootstrap-check", {
|
|
1576
|
+
method: "GET",
|
|
1577
|
+
}));
|
|
1578
|
+
if (check.status === "ready") {
|
|
1579
|
+
await startFlow();
|
|
1580
|
+
return;
|
|
1581
|
+
}
|
|
1582
|
+
if (check.status === "needs_issue") {
|
|
1583
|
+
if (check.github_available) {
|
|
1584
|
+
const issueRef = await promptIssueRef(check.repo);
|
|
1585
|
+
if (!issueRef) {
|
|
1586
|
+
flash("Bootstrap cancelled (no issue provided)", "info");
|
|
1587
|
+
return;
|
|
1588
|
+
}
|
|
1589
|
+
await seedIssueFromGithub(issueRef);
|
|
1590
|
+
}
|
|
1591
|
+
else {
|
|
1592
|
+
const planText = await promptPlanText();
|
|
1593
|
+
if (!planText) {
|
|
1594
|
+
flash("Bootstrap cancelled (no description provided)", "info");
|
|
1595
|
+
return;
|
|
1596
|
+
}
|
|
1597
|
+
await seedIssueFromPlan(planText);
|
|
1598
|
+
}
|
|
1599
|
+
await startFlow();
|
|
1600
|
+
return;
|
|
1601
|
+
}
|
|
1602
|
+
// Fallback: start normally
|
|
1603
|
+
await startFlow();
|
|
1604
|
+
}
|
|
1605
|
+
catch (err) {
|
|
1606
|
+
flash(err.message || "Failed to start ticket flow", "error");
|
|
1607
|
+
}
|
|
1608
|
+
finally {
|
|
1609
|
+
bootstrapBtn.textContent = "Start Ticket Flow";
|
|
1610
|
+
setButtonsDisabled(false);
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
async function resumeTicketFlow() {
|
|
1614
|
+
const { resumeBtn } = els();
|
|
1615
|
+
if (!resumeBtn)
|
|
1616
|
+
return;
|
|
1617
|
+
if (!isRepoHealthy()) {
|
|
1618
|
+
flash("Repo offline; cannot resume ticket flow.", "error");
|
|
1619
|
+
return;
|
|
1620
|
+
}
|
|
1621
|
+
if (!currentRunId) {
|
|
1622
|
+
flash("No ticket flow run to resume", "info");
|
|
1623
|
+
return;
|
|
1624
|
+
}
|
|
1625
|
+
setButtonsDisabled(true);
|
|
1626
|
+
resumeBtn.textContent = "Resuming…";
|
|
1627
|
+
try {
|
|
1628
|
+
await api(`/api/flows/${currentRunId}/resume`, { method: "POST", body: {} });
|
|
1629
|
+
flash("Ticket flow resumed");
|
|
1630
|
+
await loadTicketFlow();
|
|
1631
|
+
}
|
|
1632
|
+
catch (err) {
|
|
1633
|
+
flash(err.message || "Failed to resume", "error");
|
|
1634
|
+
}
|
|
1635
|
+
finally {
|
|
1636
|
+
resumeBtn.textContent = "Resume";
|
|
1637
|
+
setButtonsDisabled(false);
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
function reconnectTicketFlowStream() {
|
|
1641
|
+
if (!currentRunId) {
|
|
1642
|
+
flash("No ticket flow run to reconnect", "info");
|
|
1643
|
+
return;
|
|
1644
|
+
}
|
|
1645
|
+
const afterSeq = typeof lastKnownEventSeq === "number"
|
|
1646
|
+
? lastKnownEventSeq
|
|
1647
|
+
: getLastSeenSeq(currentRunId);
|
|
1648
|
+
connectEventStream(currentRunId, afterSeq ?? undefined);
|
|
1649
|
+
flash("Reconnecting event stream", "info");
|
|
1650
|
+
}
|
|
1651
|
+
async function stopTicketFlow() {
|
|
1652
|
+
const { stopBtn } = els();
|
|
1653
|
+
if (!stopBtn)
|
|
1654
|
+
return;
|
|
1655
|
+
if (!isRepoHealthy()) {
|
|
1656
|
+
flash("Repo offline; cannot stop ticket flow.", "error");
|
|
1657
|
+
return;
|
|
1658
|
+
}
|
|
1659
|
+
if (!currentRunId) {
|
|
1660
|
+
flash("No ticket flow run to stop", "info");
|
|
1661
|
+
return;
|
|
1662
|
+
}
|
|
1663
|
+
setButtonsDisabled(true);
|
|
1664
|
+
stopBtn.textContent = "Stopping…";
|
|
1665
|
+
try {
|
|
1666
|
+
await api(`/api/flows/${currentRunId}/stop`, { method: "POST", body: {} });
|
|
1667
|
+
flash("Ticket flow stopping");
|
|
1668
|
+
await loadTicketFlow();
|
|
1669
|
+
}
|
|
1670
|
+
catch (err) {
|
|
1671
|
+
flash(err.message || "Failed to stop ticket flow", "error");
|
|
1672
|
+
}
|
|
1673
|
+
finally {
|
|
1674
|
+
stopBtn.textContent = "Stop";
|
|
1675
|
+
setButtonsDisabled(false);
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
async function recoverTicketFlow() {
|
|
1679
|
+
const { recoverBtn } = els();
|
|
1680
|
+
if (!recoverBtn)
|
|
1681
|
+
return;
|
|
1682
|
+
if (!isRepoHealthy()) {
|
|
1683
|
+
flash("Repo offline; cannot recover ticket flow.", "error");
|
|
1684
|
+
return;
|
|
1685
|
+
}
|
|
1686
|
+
if (!currentRunId) {
|
|
1687
|
+
flash("No ticket flow run to recover", "info");
|
|
1688
|
+
return;
|
|
1689
|
+
}
|
|
1690
|
+
setButtonsDisabled(true);
|
|
1691
|
+
recoverBtn.textContent = "Recovering…";
|
|
1692
|
+
try {
|
|
1693
|
+
await api(`/api/flows/${currentRunId}/reconcile`, { method: "POST", body: {} });
|
|
1694
|
+
flash("Flow reconciled");
|
|
1695
|
+
await loadTicketFlow();
|
|
1696
|
+
}
|
|
1697
|
+
catch (err) {
|
|
1698
|
+
flash(err.message || "Failed to recover ticket flow", "error");
|
|
1699
|
+
}
|
|
1700
|
+
finally {
|
|
1701
|
+
recoverBtn.textContent = "Recover";
|
|
1702
|
+
setButtonsDisabled(false);
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
async function restartTicketFlow() {
|
|
1706
|
+
const { restartBtn } = els();
|
|
1707
|
+
if (!restartBtn)
|
|
1708
|
+
return;
|
|
1709
|
+
if (!isRepoHealthy()) {
|
|
1710
|
+
flash("Repo offline; cannot restart ticket flow.", "error");
|
|
1711
|
+
return;
|
|
1712
|
+
}
|
|
1713
|
+
if (!ticketsExist) {
|
|
1714
|
+
flash("Create a ticket first before restarting the flow.", "error");
|
|
1715
|
+
return;
|
|
1716
|
+
}
|
|
1717
|
+
if (!confirm("Restart ticket flow? This will stop the current run and start a new one.")) {
|
|
1718
|
+
return;
|
|
1719
|
+
}
|
|
1720
|
+
setButtonsDisabled(true);
|
|
1721
|
+
restartBtn.textContent = "Restarting…";
|
|
1722
|
+
try {
|
|
1723
|
+
// Stop the current run first if it exists
|
|
1724
|
+
if (currentRunId) {
|
|
1725
|
+
await api(`/api/flows/${currentRunId}/stop`, { method: "POST", body: {} });
|
|
1726
|
+
}
|
|
1727
|
+
// Start a new run with force_new to bypass reuse logic
|
|
1728
|
+
const res = (await api("/api/flows/ticket_flow/bootstrap", {
|
|
1729
|
+
method: "POST",
|
|
1730
|
+
body: { metadata: { force_new: true } },
|
|
1731
|
+
}));
|
|
1732
|
+
currentRunId = res?.id || null;
|
|
1733
|
+
flash("Ticket flow restarted");
|
|
1734
|
+
clearLiveOutput();
|
|
1735
|
+
await loadTicketFlow();
|
|
1736
|
+
}
|
|
1737
|
+
catch (err) {
|
|
1738
|
+
flash(err.message || "Failed to restart ticket flow", "error");
|
|
1739
|
+
}
|
|
1740
|
+
finally {
|
|
1741
|
+
restartBtn.textContent = "Restart";
|
|
1742
|
+
setButtonsDisabled(false);
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
async function archiveTicketFlow() {
|
|
1746
|
+
const { archiveBtn, reason } = els();
|
|
1747
|
+
if (!archiveBtn)
|
|
1748
|
+
return;
|
|
1749
|
+
if (!isRepoHealthy()) {
|
|
1750
|
+
flash("Repo offline; cannot archive ticket flow.", "error");
|
|
1751
|
+
return;
|
|
1752
|
+
}
|
|
1753
|
+
if (!currentRunId) {
|
|
1754
|
+
flash("No ticket flow run to archive", "info");
|
|
1755
|
+
return;
|
|
1756
|
+
}
|
|
1757
|
+
if (!confirm("Archive all tickets from this flow? They will be moved to the run's artifact directory.")) {
|
|
1758
|
+
return;
|
|
1759
|
+
}
|
|
1760
|
+
setButtonsDisabled(true);
|
|
1761
|
+
archiveBtn.textContent = "Archiving…";
|
|
1762
|
+
try {
|
|
1763
|
+
// Force archive if flow is stuck in stopping or paused state
|
|
1764
|
+
const force = currentFlowStatus === "stopping" || currentFlowStatus === "paused";
|
|
1765
|
+
const res = (await api(`/api/flows/${currentRunId}/archive?force=${force}`, {
|
|
1766
|
+
method: "POST",
|
|
1767
|
+
body: {},
|
|
1768
|
+
}));
|
|
1769
|
+
const count = res?.tickets_archived ?? 0;
|
|
1770
|
+
flash(`Archived ${count} ticket${count !== 1 ? "s" : ""}`);
|
|
1771
|
+
clearLiveOutput();
|
|
1772
|
+
// Reset all state variables
|
|
1773
|
+
currentRunId = null;
|
|
1774
|
+
currentFlowStatus = null;
|
|
1775
|
+
currentActiveTicket = null;
|
|
1776
|
+
currentReasonFull = null;
|
|
1777
|
+
// Reset all UI elements to idle state directly (avoid re-fetching stale data)
|
|
1778
|
+
const { status, run, current, turn, elapsed, progress, lastActivity, stalePill, reconnectBtn, workerStatus, workerPill, recoverBtn, bootstrapBtn, resumeBtn, stopBtn, restartBtn, archiveBtn } = els();
|
|
1779
|
+
if (status)
|
|
1780
|
+
statusPill(status, "idle");
|
|
1781
|
+
if (run)
|
|
1782
|
+
run.textContent = "–";
|
|
1783
|
+
if (current)
|
|
1784
|
+
current.textContent = "–";
|
|
1785
|
+
if (turn)
|
|
1786
|
+
turn.textContent = "–";
|
|
1787
|
+
if (elapsed)
|
|
1788
|
+
elapsed.textContent = "–";
|
|
1789
|
+
if (progress)
|
|
1790
|
+
progress.textContent = "–";
|
|
1791
|
+
if (lastActivity)
|
|
1792
|
+
lastActivity.textContent = "–";
|
|
1793
|
+
if (stalePill)
|
|
1794
|
+
stalePill.style.display = "none";
|
|
1795
|
+
if (reconnectBtn)
|
|
1796
|
+
reconnectBtn.style.display = "none";
|
|
1797
|
+
if (workerStatus)
|
|
1798
|
+
workerStatus.textContent = "–";
|
|
1799
|
+
if (workerPill)
|
|
1800
|
+
workerPill.style.display = "none";
|
|
1801
|
+
if (recoverBtn)
|
|
1802
|
+
recoverBtn.style.display = "none";
|
|
1803
|
+
if (reason) {
|
|
1804
|
+
reason.textContent = "No ticket flow run yet.";
|
|
1805
|
+
reason.classList.remove("has-details");
|
|
1806
|
+
}
|
|
1807
|
+
renderDispatchHistory(null, null);
|
|
1808
|
+
// Stop timers and disconnect event stream
|
|
1809
|
+
disconnectEventStream();
|
|
1810
|
+
stopElapsedTimer();
|
|
1811
|
+
stopLastActivityTimer();
|
|
1812
|
+
lastActivityTime = null;
|
|
1813
|
+
// Update button states for no active run
|
|
1814
|
+
if (bootstrapBtn) {
|
|
1815
|
+
bootstrapBtn.disabled = false;
|
|
1816
|
+
bootstrapBtn.textContent = "Start Ticket Flow";
|
|
1817
|
+
bootstrapBtn.title = "";
|
|
1818
|
+
}
|
|
1819
|
+
if (resumeBtn)
|
|
1820
|
+
resumeBtn.disabled = true;
|
|
1821
|
+
if (stopBtn)
|
|
1822
|
+
stopBtn.disabled = true;
|
|
1823
|
+
if (restartBtn)
|
|
1824
|
+
restartBtn.style.display = "none";
|
|
1825
|
+
const { overflowRestart, overflowArchive } = els();
|
|
1826
|
+
if (overflowRestart)
|
|
1827
|
+
overflowRestart.style.display = "none";
|
|
1828
|
+
if (archiveBtn)
|
|
1829
|
+
archiveBtn.style.display = "none";
|
|
1830
|
+
if (overflowArchive)
|
|
1831
|
+
overflowArchive.style.display = "none";
|
|
1832
|
+
// Refresh inbox badge and ticket list (tickets were archived/moved)
|
|
1833
|
+
void refreshBell();
|
|
1834
|
+
await loadTicketFiles();
|
|
1835
|
+
}
|
|
1836
|
+
catch (err) {
|
|
1837
|
+
flash(err.message || "Failed to archive ticket flow", "error");
|
|
1838
|
+
}
|
|
1839
|
+
finally {
|
|
1840
|
+
if (archiveBtn) {
|
|
1841
|
+
archiveBtn.textContent = "Archive Flow";
|
|
1842
|
+
}
|
|
1843
|
+
setButtonsDisabled(false);
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
export function initTicketFlow() {
|
|
1847
|
+
const { card, bootstrapBtn, resumeBtn, refreshBtn, stopBtn, restartBtn, archiveBtn, reconnectBtn, recoverBtn } = els();
|
|
1848
|
+
if (!card || card.dataset.ticketInitialized === "1")
|
|
1849
|
+
return;
|
|
1850
|
+
card.dataset.ticketInitialized = "1";
|
|
1851
|
+
if (bootstrapBtn)
|
|
1852
|
+
bootstrapBtn.addEventListener("click", bootstrapTicketFlow);
|
|
1853
|
+
if (resumeBtn)
|
|
1854
|
+
resumeBtn.addEventListener("click", resumeTicketFlow);
|
|
1855
|
+
if (stopBtn)
|
|
1856
|
+
stopBtn.addEventListener("click", stopTicketFlow);
|
|
1857
|
+
if (restartBtn)
|
|
1858
|
+
restartBtn.addEventListener("click", restartTicketFlow);
|
|
1859
|
+
if (archiveBtn)
|
|
1860
|
+
archiveBtn.addEventListener("click", archiveTicketFlow);
|
|
1861
|
+
if (reconnectBtn)
|
|
1862
|
+
reconnectBtn.addEventListener("click", reconnectTicketFlowStream);
|
|
1863
|
+
if (recoverBtn)
|
|
1864
|
+
recoverBtn.addEventListener("click", recoverTicketFlow);
|
|
1865
|
+
if (refreshBtn)
|
|
1866
|
+
refreshBtn.addEventListener("click", () => {
|
|
1867
|
+
void loadTicketFlow({ reason: "manual" });
|
|
1868
|
+
});
|
|
1869
|
+
const { overflowToggle, overflowDropdown, overflowNew, overflowRestart, overflowArchive } = els();
|
|
1870
|
+
if (overflowToggle && overflowDropdown) {
|
|
1871
|
+
const toggleMenu = (e) => {
|
|
1872
|
+
e.stopPropagation();
|
|
1873
|
+
const isHidden = overflowDropdown.classList.contains("hidden");
|
|
1874
|
+
overflowDropdown.classList.toggle("hidden", !isHidden);
|
|
1875
|
+
};
|
|
1876
|
+
overflowToggle.addEventListener("click", toggleMenu);
|
|
1877
|
+
overflowToggle.addEventListener("touchend", (e) => {
|
|
1878
|
+
e.preventDefault(); // Prevent ghost click
|
|
1879
|
+
toggleMenu(e);
|
|
1880
|
+
});
|
|
1881
|
+
// Close on outside click
|
|
1882
|
+
document.addEventListener("click", (e) => {
|
|
1883
|
+
if (!overflowDropdown.classList.contains("hidden") &&
|
|
1884
|
+
!overflowToggle.contains(e.target) &&
|
|
1885
|
+
!overflowDropdown.contains(e.target)) {
|
|
1886
|
+
overflowDropdown.classList.add("hidden");
|
|
1887
|
+
}
|
|
1888
|
+
});
|
|
1889
|
+
}
|
|
1890
|
+
if (overflowNew) {
|
|
1891
|
+
overflowNew.addEventListener("click", () => {
|
|
1892
|
+
const newBtn = document.getElementById("ticket-new-btn");
|
|
1893
|
+
newBtn?.click();
|
|
1894
|
+
overflowDropdown?.classList.add("hidden");
|
|
1895
|
+
});
|
|
1896
|
+
}
|
|
1897
|
+
if (overflowRestart) {
|
|
1898
|
+
overflowRestart.addEventListener("click", () => {
|
|
1899
|
+
void restartTicketFlow();
|
|
1900
|
+
overflowDropdown?.classList.add("hidden");
|
|
1901
|
+
});
|
|
1902
|
+
}
|
|
1903
|
+
if (overflowArchive) {
|
|
1904
|
+
overflowArchive.addEventListener("click", () => {
|
|
1905
|
+
void archiveTicketFlow();
|
|
1906
|
+
overflowDropdown?.classList.add("hidden");
|
|
1907
|
+
});
|
|
1908
|
+
}
|
|
1909
|
+
// Initialize reason click handler for modal
|
|
1910
|
+
initReasonModal();
|
|
1911
|
+
// Initialize live output panel
|
|
1912
|
+
initLiveOutputPanel();
|
|
1913
|
+
// Initialize dispatch panel toggle for medium screens
|
|
1914
|
+
initDispatchPanelToggle();
|
|
1915
|
+
// Set up scroll listeners for fade indicator
|
|
1916
|
+
const ticketList = document.getElementById("ticket-flow-tickets");
|
|
1917
|
+
const dispatchHistory = document.getElementById("ticket-dispatch-history");
|
|
1918
|
+
[ticketList, dispatchHistory].forEach((el) => {
|
|
1919
|
+
if (el) {
|
|
1920
|
+
el.addEventListener("scroll", updateScrollFade, { passive: true });
|
|
1921
|
+
}
|
|
1922
|
+
});
|
|
1923
|
+
const newThreadBtn = document.getElementById("ticket-chat-new-thread");
|
|
1924
|
+
if (newThreadBtn) {
|
|
1925
|
+
newThreadBtn.addEventListener("click", async () => {
|
|
1926
|
+
const { startNewTicketChatThread } = await import("./ticketChatActions.js");
|
|
1927
|
+
await startNewTicketChatThread();
|
|
1928
|
+
});
|
|
1929
|
+
}
|
|
1930
|
+
// Initialize the ticket editor modal
|
|
1931
|
+
initTicketEditor();
|
|
1932
|
+
loadTicketFlow();
|
|
1933
|
+
registerAutoRefresh("ticket-flow", {
|
|
1934
|
+
callback: async (ctx) => {
|
|
1935
|
+
await loadTicketFlow(ctx);
|
|
1936
|
+
},
|
|
1937
|
+
tabId: "tickets",
|
|
1938
|
+
interval: CONSTANTS.UI?.AUTO_REFRESH_INTERVAL ||
|
|
1939
|
+
15000,
|
|
1940
|
+
refreshOnActivation: true,
|
|
1941
|
+
immediate: false,
|
|
1942
|
+
});
|
|
1943
|
+
subscribe("repo:health", (payload) => {
|
|
1944
|
+
const status = payload?.status || "";
|
|
1945
|
+
if (status === "ok" || status === "degraded") {
|
|
1946
|
+
void loadTicketFlow();
|
|
1947
|
+
}
|
|
1948
|
+
});
|
|
1949
|
+
// Refresh ticket list when tickets are updated (from editor)
|
|
1950
|
+
subscribe("tickets:updated", () => {
|
|
1951
|
+
void loadTicketFiles();
|
|
1952
|
+
});
|
|
1953
|
+
// Update selection when editor opens a ticket
|
|
1954
|
+
subscribe("ticket-editor:opened", (payload) => {
|
|
1955
|
+
const data = payload;
|
|
1956
|
+
if (data?.path) {
|
|
1957
|
+
updateSelectedTicket(data.path);
|
|
1958
|
+
return;
|
|
1959
|
+
}
|
|
1960
|
+
if (data?.index != null && ticketListCache?.tickets?.length) {
|
|
1961
|
+
const match = ticketListCache.tickets.find((ticket) => ticket.index === data.index);
|
|
1962
|
+
if (match?.path) {
|
|
1963
|
+
updateSelectedTicket(match.path);
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
});
|
|
1967
|
+
// Clear selection when editor is closed
|
|
1968
|
+
subscribe("ticket-editor:closed", () => {
|
|
1969
|
+
updateSelectedTicket(null);
|
|
1970
|
+
});
|
|
1971
|
+
// Handle browser navigation (back/forward)
|
|
1972
|
+
window.addEventListener("popstate", () => {
|
|
1973
|
+
const params = getUrlParams();
|
|
1974
|
+
const ticketIndex = params.get("ticket");
|
|
1975
|
+
if (ticketIndex) {
|
|
1976
|
+
void openTicketByIndex(parseInt(ticketIndex, 10));
|
|
1977
|
+
}
|
|
1978
|
+
else {
|
|
1979
|
+
closeTicketEditor();
|
|
1980
|
+
}
|
|
1981
|
+
});
|
|
1982
|
+
// Check URL for ticket param on initial load
|
|
1983
|
+
const params = getUrlParams();
|
|
1984
|
+
const ticketIndex = params.get("ticket");
|
|
1985
|
+
if (ticketIndex) {
|
|
1986
|
+
void openTicketByIndex(parseInt(ticketIndex, 10));
|
|
1987
|
+
}
|
|
1988
|
+
}
|