loadtoagent 1.3.7 → 1.3.9

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.
@@ -6,6 +6,7 @@ window.LoadToAgentAppFactories.createDrawerContent = function createDrawerConten
6
6
  const {
7
7
  esc, uiLocale, state, messageContentHtml, compact, fullNumber, timeOnly, providerInfo, statusIcon, agentPathTaskName, snapshotSession,
8
8
  controlRoomAgentGoal, inferredExecutionSummary, executionActivityLabel, executionActivityStatus,
9
+ conversationDeliveryState, observeConversationDelivery,
9
10
  } = context;
10
11
  const t = (key, params) => window.LoadToAgentI18n.t(key, params);
11
12
 
@@ -38,7 +39,7 @@ window.LoadToAgentAppFactories.createDrawerContent = function createDrawerConten
38
39
  if (message.role === "tool" && current && current.assistants.length) current.activityAfterAssistant = true;
39
40
  }
40
41
  const latestIndex = turns.length - 1;
41
- const live = session.status === "running" || session.status === "starting";
42
+ const live = options.forceLatestLive || session.status === "running" || session.status === "starting";
42
43
  return turns.map((turn, index) => ({
43
44
  ...turn,
44
45
  representative: turn.assistants.at(-1) || null,
@@ -57,31 +58,81 @@ window.LoadToAgentAppFactories.createDrawerContent = function createDrawerConten
57
58
  const answerKind = assistant && options.answerKind
58
59
  ? `<span class="chat-answer-kind${options.live ? " is-live" : ""}">${esc(options.answerKind)}</span>`
59
60
  : "";
60
- return `<div class="chat-row ${assistant ? "assistant" : "user"}" data-message-id="${esc(message.id || "")}">
61
+ const deliveryStatusKey = {
62
+ sending: "drawer.message_sending",
63
+ confirming: "drawer.message_confirming",
64
+ delayed: "drawer.message_unconfirmed",
65
+ received: "drawer.message_received",
66
+ responding: "drawer.message_responding",
67
+ failed: "drawer.message_failed",
68
+ };
69
+ const deliveryStatus = !assistant && message.deliveryStatus
70
+ ? `<span class="chat-delivery-status ${esc(message.deliveryStatus)}">${esc(t(deliveryStatusKey[message.deliveryStatus]
71
+ || "drawer.message_sent"))}</span>`
72
+ : "";
73
+ const optimisticClasses = message.optimistic
74
+ ? ` is-optimistic is-${esc(message.deliveryStatus || "awaiting")}${message.animate ? " is-new" : ""}`
75
+ : "";
76
+ return `<div class="chat-row ${assistant ? "assistant" : "user"}${optimisticClasses}" data-message-id="${esc(message.id || "")}">
61
77
  <span class="chat-avatar">${esc(avatar)}</span>
62
78
  <div class="chat-bubble">
63
79
  <div class="chat-bubble-head">
64
80
  <b>${esc(label)}</b>
65
- <span title="${esc(fullTime)}">${esc(timeOnly(message.timestamp))}</span>${answerKind}
81
+ <span title="${esc(fullTime)}">${esc(timeOnly(message.timestamp))}</span>${deliveryStatus}${answerKind}
66
82
  </div>${messageContentHtml(message, session.id)}</div>
67
83
  </div>`;
68
84
  }
69
85
 
70
- function progressUpdatesHtml(turn, session) {
71
- if (!turn.progress.length) return "";
72
- const rows = turn.progress.map((message, index) => {
73
- const fullTime = new Date(message.timestamp).toLocaleString(uiLocale());
74
- return `<article data-progress-message-id="${esc(message.id || "")}">
75
- <header><b>${esc(t("drawer.progress_update_item", { count: index + 1 }))}</b><time title="${esc(fullTime)}">${esc(timeOnly(message.timestamp))}</time></header>
76
- ${messageContentHtml(message, session.id)}
77
- </article>`;
78
- }).join("");
79
- return `<details class="chat-progress-updates" data-progress-count="${turn.progress.length}"
80
- data-disclosure-key="${esc(`drawer:${session.id}:turn:${turn.id}:progress`)}">
81
- <summary><span><b>${esc(t("drawer.progress_updates", { count: turn.progress.length }))}</b>
82
- <small>${esc(t("drawer.progress_updates_help"))}</small></span><i aria-hidden="true">↓</i></summary>
83
- <div class="chat-progress-list">${rows}</div>
84
- </details>`;
86
+ function conversationOverlay(session) {
87
+ const pending = state.pendingConversationMessages.get(session.id) || [];
88
+ if (!pending.length) return { session, forceLatestLive: false };
89
+ const messages = [...(session.messages || [])];
90
+ const retained = [];
91
+ let forceLatestLive = false;
92
+ for (const entry of pending) {
93
+ const delivery = conversationDeliveryState({ ...session, messages }, entry);
94
+ if (!delivery) continue;
95
+ observeConversationDelivery(session, entry, delivery);
96
+ if (delivery.phase === "responded") {
97
+ clearTimeout(entry.confirmationTimer);
98
+ continue;
99
+ }
100
+ const tracking = {
101
+ phase: delivery.phase,
102
+ elapsedMs: delivery.elapsedMs,
103
+ error: entry.error || "",
104
+ receivedAt: delivery.receivedAt,
105
+ responseObservedAt: delivery.responseObservedAt,
106
+ };
107
+ if (!delivery.userMessage) {
108
+ messages.push({
109
+ id: entry.id,
110
+ role: "user",
111
+ text: entry.text,
112
+ timestamp: entry.timestamp,
113
+ optimistic: true,
114
+ animate: !entry.presented,
115
+ deliveryStatus: delivery.phase,
116
+ deliveryTracking: tracking,
117
+ });
118
+ entry.presented = true;
119
+ } else {
120
+ const actualIndex = messages.indexOf(delivery.userMessage);
121
+ if (actualIndex >= 0) {
122
+ messages[actualIndex] = {
123
+ ...delivery.userMessage,
124
+ deliveryStatus: delivery.phase,
125
+ deliveryTracking: tracking,
126
+ };
127
+ }
128
+ }
129
+ forceLatestLive = true;
130
+ retained.push(entry);
131
+ }
132
+ if (retained.length) state.pendingConversationMessages.set(session.id, retained);
133
+ else state.pendingConversationMessages.delete(session.id);
134
+ messages.sort((left, right) => Date.parse(left.timestamp || 0) - Date.parse(right.timestamp || 0));
135
+ return { session: { ...session, messages }, forceLatestLive };
85
136
  }
86
137
 
87
138
  function subagentCallEvents(session) {
@@ -211,27 +262,80 @@ window.LoadToAgentAppFactories.createDrawerContent = function createDrawerConten
211
262
  });
212
263
  return items.map(item => {
213
264
  if (item.kind === "call") return subagentCallHtml(item.call, { showAnchor: false });
214
- const finalMessage = item.message.id === representativeId;
265
+ const finalMessage = item.index === turn.assistants.length - 1
266
+ || (representativeId && item.message.id === representativeId);
215
267
  return conversationRowHtml(item.message, session, {
216
268
  userLabel: labels.userLabel,
217
269
  assistantLabel: labels.assistantLabel,
218
270
  answerKind: finalMessage
219
271
  ? t(turn.live ? "drawer.current_progress" : turn.awaitingFinal ? "drawer.last_progress" : "drawer.final_answer")
220
- : t("drawer.main_progress_before_call"),
272
+ : "",
221
273
  live: turn.live && finalMessage,
222
274
  });
223
275
  }).join("");
224
276
  }
225
277
 
278
+ function deliveryProgressHtml(tracking) {
279
+ if (!tracking) {
280
+ return `<div class="chat-delivery-progress phase-observed" data-delivery-phase="observed" role="status" aria-live="polite">
281
+ <header><span aria-hidden="true"></span><div><b>${esc(t("drawer.delivery_observed_title"))}</b><small>${esc(t("drawer.delivery_observed_detail"))}</small></div></header>
282
+ <ol>
283
+ <li class="done"><i aria-hidden="true">✓</i><span><b>${esc(t("drawer.delivery_observed_message"))}</b><small>${esc(t("drawer.delivery_observed_message_help"))}</small></span></li>
284
+ <li class="active"><i aria-hidden="true">2</i><span><b>${esc(t("drawer.delivery_observed_session"))}</b><small>${esc(t("drawer.delivery_observed_session_help"))}</small></span></li>
285
+ </ol>
286
+ <footer><i aria-hidden="true">◎</i><span>${esc(t("drawer.delivery_evidence_observed"))}</span></footer>
287
+ </div>`;
288
+ }
289
+ const phase = tracking.phase || "confirming";
290
+ const copy = {
291
+ sending: ["drawer.delivery_sending_title", "drawer.delivery_sending_detail", "drawer.delivery_evidence_sending"],
292
+ confirming: ["drawer.delivery_confirming_title", "drawer.delivery_confirming_detail", "drawer.delivery_evidence_confirming"],
293
+ delayed: ["drawer.delivery_delayed_title", "drawer.delivery_delayed_detail", "drawer.delivery_evidence_delayed"],
294
+ received: ["drawer.delivery_received_title", "drawer.delivery_received_detail", "drawer.delivery_evidence_received"],
295
+ responding: ["drawer.delivery_responding_title", "drawer.delivery_responding_detail", "drawer.delivery_evidence_responding"],
296
+ failed: ["drawer.delivery_failed_title", "drawer.delivery_failed_detail", "drawer.delivery_evidence_failed"],
297
+ }[phase] || ["drawer.delivery_confirming_title", "drawer.delivery_confirming_detail", "drawer.delivery_evidence_confirming"];
298
+ const stepState = (step) => {
299
+ if (phase === "failed") return step === 0 ? "error" : "pending";
300
+ if (phase === "sending") return step === 0 ? "active" : "pending";
301
+ if (phase === "confirming" || phase === "delayed") {
302
+ if (step === 0) return "done";
303
+ if (step === 1) return phase === "delayed" ? "warning" : "active";
304
+ return "pending";
305
+ }
306
+ if (phase === "received") return step < 2 ? "done" : "active";
307
+ if (phase === "responding") return step < 2 ? "done" : "active";
308
+ return "pending";
309
+ };
310
+ const steps = [
311
+ ["drawer.delivery_step_dispatch", "drawer.delivery_step_dispatch_help"],
312
+ ["drawer.delivery_step_received", "drawer.delivery_step_received_help"],
313
+ ["drawer.delivery_step_response", "drawer.delivery_step_response_help"],
314
+ ].map(([label, detail], index) => {
315
+ const status = stepState(index);
316
+ const marker = status === "done" ? "✓" : status === "error" ? "!" : String(index + 1);
317
+ return `<li class="${status}"><i aria-hidden="true">${marker}</i><span><b>${esc(t(label))}</b><small>${esc(t(detail))}</small></span></li>`;
318
+ }).join("");
319
+ const detail = phase === "failed" && tracking.error
320
+ ? t("drawer.delivery_failed_with_reason", { reason: tracking.error })
321
+ : t(copy[1]);
322
+ return `<div class="chat-delivery-progress phase-${esc(phase)}" data-delivery-phase="${esc(phase)}" role="status" aria-live="polite">
323
+ <header><span aria-hidden="true"></span><div><b>${esc(t(copy[0]))}</b><small>${esc(detail)}</small></div></header>
324
+ <ol>${steps}</ol>
325
+ <footer><i aria-hidden="true">◎</i><span>${esc(t(copy[2]))}</span></footer>
326
+ </div>`;
327
+ }
328
+
226
329
  function chatHtml(session, options = {}) {
330
+ const overlay = conversationOverlay(session);
331
+ session = overlay.session;
227
332
  const messages = session.messages || [];
228
333
  const calls = options.showSubagentCalls === false ? [] : subagentCallEvents(session);
229
334
  if (!messages.length && !calls.length) return `<div class="empty-state"><h3>${esc(t("drawer.no_conversation"))}</h3></div>`;
230
335
  const userLabel = options.userLabel || t("drawer.user");
231
336
  const assistantLabel = options.assistantLabel || providerInfo(session.provider).label;
232
337
  const conversationLabel = options.conversationLabel || t("drawer.conversation");
233
- const turns = conversationTurns(session, options);
234
- const progressCount = turns.reduce((sum, turn) => sum + turn.progress.length, 0);
338
+ const turns = conversationTurns(session, { ...options, forceLatestLive: overlay.forceLatestLive });
235
339
  const omitted = Number(session.omittedMessages || 0);
236
340
  const notice =
237
341
  omitted || session.truncated
@@ -239,14 +343,8 @@ window.LoadToAgentAppFactories.createDrawerContent = function createDrawerConten
239
343
  : "";
240
344
  const rows = turns.map((turn, turnIndex) => {
241
345
  const user = conversationRowHtml(turn.user, session, { userLabel, assistantLabel });
242
- const representative = conversationRowHtml(turn.representative, session, {
243
- userLabel,
244
- assistantLabel,
245
- answerKind: t(turn.live ? "drawer.current_progress" : turn.awaitingFinal ? "drawer.last_progress" : "drawer.final_answer"),
246
- live: turn.live,
247
- });
248
346
  const waiting = turn.live && !turn.representative
249
- ? `<div class="chat-turn-waiting"><span aria-hidden="true"></span><b>${esc(t("drawer.preparing_response"))}</b></div>`
347
+ ? deliveryProgressHtml(turn.user?.deliveryTracking)
250
348
  : "";
251
349
  const turnStartedAt = Date.parse(turn.user?.timestamp || turn.representative?.timestamp || 0);
252
350
  const nextTurnTimestamp = turns[turnIndex + 1]?.user?.timestamp;
@@ -256,11 +354,9 @@ window.LoadToAgentAppFactories.createDrawerContent = function createDrawerConten
256
354
  if (Number.isFinite(turnStartedAt) && calledAt < turnStartedAt) return false;
257
355
  return !Number.isFinite(nextTurnStartedAt) || calledAt < nextTurnStartedAt;
258
356
  });
259
- const timeline = turnCalls.length
260
- ? turnWithSubagentCallsHtml(turn, session, turnCalls, { userLabel, assistantLabel })
261
- : `${representative}${waiting}${progressUpdatesHtml(turn, session)}`;
357
+ const timeline = turnWithSubagentCallsHtml(turn, session, turnCalls, { userLabel, assistantLabel });
262
358
  return `<section class="chat-turn${turn.live ? " is-live" : ""}" data-conversation-turn="${esc(turn.id)}">
263
- ${user}${timeline}${turnCalls.length ? waiting : ""}
359
+ ${user}${timeline}${waiting}
264
360
  </section>`;
265
361
  }).join("");
266
362
  const unmatchedCalls = calls.filter(call => !turns.some((turn, turnIndex) => {
@@ -273,7 +369,7 @@ window.LoadToAgentAppFactories.createDrawerContent = function createDrawerConten
273
369
  const callOnlyRows = unmatchedCalls.map(subagentCallHtml).join("");
274
370
  const emptyConversation = turns.length || calls.length ? "" : `<div class="empty-state compact"><h3>${esc(t("drawer.no_user_ai_conversation"))}</h3></div>`;
275
371
  return `${notice}<div class="chat-history-head">
276
- <span>${esc(t("drawer.turn_summary", { label: conversationLabel, count: turns.length, updates: progressCount ? ` · ${t("drawer.progress_updates", { count: progressCount })}` : "" }))}</span>
372
+ <span>${esc(t("drawer.turn_summary", { label: conversationLabel, count: turns.length, updates: "" }))}</span>
277
373
  <button type="button" data-scroll-latest>${esc(t("drawer.latest_conversation"))} ↓</button>
278
374
  </div>
279
375
  <div class="chat-list">${callOnlyRows}${rows}${emptyConversation}<div class="chat-latest-anchor" aria-label="${esc(t("drawer.latest_conversation"))}">
@@ -375,17 +471,31 @@ window.LoadToAgentAppFactories.createDrawerContent = function createDrawerConten
375
471
  const inheritedText = parentMessageKeys.has(messageKey(message));
376
472
  return !inheritedId && !inheritedText;
377
473
  });
474
+ const existingKeys = new Set(messages.map(messageKey));
475
+ for (const event of subagentCoordinationEvents(session)) {
476
+ if (!["followup", "message"].includes(event.kind) || !normalizedText(event.text)) continue;
477
+ const message = {
478
+ id: `${session.id}:coordination:${event.id}`,
479
+ role: "user",
480
+ text: event.text,
481
+ timestamp: event.timestamp || session.updatedAt,
482
+ source: "coordination",
483
+ };
484
+ const key = messageKey(message);
485
+ if (existingKeys.has(key)) continue;
486
+ messages.push(message);
487
+ existingKeys.add(key);
488
+ }
378
489
  const hasConversation = messages.some((message) =>
379
490
  (message.role === "user" || message.role === "assistant") && String(message.text || "").trim(),
380
491
  );
381
- if (hasConversation) return messages;
382
- if (delegation.assignmentObserved && !delegation.assignmentProtected && String(delegation.assignment || "").trim()) {
492
+ if (!hasConversation && delegation.assignmentObserved && !delegation.assignmentProtected && String(delegation.assignment || "").trim()) {
383
493
  messages.push({
384
494
  id: `${session.id}:delegation`, role: "user", text: delegation.assignment,
385
495
  timestamp: delegation.startedAt || session.startedAt || session.updatedAt,
386
496
  });
387
497
  }
388
- if (String(session.result || delegation.result || "").trim()) {
498
+ if (!hasConversation && String(session.result || delegation.result || "").trim()) {
389
499
  messages.push({
390
500
  id: `${session.id}:result`, role: "assistant", text: session.result || delegation.result,
391
501
  timestamp: session.completedAt || delegation.completedAt || session.updatedAt,
@@ -515,5 +625,9 @@ window.LoadToAgentAppFactories.createDrawerContent = function createDrawerConten
515
625
  </div>`;
516
626
  }
517
627
 
518
- return { conversationTurns, chatHtml, lifecycleHtml, tokensHtml, subagentCommunicationEvents, subagentCoordinationEvents, subagentTextPreview, subagentConversationHtml, executionActivityDetailHtml };
628
+ return {
629
+ conversationTurns, chatHtml, lifecycleHtml, tokensHtml,
630
+ subagentCommunicationEvents, subagentCoordinationEvents, subagentWorkMessages,
631
+ subagentTextPreview, subagentConversationHtml, executionActivityDetailHtml,
632
+ };
519
633
  };
@@ -7,11 +7,20 @@ window.LoadToAgentAppFactories.createDrawer = function createDrawer(context = {}
7
7
  const {
8
8
  $, $$, esc, state, motionPreference, motionState, STATUS, markGuideStep, rememberDialogTrigger, restoreDialogTrigger, setDialogOpenState,
9
9
  providerInfo, isLiveSession, controlRoomStatus = session => session?.status, subagentWorkState, subagentWorkLabel, isProjectlessSession, sessionOriginPath, sessionWorkspaceLabel,
10
+ pendingConversationDelivery = () => null,
10
11
  agentResumeSupport, originAppInfo, selectedSession, snapshotSession, loadSessionDetail, loadSubagentParentDetail,
11
12
  chatHtml, lifecycleHtml, tokensHtml, outcomeHtml, subagentCoordinationEvents, subagentConversationHtml, executionActivityDetailHtml,
12
13
  agentCommandComposer,
13
14
  rememberDisclosureStates = () => {}, restoreDisclosureStates = () => {},
14
15
  } = context;
16
+ const deliveryLabelKey = (phase) => ({
17
+ sending: "control.delivery_sending",
18
+ confirming: "control.delivery_confirming",
19
+ delayed: "control.delivery_delayed",
20
+ received: "control.delivery_received",
21
+ responding: "control.delivery_responding",
22
+ failed: "control.delivery_failed",
23
+ })[phase] || "control.delivery_confirming";
15
24
 
16
25
  function openDrawer(id) {
17
26
  rememberDialogTrigger();
@@ -98,6 +107,8 @@ window.LoadToAgentAppFactories.createDrawer = function createDrawer(context = {}
98
107
  if (!session) return closeDrawer();
99
108
  const provider = providerInfo(session.provider);
100
109
  const presentationStatus = controlRoomStatus(session);
110
+ const delivery = pendingConversationDelivery(session);
111
+ const presentationLabel = delivery ? t(deliveryLabelKey(delivery.phase)) : STATUS[presentationStatus] || presentationStatus;
101
112
  const subagentMode = state.drawerMode === "subagent" && Boolean(session.parentId);
102
113
  const executionMode = state.drawerMode === "execution" && Boolean(state.drawerExecutionId);
103
114
  const snapshot = snapshotSession(session.id);
@@ -116,8 +127,8 @@ window.LoadToAgentAppFactories.createDrawer = function createDrawer(context = {}
116
127
  $("#drawerProvider").textContent = executionMode
117
128
  ? `${activity?.runtime || activity?.tool || t("drawer.execution_unit")} · ${activity ? context.executionActivityStatus(activity) : t("drawer.unknown")}`
118
129
  : subagentMode
119
- ? `${t("control.subagent")} · ${STATUS[presentationStatus] || presentationStatus}`
120
- : `${provider.company} · ${STATUS[presentationStatus] || presentationStatus}`;
130
+ ? `${t("control.subagent")} · ${presentationLabel}`
131
+ : `${provider.company} · ${presentationLabel}`;
121
132
  const drawerTitle = executionMode
122
133
  ? context.inferredExecutionSummary(activity || {}).text
123
134
  : subagentMode ? session.title || session.taskName || (session.delegation && session.delegation.taskName) : session.title;
@@ -4,7 +4,7 @@ window.LoadToAgentAppFactories = window.LoadToAgentAppFactories || {};
4
4
 
5
5
  window.LoadToAgentAppFactories.createFilterEventBindings = function createFilterEventBindings(context = {}) {
6
6
  const t = (key, params) => window.LoadToAgentI18n.t(key, params);
7
- const { $, state, setProviderVisible = () => {}, visibleSnapshot = () => state.snapshot, closeDrawer = () => {}, openDrawer = () => {}, renderSessions, render, renderWorkspaces, renderProviderOverview, renderProviderFilter, toggleProviderFilter, announceProviderFilter, filteredSessions, performUiAction, toast, announce, normalizedSearch = (value) => String(value || "").trim(), saveDashboardPreferences = () => {}, restoreDialogTrigger = () => {}, setDialogOpenState = () => {} } = context;
7
+ const { $, state, setProviderVisible = () => {}, visibleSnapshot = () => state.snapshot, closeDrawer = () => {}, openDrawer = () => {}, renderSessions, render, renderWorkspaces, renderProviderOverview, renderProviderFilter, toggleProviderFilter, announceProviderFilter, filteredSessions, performUiAction, toast, announce, normalizedSearch = (value) => String(value || "").trim(), saveDashboardPreferences = () => {}, restoreDialogTrigger = () => {}, setDialogOpenState = () => {}, syncControlRoomDisclosureButtons = () => {} } = context;
8
8
 
9
9
  function bindFilterAndWorkspaceEvents() {
10
10
  const syncFilterResetButton = () => {
@@ -62,7 +62,6 @@ window.LoadToAgentAppFactories.createFilterEventBindings = function createFilter
62
62
  if (item) {
63
63
  const label = item.querySelector("strong")?.textContent.trim() || t("project.all");
64
64
  state.workspace = item.dataset.workspace;
65
- state.controlRoomPage = 0;
66
65
  state.visibleLimit = 30;
67
66
  renderWorkspaces();
68
67
  renderSessions("filter");
@@ -74,9 +73,12 @@ window.LoadToAgentAppFactories.createFilterEventBindings = function createFilter
74
73
  setDialogOpenState(menu, false);
75
74
  menu?.classList.add("hidden");
76
75
  $("#mobileMoreBtn")?.setAttribute("aria-expanded", "false");
77
- const focusResults = () => ($("#liveSessionGrid")?.querySelector("[data-graph-focus], [data-open-session]")
78
- || $("#sessionGrid")?.querySelector("[data-session-id]")
79
- || $("#mainContent"))?.focus({ preventScroll: true });
76
+ const focusResults = () => {
77
+ const result = $("#liveSessionGrid")?.querySelector("[data-graph-focus], [data-open-session]")
78
+ || $("#sessionGrid")?.querySelector("[data-session-id]");
79
+ result?.focus({ preventScroll: true });
80
+ if (document.activeElement !== result) $("#mainContent")?.focus({ preventScroll: true });
81
+ };
80
82
  restoreDialogTrigger();
81
83
  focusResults();
82
84
  requestAnimationFrame(focusResults);
@@ -93,7 +95,6 @@ window.LoadToAgentAppFactories.createFilterEventBindings = function createFilter
93
95
  const controlProjectSelect = $("#controlRoomProjectSelect");
94
96
  controlProjectSelect?.addEventListener("change", (event) => {
95
97
  state.workspace = event.target.value;
96
- state.controlRoomPage = 0;
97
98
  state.visibleLimit = 30;
98
99
  renderWorkspaces();
99
100
  renderSessions("filter");
@@ -104,7 +105,6 @@ window.LoadToAgentAppFactories.createFilterEventBindings = function createFilter
104
105
  const controlSortSelect = $("#controlRoomSortSelect");
105
106
  controlSortSelect?.addEventListener("change", (event) => {
106
107
  state.controlRoomSort = event.target.value;
107
- state.controlRoomPage = 0;
108
108
  state.visibleLimit = 30;
109
109
  renderSessions("filter");
110
110
  syncFilterResetButton();
@@ -131,7 +131,6 @@ window.LoadToAgentAppFactories.createFilterEventBindings = function createFilter
131
131
  if ($("#searchInput")) $("#searchInput").value = value;
132
132
  controlSearchTimer = setTimeout(() => {
133
133
  state.search = normalizedSearch(value);
134
- state.controlRoomPage = 0;
135
134
  state.visibleLimit = 30;
136
135
  renderSessions("filter");
137
136
  syncFilterResetButton();
@@ -150,16 +149,18 @@ window.LoadToAgentAppFactories.createFilterEventBindings = function createFilter
150
149
  controlSearchButton?.focus();
151
150
  }
152
151
  });
153
- $("#controlRoomPagePrev")?.addEventListener("click", (event) => {
154
- state.controlRoomPage = Math.max(0, Number(state.controlRoomPage || 0) - 1);
155
- renderSessions("filter");
156
- event.currentTarget.focus({ preventScroll: true });
157
- });
158
- $("#controlRoomPageNext")?.addEventListener("click", (event) => {
159
- state.controlRoomPage = Math.max(0, Number(state.controlRoomPage || 0) + 1);
160
- renderSessions("filter");
161
- event.currentTarget.focus({ preventScroll: true });
162
- });
152
+ const setAllControlRoomGroups = (open) => {
153
+ document.querySelectorAll("#liveSessionGrid .control-room-project-group").forEach(group => {
154
+ group.open = open;
155
+ });
156
+ syncControlRoomDisclosureButtons();
157
+ announce(t(open ? "control.all_projects_expanded" : "control.all_projects_collapsed"));
158
+ };
159
+ $("#controlRoomExpandAll")?.addEventListener("click", () => setAllControlRoomGroups(true));
160
+ $("#controlRoomCollapseAll")?.addEventListener("click", () => setAllControlRoomGroups(false));
161
+ $("#liveSessionGrid")?.addEventListener("toggle", (event) => {
162
+ if (event.target.matches?.(".control-room-project-group")) syncControlRoomDisclosureButtons();
163
+ }, true);
163
164
  let searchTimer = null;
164
165
  $("#searchInput").addEventListener("input", (event) => {
165
166
  clearTimeout(searchTimer);
@@ -168,7 +169,6 @@ window.LoadToAgentAppFactories.createFilterEventBindings = function createFilter
168
169
  syncFilterResetButton();
169
170
  searchTimer = setTimeout(() => {
170
171
  state.search = normalizedSearch(value);
171
- state.controlRoomPage = 0;
172
172
  state.visibleLimit = 30;
173
173
  renderSessions("filter");
174
174
  announce(window.LoadToAgentI18n.t("filter.search_results", { count: filteredSessions().length }));
@@ -181,7 +181,6 @@ window.LoadToAgentAppFactories.createFilterEventBindings = function createFilter
181
181
  $("#searchInput").value = "";
182
182
  $("#searchClearBtn").classList.add("hidden");
183
183
  state.search = "";
184
- state.controlRoomPage = 0;
185
184
  state.visibleLimit = 30;
186
185
  renderSessions("filter");
187
186
  announce(window.LoadToAgentI18n.t("filter.search_cleared"));
@@ -212,7 +211,6 @@ window.LoadToAgentAppFactories.createFilterEventBindings = function createFilter
212
211
  const chip = event.target.closest("[data-provider-filter]");
213
212
  if (!chip) return;
214
213
  toggleProviderFilter(chip.dataset.providerFilter);
215
- state.controlRoomPage = 0;
216
214
  state.visibleLimit = 30;
217
215
  renderProviderFilter();
218
216
  renderProviderOverview();
@@ -229,7 +227,6 @@ window.LoadToAgentAppFactories.createFilterEventBindings = function createFilter
229
227
  });
230
228
  $("#sortSelect").addEventListener("change", (event) => {
231
229
  state.sort = event.target.value;
232
- state.controlRoomPage = 0;
233
230
  state.visibleLimit = 30;
234
231
  renderSessions("filter");
235
232
  const label = event.target.selectedOptions[0]?.textContent || event.target.value;
@@ -244,7 +241,6 @@ window.LoadToAgentAppFactories.createFilterEventBindings = function createFilter
244
241
  state.workspace = "all";
245
242
  state.sort = "recent";
246
243
  state.controlRoomSort = "recent";
247
- state.controlRoomPage = 0;
248
244
  state.visibleLimit = 30;
249
245
  $("#searchInput").value = "";
250
246
  $("#searchClearBtn").classList.add("hidden");
@@ -10,10 +10,12 @@ window.LoadToAgentAppFactories.createSessionEventBindings = function createSessi
10
10
  copyText = async () => false,
11
11
  announce = () => {},
12
12
  moveSessionOrder = () => false,
13
+ moveProjectOrder = () => false,
13
14
  archiveSession = () => false,
14
15
  } = context;
15
16
 
16
17
  let sessionDragJustEnded = false;
18
+ let projectDragEndedAt = 0;
17
19
 
18
20
  const sortableSessionId = node => String(node?.dataset.sessionSortable || "");
19
21
  const clearSessionDropState = container => {
@@ -118,6 +120,96 @@ window.LoadToAgentAppFactories.createSessionEventBindings = function createSessi
118
120
  });
119
121
  };
120
122
 
123
+ const bindSortableProjectGroups = (container) => {
124
+ const selector = ".control-room-project-group[data-project-sortable]";
125
+ let draggedProjectId = "";
126
+ const projectId = node => String(node?.dataset.projectSortable || "");
127
+ const clearProjectDropState = () => {
128
+ container.querySelectorAll(selector).forEach(group => {
129
+ group.classList.remove("project-sort-dragging");
130
+ group.removeAttribute("data-project-drop-edge");
131
+ group.querySelector(":scope > .control-project-header")?.setAttribute("aria-grabbed", "false");
132
+ });
133
+ };
134
+ const finishProjectDrag = () => {
135
+ clearProjectDropState();
136
+ draggedProjectId = "";
137
+ projectDragEndedAt = Date.now();
138
+ };
139
+ const commitProjectPosition = (sourceId, targetId, placeAfter, focusSource = false) => {
140
+ if (!moveProjectOrder(sourceId, targetId, placeAfter)) return false;
141
+ saveDashboardPreferences();
142
+ renderSessions("reorder");
143
+ announce(window.LoadToAgentI18n.t("project.position_changed"));
144
+ if (focusSource) {
145
+ requestAnimationFrame(() => container
146
+ .querySelector(`${selector}[data-project-sortable="${CSS.escape(sourceId)}"] > .control-project-header`)
147
+ ?.focus({ preventScroll: true }));
148
+ }
149
+ return true;
150
+ };
151
+ container.addEventListener("dragstart", (event) => {
152
+ const header = event.target.closest(".control-project-header[draggable='true']");
153
+ const group = header?.closest(selector);
154
+ if (!group) return;
155
+ draggedProjectId = projectId(group);
156
+ if (!draggedProjectId) {
157
+ event.preventDefault();
158
+ return;
159
+ }
160
+ group.classList.add("project-sort-dragging");
161
+ header.setAttribute("aria-grabbed", "true");
162
+ if (event.dataTransfer) {
163
+ event.dataTransfer.effectAllowed = "move";
164
+ event.dataTransfer.setData("text/plain", draggedProjectId);
165
+ event.dataTransfer.setData("application/x-loadtoagent-project-list", container.id);
166
+ event.dataTransfer.setDragImage(header, 20, 20);
167
+ }
168
+ });
169
+ container.addEventListener("dragover", (event) => {
170
+ if (!draggedProjectId) return;
171
+ const target = event.target.closest(selector);
172
+ if (!target || projectId(target) === draggedProjectId) return;
173
+ event.preventDefault();
174
+ if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
175
+ container.querySelectorAll(`${selector}[data-project-drop-edge]`).forEach(group => group.removeAttribute("data-project-drop-edge"));
176
+ const bounds = target.getBoundingClientRect();
177
+ target.dataset.projectDropEdge = event.clientY > bounds.top + bounds.height / 2 ? "bottom" : "top";
178
+ });
179
+ container.addEventListener("drop", (event) => {
180
+ if (!draggedProjectId) return;
181
+ const target = event.target.closest(selector);
182
+ if (!target || projectId(target) === draggedProjectId) return;
183
+ event.preventDefault();
184
+ event.stopPropagation();
185
+ const bounds = target.getBoundingClientRect();
186
+ const changed = commitProjectPosition(
187
+ draggedProjectId,
188
+ projectId(target),
189
+ event.clientY > bounds.top + bounds.height / 2,
190
+ );
191
+ finishProjectDrag();
192
+ if (!changed) clearProjectDropState();
193
+ });
194
+ container.addEventListener("dragend", finishProjectDrag);
195
+ container.addEventListener("dragleave", (event) => {
196
+ if (!container.contains(event.relatedTarget)) clearProjectDropState();
197
+ });
198
+ container.addEventListener("keydown", (event) => {
199
+ const header = event.target.closest(".control-project-header[draggable='true']");
200
+ if (!header || event.target !== header || !event.altKey || !["ArrowUp", "ArrowDown"].includes(event.key)) return;
201
+ const group = header.closest(selector);
202
+ const groups = Array.from(container.querySelectorAll(selector));
203
+ const current = groups.indexOf(group);
204
+ const offset = event.key === "ArrowUp" ? -1 : 1;
205
+ const target = groups[current + offset];
206
+ if (current < 0 || !target) return;
207
+ event.preventDefault();
208
+ event.stopPropagation();
209
+ commitProjectPosition(projectId(group), projectId(target), offset > 0, true);
210
+ });
211
+ };
212
+
121
213
  const managementFilterLabel = value => value === "all" ? window.LoadToAgentI18n.t("management.filter_all") : window.LoadToAgentI18n.t(`management.health.${value}`);
122
214
  const announceManagementFilter = value => announce(window.LoadToAgentI18n.t("management.filter_results", {
123
215
  filter: managementFilterLabel(value),
@@ -336,7 +428,13 @@ window.LoadToAgentAppFactories.createSessionEventBindings = function createSessi
336
428
 
337
429
  function bindLiveAgentEvents() {
338
430
  bindSortableSessionList($("#liveSessionGrid"), "[data-control-session][data-session-sortable]");
431
+ bindSortableProjectGroups($("#liveSessionGrid"));
339
432
  $("#liveSessionGrid").addEventListener("click", async (event) => {
433
+ if (Date.now() - projectDragEndedAt < 250 && event.target.closest(".control-project-header")) {
434
+ event.preventDefault();
435
+ event.stopPropagation();
436
+ return;
437
+ }
340
438
  if (sessionDragJustEnded) return;
341
439
  const archive = event.target.closest("[data-session-archive]");
342
440
  if (archive) {
@@ -11,6 +11,7 @@ window.LoadToAgentAppFactories.createGraphModel = function createGraphModel(cont
11
11
  compact,
12
12
  isLiveSession,
13
13
  isControlRoomSession = isLiveSession,
14
+ isSessionManuallyArchived = () => false,
14
15
  stableSessionSort = sessions => [...sessions],
15
16
  } = context;
16
17
 
@@ -33,7 +34,7 @@ window.LoadToAgentAppFactories.createGraphModel = function createGraphModel(cont
33
34
  let current = session;
34
35
  const seen = new Set();
35
36
  while (current && !seen.has(current.id)) {
36
- included.add(current.id);
37
+ if (!isSessionManuallyArchived(current) || isControlRoomSession(current)) included.add(current.id);
37
38
  seen.add(current.id);
38
39
  current = current.parentId ? byId.get(current.parentId) : null;
39
40
  }
@@ -58,7 +59,10 @@ window.LoadToAgentAppFactories.createGraphModel = function createGraphModel(cont
58
59
  }
59
60
  for (const id of [...included]) {
60
61
  const session = byId.get(id);
61
- for (const childId of (session && session.childIds) || []) included.add(childId);
62
+ for (const childId of (session && session.childIds) || []) {
63
+ const child = byId.get(childId);
64
+ if (child && (!isSessionManuallyArchived(child) || isControlRoomSession(child))) included.add(childId);
65
+ }
62
66
  }
63
67
  return { byId, included, nodes: sessions.filter((session) => included.has(session.id)) };
64
68
  }