loadtoagent 1.3.5 → 1.3.6

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.
@@ -19,7 +19,9 @@ LoadToAgent는 제공사별 이벤트를 아래 공통 단계로 정규화합니
19
19
 
20
20
  관측 이벤트가 없는 값은 사실로 단정하지 않고 `inferred` 또는 `unverified`로 표시합니다. 시간 기반 건강 신호는 매분 다시 계산됩니다.
21
21
 
22
- 화면의 최근 세션 기준은 마지막 활동 24시간입니다. 실행 중인 세션은 마지막 활동 시각과 관계없이 계속 표시합니다. `확인할 일`은 범위 안의 실제 응답 요청과 현재 실행 위험만 집계하며, 오래된 세션이나 단순한 낮은 확인 수준은 제외합니다.
22
+ 서브에이전트 작업 지시는 제공사 호출 로그에서 확인한 원문만 `assignment`로 취급합니다. Claude의 `Agent`/`Task` 도구는 `input.prompt`, Codex의 `spawn_agent`는 평문 `message`가 근거입니다. Codex가 `message`를 암호문으로 저장한 경우 직전 메인 AI 설명을 실제 전달 원문으로 대체하지 않으며, 원문은 `protected`, 직전 설명은 별도 참고 문맥으로 표시합니다.
23
+
24
+ 화면의 최근 세션 기준은 마지막 활동 후 24시간입니다. 실행 중인 세션은 마지막 활동 시각과 관계없이 계속 표시합니다. 앱이 실행 상태를 관측한 세션은 실행이 끝나도 마지막 AI 응답 후 30분 동안 관제 화면에서 `대기 중`으로 유지하며, 사용자가 직접 지난 기록으로 옮기면 즉시 제외합니다. 새 AI 응답이 생기면 이전의 수동 이동 기록은 무효화됩니다. `확인할 일`은 24시간 범위 안의 실제 응답 요청과 현재 실행 위험만 집계하며, 오래된 세션이나 단순한 낮은 확인 수준은 제외합니다.
23
25
 
24
26
  ## 명령·백그라운드 실행 계약
25
27
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loadtoagent",
3
- "version": "1.3.5",
3
+ "version": "1.3.6",
4
4
  "description": "A local desktop dashboard for Claude, Codex, Gemini, and Grok sessions.",
5
5
  "main": "main.js",
6
6
  "author": "wincube AX",
@@ -16,6 +16,7 @@ window.LoadToAgentAppFactories.createDashboard = function createDashboard(contex
16
16
  visibleSessions = () => ((state.snapshot && state.snapshot.sessions) || []),
17
17
  isProviderVisible = () => true,
18
18
  isRuntimeLoopSession = () => false,
19
+ isControlRoomSession = session => session?.status === "running" || session?.status === "starting",
19
20
  } = context;
20
21
 
21
22
  function displaySessions() {
@@ -64,11 +65,11 @@ window.LoadToAgentAppFactories.createDashboard = function createDashboard(contex
64
65
  return normalized.split("/").filter(Boolean).pop() || t("workspace.unknown");
65
66
  }
66
67
 
67
- function observedProjects() {
68
+ function observedProjects(sessions = displaySessions().filter((session) => !session.parentId)) {
68
69
  const projects = new Map();
69
70
  const saved = state.workspaces.map((item) => ({ ...item, key: normalizedProjectPath(item.path) }));
70
71
  saved.forEach((item) => projects.set(item.key, { path: item.path, name: item.name || projectName(item.path), saved: true, count: 0 }));
71
- displaySessions().filter((session) => !session.parentId && !isProjectlessSession(session)).forEach((session) => {
72
+ sessions.filter((session) => !session.parentId && !isProjectlessSession(session)).forEach((session) => {
72
73
  const originPath = sessionOriginPath(session);
73
74
  if (!originPath) return;
74
75
  const owner = saved
@@ -97,9 +98,26 @@ window.LoadToAgentAppFactories.createDashboard = function createDashboard(contex
97
98
  }
98
99
 
99
100
  function sessionWorkspaceLabel(session) {
100
- return isProjectlessSession(session)
101
- ? t("ui.no_project")
102
- : (session && session.workspace) || projectName(sessionOriginPath(session));
101
+ if (isProjectlessSession(session)) return t("ui.no_project");
102
+ const originPath = sessionOriginPath(session);
103
+ const owner = state.workspaces
104
+ .filter((item) => projectContainsPath(item.path, originPath))
105
+ .sort((a, b) => normalizedProjectPath(b.path).length - normalizedProjectPath(a.path).length)[0];
106
+ return owner?.name || (session && session.workspace) || projectName(originPath);
107
+ }
108
+
109
+ function controlRoomProject(session) {
110
+ if (isProjectlessSession(session)) return { key: PROJECTLESS_WORKSPACE, path: PROJECTLESS_WORKSPACE, label: t("control.other_projects") };
111
+ const originPath = sessionOriginPath(session);
112
+ const owner = state.workspaces
113
+ .filter((item) => projectContainsPath(item.path, originPath))
114
+ .sort((a, b) => normalizedProjectPath(b.path).length - normalizedProjectPath(a.path).length)[0];
115
+ const path = owner?.path || originPath;
116
+ return {
117
+ key: normalizedProjectPath(path) || String(session?.workspace || session?.id || "unknown").toLocaleLowerCase(),
118
+ path,
119
+ label: owner?.name || (session && session.workspace) || projectName(originPath) || t("control.other_projects"),
120
+ };
103
121
  }
104
122
 
105
123
  function matchesWorkspaceFilter(session) {
@@ -109,21 +127,29 @@ window.LoadToAgentAppFactories.createDashboard = function createDashboard(contex
109
127
  }
110
128
 
111
129
  function renderWorkspaces() {
112
- const lists = [$("#workspaceList"), $("#mobileWorkspaceList")].filter(Boolean);
113
130
  const rootSessions = displaySessions().filter((session) => !session.parentId);
114
- const projects = observedProjects();
131
+ const liveRootSessions = rootSessions.filter(isControlRoomSession);
132
+ const projects = observedProjects(rootSessions);
133
+ const savedProjectOrder = new Map(state.workspaces.map((workspace, index) => [normalizedProjectPath(workspace.path), index]));
134
+ const liveProjects = observedProjects(liveRootSessions).sort((a, b) => {
135
+ const aOrder = savedProjectOrder.get(normalizedProjectPath(a.path));
136
+ const bOrder = savedProjectOrder.get(normalizedProjectPath(b.path));
137
+ if (aOrder != null || bOrder != null) return (aOrder ?? Number.MAX_SAFE_INTEGER) - (bOrder ?? Number.MAX_SAFE_INTEGER);
138
+ return Number(b.count || 0) - Number(a.count || 0) || String(a.name).localeCompare(String(b.name), uiLocale());
139
+ });
115
140
  const projectlessCount = rootSessions.filter(isProjectlessSession).length;
141
+ const liveProjectlessCount = liveRootSessions.filter(isProjectlessSession).length;
116
142
  const savedWorkspaceExists = state.workspace === "all"
117
143
  || (state.workspace === PROJECTLESS_WORKSPACE && projectlessCount > 0)
118
144
  || projects.some((project) => normalizedProjectPath(project.path) === normalizedProjectPath(state.workspace));
119
145
  if (!savedWorkspaceExists) state.workspace = "all";
120
- const projectButton = (item) => `<button type="button" class="workspace-item observed-project ${state.workspace === item.path ? "selected" : ""}"
146
+ const projectButton = (item, compactClass = "") => `<button type="button" class="workspace-item observed-project ${compactClass} ${state.workspace === item.path ? "selected" : ""}"
121
147
  data-workspace="${esc(item.path)}" title="${esc(item.path)}"
122
148
  aria-label="${esc(t("project.filter_named", { name: item.name, count: item.count }))}"
123
149
  aria-pressed="${state.workspace === item.path ? "true" : "false"}">
124
150
  <strong>${esc(item.name)}</strong><small>${Number(item.count || 0)}</small>
125
151
  </button>`;
126
- const html =
152
+ const mobileHtml =
127
153
  `<button type="button" class="workspace-item ${state.workspace === "all" ? "selected" : ""}"
128
154
  data-workspace="all" aria-pressed="${state.workspace === "all" ? "true" : "false"}">
129
155
  <strong>${window.LoadToAgentI18n.t("project.all")}</strong><small>${rootSessions.length}</small>
@@ -144,7 +170,40 @@ window.LoadToAgentAppFactories.createDashboard = function createDashboard(contex
144
170
  title="${esc(window.LoadToAgentI18n.t("ui.remove_from_list"))}">×</button>
145
171
  </div>` : projectButton(item)).join("") +
146
172
  (!projects.length && !projectlessCount ? `<div class="workspace-empty">${window.LoadToAgentI18n.t("project.empty")}</div>` : "");
147
- lists.forEach((list) => { list.innerHTML = html; });
173
+ const desktopHtml =
174
+ `<button type="button" class="workspace-item control-room-project-chip ${state.workspace === "all" ? "selected" : ""}"
175
+ data-workspace="all" aria-pressed="${state.workspace === "all" ? "true" : "false"}">
176
+ <strong>${esc(t("control.all_projects"))}</strong><small>${liveRootSessions.length}</small>
177
+ </button>` +
178
+ liveProjects.map((item) => projectButton(item, "control-room-project-chip")).join("") +
179
+ (liveProjectlessCount
180
+ ? `<button type="button" class="workspace-item projectless control-room-project-chip ${state.workspace === PROJECTLESS_WORKSPACE ? "selected" : ""}"
181
+ data-workspace="${PROJECTLESS_WORKSPACE}" aria-pressed="${state.workspace === PROJECTLESS_WORKSPACE ? "true" : "false"}">
182
+ <strong>${esc(t("control.other_projects"))}</strong><small>${liveProjectlessCount}</small>
183
+ </button>`
184
+ : "") +
185
+ (!liveProjects.length && !liveProjectlessCount ? `<div class="workspace-empty">${window.LoadToAgentI18n.t("project.empty")}</div>` : "");
186
+ const desktopList = $("#workspaceList");
187
+ const mobileList = $("#mobileWorkspaceList");
188
+ if (desktopList) desktopList.innerHTML = desktopHtml;
189
+ if (mobileList) mobileList.innerHTML = mobileHtml;
190
+ const projectSelect = $("#controlRoomProjectSelect");
191
+ if (projectSelect) {
192
+ projectSelect.innerHTML = `<option value="all">${esc(t("control.all_projects"))}</option>`
193
+ + liveProjects.map((item) => `<option value="${esc(item.path)}">${esc(item.name)}</option>`).join("")
194
+ + (liveProjectlessCount ? `<option value="${PROJECTLESS_WORKSPACE}">${esc(t("control.other_projects"))}</option>` : "");
195
+ projectSelect.value = [...projectSelect.options].some((option) => option.value === state.workspace) ? state.workspace : "all";
196
+ }
197
+ const controlSort = $("#controlRoomSortSelect");
198
+ if (controlSort) controlSort.value = state.controlRoomSort || "recent";
199
+ const controlSearch = $("#controlRoomSearchInput");
200
+ if (controlSearch && controlSearch.value !== state.search) controlSearch.value = state.search;
201
+ $("#controlRoomSearch")?.classList.toggle("is-open", Boolean(state.search));
202
+ $("#controlRoomSearchBtn")?.setAttribute("aria-expanded", state.search ? "true" : "false");
203
+ if (controlSearch) {
204
+ controlSearch.tabIndex = state.search ? 0 : -1;
205
+ controlSearch.setAttribute("aria-hidden", state.search ? "false" : "true");
206
+ }
148
207
  const selectedProject = projects.find((project) => normalizedProjectPath(project.path) === normalizedProjectPath(state.workspace));
149
208
  const mobileSummary = $("#mobileWorkspaceSummary");
150
209
  if (mobileSummary) mobileSummary.textContent = state.workspace === "all"
@@ -181,7 +240,7 @@ window.LoadToAgentAppFactories.createDashboard = function createDashboard(contex
181
240
  )
182
241
  .join("");
183
242
  $("#navAllCount").textContent = rootCount;
184
- const activeRootCount = sessions.filter((session) => !session.parentId && ["running", "starting"].includes(session.status)).length;
243
+ const activeRootCount = sessions.filter((session) => !session.parentId && isControlRoomSession(session)).length;
185
244
  $("#navActiveCount").textContent = activeRootCount;
186
245
  const reviewCount = sessions.filter((session) => context.needsManagementReview?.(session)).length;
187
246
  $("#navWaitingCount").textContent = reviewCount;
@@ -426,7 +485,7 @@ window.LoadToAgentAppFactories.createDashboard = function createDashboard(contex
426
485
  function filteredSessions() {
427
486
  const allSessions = displaySessions();
428
487
  let sessions = state.view === "waiting" ? allSessions : allSessions.filter((session) => !session.parentId);
429
- if (state.view === "active") sessions = sessions.filter((session) => session.status === "running" || session.status === "starting");
488
+ if (state.view === "active") sessions = sessions.filter(isControlRoomSession);
430
489
  if (state.view === "waiting") sessions = sessions.filter((session) => context.needsManagementReview?.(session));
431
490
  if (state.providerFilters.size) sessions = sessions.filter((session) => state.providerFilters.has(session.provider));
432
491
  sessions = sessions.filter(matchesWorkspaceFilter);
@@ -489,7 +548,9 @@ window.LoadToAgentAppFactories.createDashboard = function createDashboard(contex
489
548
  .toLowerCase()
490
549
  .includes(query),
491
550
  );
492
- return stableSessionSort(sessions);
551
+ if (state.controlRoomSort === "tokens") return [...sessions].sort((a, b) => Number((b.usage && b.usage.total) || 0) - Number((a.usage && a.usage.total) || 0));
552
+ if (state.controlRoomSort === "context") return [...sessions].sort((a, b) => Number((b.context && b.context.percent) || 0) - Number((a.context && a.context.percent) || 0));
553
+ return [...sessions].sort((a, b) => Date.parse(b.updatedAt || 0) - Date.parse(a.updatedAt || 0));
493
554
  }
494
555
 
495
556
  function renderProviderVisibilitySettings() {
@@ -515,6 +576,7 @@ window.LoadToAgentAppFactories.createDashboard = function createDashboard(contex
515
576
  sessionOriginPath,
516
577
  observedProjects,
517
578
  sessionWorkspaceLabel,
579
+ controlRoomProject,
518
580
  matchesWorkspaceFilter,
519
581
  renderWorkspaces,
520
582
  renderGlobalStats,
@@ -84,9 +84,149 @@ window.LoadToAgentAppFactories.createDrawerContent = function createDrawerConten
84
84
  </details>`;
85
85
  }
86
86
 
87
+ function subagentCallEvents(session) {
88
+ const spawns = session?.collaboration?.spawns || [];
89
+ const children = (session?.childIds || [])
90
+ .map(id => state.details.get(id) || snapshotSession(id))
91
+ .filter(Boolean);
92
+ const recordedChildren = new Set(spawns.map(spawn => spawn.childId).filter(Boolean));
93
+ const records = spawns.concat(children
94
+ .filter(child => !recordedChildren.has(child.id)
95
+ && !spawns.some(spawn => (spawn.agentPath && child.agentPath === spawn.agentPath)
96
+ || (spawn.taskName && child.taskName === spawn.taskName)))
97
+ .map(child => ({
98
+ callId: `inferred:${child.id}`,
99
+ childId: child.id,
100
+ agentPath: child.agentPath,
101
+ taskName: child.taskName || child.delegation?.taskName || child.agentName,
102
+ assignment: child.delegation?.assignmentObserved ? child.delegation.assignment : "",
103
+ assignmentProtected: Boolean(child.delegation?.assignmentProtected),
104
+ assignmentSource: child.delegation?.assignmentSource || "unavailable",
105
+ status: child.status,
106
+ startedAt: child.delegation?.startedAt || child.startedAt,
107
+ })));
108
+ const calls = records.map((spawn, index) => {
109
+ const child = (spawn.childId && (state.details.get(spawn.childId) || snapshotSession(spawn.childId)))
110
+ || children.find(candidate => (spawn.agentPath && candidate.agentPath === spawn.agentPath)
111
+ || (spawn.taskName && candidate.taskName === spawn.taskName));
112
+ const timestamp = spawn.startedAt || child?.startedAt || session.startedAt || session.updatedAt;
113
+ const assignmentProtected = Boolean(spawn.assignmentProtected || child?.delegation?.assignmentProtected);
114
+ const observedAssignment = spawn.assignmentObserved
115
+ ? spawn.assignment
116
+ : (child?.delegation?.assignmentObserved ? child.delegation.assignment : "");
117
+ const childTitle = String(child?.title || "").trim();
118
+ return {
119
+ id: spawn.callId || child?.id || `subagent-call-${index}`,
120
+ childId: child?.id || spawn.childId || "",
121
+ taskName: spawn.taskName || child?.taskName || child?.agentName || t("control.subagent"),
122
+ assignment: observedAssignment,
123
+ workSummary: observedAssignment || (!assignmentProtected && childTitle && childTitle !== session.title ? childTitle : ""),
124
+ assignmentProtected,
125
+ assignmentSource: spawn.assignmentSource || child?.delegation?.assignmentSource || "unavailable",
126
+ status: child?.status || spawn.status || "idle",
127
+ timestamp,
128
+ };
129
+ }).sort((left, right) => Date.parse(left.timestamp || 0) - Date.parse(right.timestamp || 0));
130
+ const messages = session.messages || [];
131
+ return calls.map((call, index) => {
132
+ const calledAt = Date.parse(call.timestamp || 0);
133
+ const latestUserAt = messages.reduce((latest, message) => {
134
+ const messageAt = Date.parse(message?.timestamp || 0);
135
+ return message?.role === "user" && messageAt <= calledAt ? Math.max(latest, messageAt) : latest;
136
+ }, Number.NEGATIVE_INFINITY);
137
+ const anchor = [...messages].reverse().find(message => {
138
+ const messageAt = Date.parse(message?.timestamp || 0);
139
+ return message?.role === "assistant" && String(message.text || "").trim()
140
+ && messageAt <= calledAt && messageAt >= latestUserAt;
141
+ });
142
+ const anchorText = String(anchor?.text || "").replace(/\s+/g, " ").trim();
143
+ return {
144
+ ...call,
145
+ sequence: index + 1,
146
+ anchorText: anchorText.length > 240 ? `${anchorText.slice(0, 240).trimEnd()}…` : anchorText,
147
+ anchorTimestamp: anchor?.timestamp || "",
148
+ requestTimestamp: Number.isFinite(latestUserAt) ? new Date(latestUserAt).toISOString() : "",
149
+ elapsedAfterRequestMs: Number.isFinite(latestUserAt) && Number.isFinite(calledAt)
150
+ ? Math.max(0, calledAt - latestUserAt)
151
+ : null,
152
+ };
153
+ });
154
+ }
155
+
156
+ function subagentCallElapsed(milliseconds) {
157
+ if (!Number.isFinite(milliseconds)) return "";
158
+ const totalSeconds = Math.max(0, Math.floor(milliseconds / 1000));
159
+ if (totalSeconds < 1) return t("drawer.duration_less_than_second");
160
+ const hours = Math.floor(totalSeconds / 3600);
161
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
162
+ const seconds = totalSeconds % 60;
163
+ if (hours) return t("drawer.duration_hours_minutes_seconds", { hours, minutes, seconds });
164
+ if (minutes) return t("drawer.duration_minutes_seconds", { minutes, seconds });
165
+ return t("drawer.duration_seconds", { seconds });
166
+ }
167
+
168
+ function subagentCallStatus(status) {
169
+ if (status === "completed") return t("ui.completed");
170
+ if (status === "running" || status === "starting") return t("ui.working");
171
+ if (status === "waiting") return t("ui.waiting_for_review");
172
+ if (status === "failed") return t("ui.problem");
173
+ if (status === "cancelled") return t("ui.stopped");
174
+ return t("ui.idle");
175
+ }
176
+
177
+ function subagentCallHtml(call, options = {}) {
178
+ const fullTime = new Date(call.timestamp).toLocaleString(uiLocale());
179
+ const assignment = call.workSummary || call.assignment || (call.assignmentProtected ? t("drawer.assignment_protected_short") : call.taskName);
180
+ const elapsed = subagentCallElapsed(call.elapsedAfterRequestMs);
181
+ const timing = elapsed
182
+ ? t("drawer.called_after_user_request", { elapsed })
183
+ : t("drawer.subagent_called");
184
+ const anchorTime = call.anchorTimestamp ? timeOnly(call.anchorTimestamp) : "";
185
+ const anchor = call.anchorText
186
+ ? `<div class="subagent-call-anchor"><span aria-hidden="true">AI</span><div><small>${esc(t("drawer.called_after_main_message"))}${anchorTime ? ` · ${esc(anchorTime)}` : ""}</small><blockquote>${esc(call.anchorText)}</blockquote></div></div>`
187
+ : `<div class="subagent-call-anchor is-context-only"><span aria-hidden="true">AI</span><div><small>${esc(t("drawer.main_called_here"))}</small></div></div>`;
188
+ const showAnchor = options.showAnchor !== false || !call.anchorText;
189
+ const content = `<span class="subagent-call-icon" aria-hidden="true">⑂</span>
190
+ <span class="subagent-call-copy"><small class="subagent-call-timing">${esc(timing)}</small><span class="subagent-call-clock">${esc(t("drawer.subagent_call_point", { count: call.sequence }))} · <time title="${esc(fullTime)}">${esc(timeOnly(call.timestamp))}</time></span>
191
+ <b>${esc(assignment)}</b><span>${esc(t("drawer.subagent_name", { name: call.taskName }))}</span></span>
192
+ <span class="subagent-call-action"><em class="status-${esc(call.status)}">${esc(subagentCallStatus(call.status))}</em><strong>${esc(t("drawer.open_subagent_work"))} →</strong></span>`;
193
+ const event = !call.childId
194
+ ? `<div class="subagent-call-event is-unavailable" data-subagent-call-event="${esc(call.id)}">${content}</div>`
195
+ : `<button type="button" class="subagent-call-event" data-subagent-call-event="${esc(call.id)}"
196
+ data-open-subagent-chat="${esc(call.childId)}" aria-label="${esc(t("control.open_subagent", { task: call.taskName }))}">${content}</button>`;
197
+ return `<div class="subagent-call-moment${showAnchor ? "" : " is-inline"}" data-subagent-call-sequence="${call.sequence}"${Number.isFinite(call.elapsedAfterRequestMs) ? ` data-subagent-call-elapsed-ms="${call.elapsedAfterRequestMs}"` : ""}>${showAnchor ? anchor : ""}<span class="subagent-call-connector" aria-hidden="true"><i></i><b>↓</b></span>${event}</div>`;
198
+ }
199
+
200
+ function turnWithSubagentCallsHtml(turn, session, calls, labels) {
201
+ const representativeId = turn.representative?.id || "";
202
+ const items = [
203
+ ...turn.assistants.map((message, index) => ({ kind: "message", message, index, timestamp: message.timestamp })),
204
+ ...calls.map((call, index) => ({ kind: "call", call, index, timestamp: call.timestamp })),
205
+ ].sort((left, right) => {
206
+ const leftAt = Date.parse(left.timestamp || 0);
207
+ const rightAt = Date.parse(right.timestamp || 0);
208
+ if (leftAt !== rightAt) return leftAt - rightAt;
209
+ if (left.kind !== right.kind) return left.kind === "message" ? -1 : 1;
210
+ return left.index - right.index;
211
+ });
212
+ return items.map(item => {
213
+ if (item.kind === "call") return subagentCallHtml(item.call, { showAnchor: false });
214
+ const finalMessage = item.message.id === representativeId;
215
+ return conversationRowHtml(item.message, session, {
216
+ userLabel: labels.userLabel,
217
+ assistantLabel: labels.assistantLabel,
218
+ answerKind: finalMessage
219
+ ? t(turn.live ? "drawer.current_progress" : turn.awaitingFinal ? "drawer.last_progress" : "drawer.final_answer")
220
+ : t("drawer.main_progress_before_call"),
221
+ live: turn.live && finalMessage,
222
+ });
223
+ }).join("");
224
+ }
225
+
87
226
  function chatHtml(session, options = {}) {
88
227
  const messages = session.messages || [];
89
- if (!messages.length) return `<div class="empty-state"><h3>${esc(t("drawer.no_conversation"))}</h3></div>`;
228
+ const calls = options.showSubagentCalls === false ? [] : subagentCallEvents(session);
229
+ if (!messages.length && !calls.length) return `<div class="empty-state"><h3>${esc(t("drawer.no_conversation"))}</h3></div>`;
90
230
  const userLabel = options.userLabel || t("drawer.user");
91
231
  const assistantLabel = options.assistantLabel || providerInfo(session.provider).label;
92
232
  const conversationLabel = options.conversationLabel || t("drawer.conversation");
@@ -97,7 +237,7 @@ window.LoadToAgentAppFactories.createDrawerContent = function createDrawerConten
97
237
  omitted || session.truncated
98
238
  ? `<div class="chat-truncated">${esc(t("drawer.recent_history"))}${omitted ? ` · ${esc(t("drawer.messages_omitted", { count: omitted.toLocaleString(uiLocale()) }))}` : ""}</div>`
99
239
  : "";
100
- const rows = turns.map((turn) => {
240
+ const rows = turns.map((turn, turnIndex) => {
101
241
  const user = conversationRowHtml(turn.user, session, { userLabel, assistantLabel });
102
242
  const representative = conversationRowHtml(turn.representative, session, {
103
243
  userLabel,
@@ -108,16 +248,35 @@ window.LoadToAgentAppFactories.createDrawerContent = function createDrawerConten
108
248
  const waiting = turn.live && !turn.representative
109
249
  ? `<div class="chat-turn-waiting"><span aria-hidden="true"></span><b>${esc(t("drawer.preparing_response"))}</b></div>`
110
250
  : "";
251
+ const turnStartedAt = Date.parse(turn.user?.timestamp || turn.representative?.timestamp || 0);
252
+ const nextTurnTimestamp = turns[turnIndex + 1]?.user?.timestamp;
253
+ const nextTurnStartedAt = nextTurnTimestamp ? Date.parse(nextTurnTimestamp) : Number.NaN;
254
+ const turnCalls = calls.filter(call => {
255
+ const calledAt = Date.parse(call.timestamp || 0);
256
+ if (Number.isFinite(turnStartedAt) && calledAt < turnStartedAt) return false;
257
+ return !Number.isFinite(nextTurnStartedAt) || calledAt < nextTurnStartedAt;
258
+ });
259
+ const timeline = turnCalls.length
260
+ ? turnWithSubagentCallsHtml(turn, session, turnCalls, { userLabel, assistantLabel })
261
+ : `${representative}${waiting}${progressUpdatesHtml(turn, session)}`;
111
262
  return `<section class="chat-turn${turn.live ? " is-live" : ""}" data-conversation-turn="${esc(turn.id)}">
112
- ${user}${representative}${waiting}${progressUpdatesHtml(turn, session)}
263
+ ${user}${timeline}${turnCalls.length ? waiting : ""}
113
264
  </section>`;
114
265
  }).join("");
115
- const emptyConversation = turns.length ? "" : `<div class="empty-state compact"><h3>${esc(t("drawer.no_user_ai_conversation"))}</h3></div>`;
266
+ const unmatchedCalls = calls.filter(call => !turns.some((turn, turnIndex) => {
267
+ const startedAt = Date.parse(turn.user?.timestamp || turn.representative?.timestamp || 0);
268
+ const nextTimestamp = turns[turnIndex + 1]?.user?.timestamp;
269
+ const nextStartedAt = nextTimestamp ? Date.parse(nextTimestamp) : Number.NaN;
270
+ const calledAt = Date.parse(call.timestamp || 0);
271
+ return (!Number.isFinite(startedAt) || calledAt >= startedAt) && (!Number.isFinite(nextStartedAt) || calledAt < nextStartedAt);
272
+ }));
273
+ const callOnlyRows = unmatchedCalls.map(subagentCallHtml).join("");
274
+ const emptyConversation = turns.length || calls.length ? "" : `<div class="empty-state compact"><h3>${esc(t("drawer.no_user_ai_conversation"))}</h3></div>`;
116
275
  return `${notice}<div class="chat-history-head">
117
276
  <span>${esc(t("drawer.turn_summary", { label: conversationLabel, count: turns.length, updates: progressCount ? ` · ${t("drawer.progress_updates", { count: progressCount })}` : "" }))}</span>
118
277
  <button type="button" data-scroll-latest>${esc(t("drawer.latest_conversation"))} ↓</button>
119
278
  </div>
120
- <div class="chat-list">${rows}${emptyConversation}<div class="chat-latest-anchor" aria-label="${esc(t("drawer.latest_conversation"))}">
279
+ <div class="chat-list">${callOnlyRows}${rows}${emptyConversation}<div class="chat-latest-anchor" aria-label="${esc(t("drawer.latest_conversation"))}">
121
280
  </div>
122
281
  </div>`;
123
282
  }
@@ -265,7 +424,23 @@ window.LoadToAgentAppFactories.createDrawerContent = function createDrawerConten
265
424
  const delegation = session.delegation || {};
266
425
  const parent = session.parentId ? state.details.get(session.parentId) || snapshotSession(session.parentId) : null;
267
426
  const assignmentEvent = subagentCoordinationEvents(session).find(event => event.kind === "assignment" && String(event.text || "").trim());
268
- const assignment = String(delegation.assignment || assignmentEvent?.text || delegation.taskName || session.taskName || session.title || "").trim();
427
+ const delegatedAssignment = delegation.assignmentObserved && String(delegation.assignment || "").trim()
428
+ ? delegation.assignment
429
+ : "";
430
+ const eventAssignment = assignmentEvent && !assignmentEvent.protected ? assignmentEvent.text : "";
431
+ const assignment = String(delegatedAssignment || eventAssignment || "").trim();
432
+ const assignmentProtected = Boolean(delegation.assignmentProtected || assignmentEvent?.protected);
433
+ const assignmentContext = String(delegation.assignmentContext || "").trim();
434
+ const assignmentBody = assignment || (assignmentProtected
435
+ ? t("drawer.assignment_protected")
436
+ : t("management.signal_unavailable"));
437
+ const assignmentSource = delegation.assignmentSource === "claude-agent-prompt"
438
+ ? t("drawer.assignment_source_claude")
439
+ : delegation.assignmentSource === "spawn-message"
440
+ ? t("drawer.assignment_source_codex")
441
+ : assignmentProtected
442
+ ? t("drawer.assignment_source_protected")
443
+ : "";
269
444
  let assignmentRemoved = false;
270
445
  const messages = subagentWorkMessages(session).filter(message => {
271
446
  if (assignmentRemoved || message.role !== "user" || !assignment) return true;
@@ -279,7 +454,8 @@ window.LoadToAgentAppFactories.createDrawerContent = function createDrawerConten
279
454
  ? t("drawer.subagent_history_reconstructed")
280
455
  : t("drawer.subagent_history_actual");
281
456
  return `<section class="subagent-assignment-card" data-subagent-assignment="true">
282
- <span aria-hidden="true">⌁</span><div><b>${esc(t("control.main_assignment"))}</b>${parent ? `<small>${esc(t("control.created_from"))} · ${esc(parent.title)}</small>` : ""}<p>${esc(assignment || t("management.signal_unavailable"))}</p></div>
457
+ <span aria-hidden="true">⌁</span><div><b>${esc(t("control.main_assignment"))}</b>${parent ? `<small>${esc(t("control.created_from"))} · ${esc(parent.title)}</small>` : ""}${assignmentSource ? `<small>${esc(assignmentSource)}</small>` : ""}<p>${esc(assignmentBody)}</p>
458
+ ${assignmentContext ? `<aside><b>${esc(t("drawer.assignment_context"))}</b><p>${esc(assignmentContext)}</p></aside>` : ""}</div>
283
459
  </section><section class="subagent-work-source" data-subagent-work-messages="${conversationCount}" data-conversation-scope="subagent-only">
284
460
  <b>${esc(t("control.subagent_conversation"))}</b><span>${esc(sourceCopy)}</span>
285
461
  </section>${chatHtml(workSession, {
@@ -306,7 +482,12 @@ window.LoadToAgentAppFactories.createDrawerContent = function createDrawerConten
306
482
  : `${t("control.main_agent")} · ${session.agentName || providerInfo(session.provider).label}`;
307
483
  const timeline = [
308
484
  activity.startedAt ? { label: t("drawer.execution_started"), value: activity.startedAt } : null,
309
- activity.updatedAt ? { label: activity.status === "running" ? t("drawer.execution_latest_activity") : t("drawer.execution_finished"), value: activity.updatedAt } : null,
485
+ activity.updatedAt ? {
486
+ label: activity.status === "running" || activity.status === "unverified"
487
+ ? t("drawer.execution_latest_activity")
488
+ : t("drawer.execution_finished"),
489
+ value: activity.updatedAt,
490
+ } : null,
310
491
  ].filter(Boolean);
311
492
  return `<div class="execution-drawer" data-execution-detail="${esc(activity.id)}" data-conversation-scope="execution-only">
312
493
  <section class="execution-purpose-card">
@@ -6,7 +6,7 @@ window.LoadToAgentAppFactories.createDrawer = function createDrawer(context = {}
6
6
  const t = (key, params) => window.LoadToAgentI18n.t(key, params);
7
7
  const {
8
8
  $, $$, esc, state, motionPreference, motionState, STATUS, markGuideStep, rememberDialogTrigger, restoreDialogTrigger, setDialogOpenState,
9
- providerInfo, isLiveSession, subagentWorkState, subagentWorkLabel, isProjectlessSession, sessionOriginPath, sessionWorkspaceLabel,
9
+ providerInfo, isLiveSession, controlRoomStatus = session => session?.status, subagentWorkState, subagentWorkLabel, isProjectlessSession, sessionOriginPath, sessionWorkspaceLabel,
10
10
  agentResumeSupport, originAppInfo, selectedSession, snapshotSession, loadSessionDetail, loadSubagentParentDetail,
11
11
  chatHtml, lifecycleHtml, tokensHtml, outcomeHtml, subagentCoordinationEvents, subagentConversationHtml, executionActivityDetailHtml,
12
12
  agentCommandComposer,
@@ -97,6 +97,7 @@ window.LoadToAgentAppFactories.createDrawer = function createDrawer(context = {}
97
97
  const session = selectedSession();
98
98
  if (!session) return closeDrawer();
99
99
  const provider = providerInfo(session.provider);
100
+ const presentationStatus = controlRoomStatus(session);
100
101
  const subagentMode = state.drawerMode === "subagent" && Boolean(session.parentId);
101
102
  const executionMode = state.drawerMode === "execution" && Boolean(state.drawerExecutionId);
102
103
  const snapshot = snapshotSession(session.id);
@@ -115,8 +116,8 @@ window.LoadToAgentAppFactories.createDrawer = function createDrawer(context = {}
115
116
  $("#drawerProvider").textContent = executionMode
116
117
  ? `${activity?.runtime || activity?.tool || t("drawer.execution_unit")} · ${activity ? context.executionActivityStatus(activity) : t("drawer.unknown")}`
117
118
  : subagentMode
118
- ? `${t("control.subagent")} · ${STATUS[session.status] || session.status}`
119
- : `${provider.company} · ${STATUS[session.status] || session.status}`;
119
+ ? `${t("control.subagent")} · ${STATUS[presentationStatus] || presentationStatus}`
120
+ : `${provider.company} · ${STATUS[presentationStatus] || presentationStatus}`;
120
121
  const drawerTitle = executionMode
121
122
  ? context.inferredExecutionSummary(activity || {}).text
122
123
  : subagentMode ? session.title || session.taskName || (session.delegation && session.delegation.taskName) : session.title;
@@ -9,7 +9,7 @@ window.LoadToAgentAppFactories.createDialogEventBindings = function createDialog
9
9
  closeDrawer, renderDrawer, providerPickerHtml, syncRunComposer, openRunModal, closeRunModal, toast, performUiAction,
10
10
  handleRun, trapDialogFocus, currentDialog, selectView, saveRunDraft = () => {}, safeBackdrop = null,
11
11
  copyText = async () => false,
12
- dispatchAgentCommand, controlManagedRun, quickRespond, prepareReassignment,
12
+ dispatchAgentCommand, controlManagedRun, quickRespond, prepareReassignment, openSubagentConversation,
13
13
  } = context;
14
14
 
15
15
  function bindRunComposerEvents() {
@@ -161,6 +161,11 @@ window.LoadToAgentAppFactories.createDialogEventBindings = function createDialog
161
161
  $(`.drawer-tab[data-tab="${state.drawerTab}"]`)?.focus();
162
162
  });
163
163
  $("#detailDrawer").addEventListener("click", async (event) => {
164
+ const subagent = event.target.closest("[data-open-subagent-chat]");
165
+ if (subagent) {
166
+ openSubagentConversation(subagent.dataset.openSubagentChat);
167
+ return;
168
+ }
164
169
  const route = event.target.closest("[data-agent-command-route]");
165
170
  if (route) {
166
171
  state.agentCommandRoutes.set(route.dataset.agentCommandSession, route.dataset.agentCommandRoute);
@@ -9,7 +9,7 @@ window.LoadToAgentAppFactories.createFilterEventBindings = function createFilter
9
9
  function bindFilterAndWorkspaceEvents() {
10
10
  const syncFilterResetButton = () => {
11
11
  const hasFilters = Boolean(
12
- $("#searchInput").value || state.search || state.providerFilters.size || state.workspace !== "all" || state.sort !== "recent",
12
+ $("#searchInput").value || state.search || state.providerFilters.size || state.workspace !== "all" || state.sort !== "recent" || state.controlRoomSort !== "recent",
13
13
  );
14
14
  $("#resetFiltersBtn").classList.toggle("hidden", !hasFilters);
15
15
  };
@@ -62,6 +62,7 @@ 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;
65
66
  state.visibleLimit = 30;
66
67
  renderWorkspaces();
67
68
  renderSessions("filter");
@@ -85,9 +86,80 @@ window.LoadToAgentAppFactories.createFilterEventBindings = function createFilter
85
86
  workspaceLists.forEach((list) => {
86
87
  list.addEventListener("click", handleWorkspaceClick);
87
88
  list.addEventListener("keydown", (event) => {
88
- moveFocus(event, event.currentTarget, "[data-workspace]", ["ArrowUp"], ["ArrowDown"]);
89
+ const horizontal = event.currentTarget.id === "workspaceList";
90
+ moveFocus(event, event.currentTarget, "[data-workspace]", horizontal ? ["ArrowLeft", "ArrowUp"] : ["ArrowUp"], horizontal ? ["ArrowRight", "ArrowDown"] : ["ArrowDown"]);
89
91
  });
90
92
  });
93
+ const controlProjectSelect = $("#controlRoomProjectSelect");
94
+ controlProjectSelect?.addEventListener("change", (event) => {
95
+ state.workspace = event.target.value;
96
+ state.controlRoomPage = 0;
97
+ state.visibleLimit = 30;
98
+ renderWorkspaces();
99
+ renderSessions("filter");
100
+ syncFilterResetButton();
101
+ saveDashboardPreferences();
102
+ announce(t("filter.workspace_results", { project: event.target.selectedOptions[0]?.textContent || t("control.all_projects"), count: filteredSessions().length }));
103
+ });
104
+ const controlSortSelect = $("#controlRoomSortSelect");
105
+ controlSortSelect?.addEventListener("change", (event) => {
106
+ state.controlRoomSort = event.target.value;
107
+ state.controlRoomPage = 0;
108
+ state.visibleLimit = 30;
109
+ renderSessions("filter");
110
+ syncFilterResetButton();
111
+ saveDashboardPreferences();
112
+ announce(t("filter.sort_changed", { sort: event.target.selectedOptions[0]?.textContent || event.target.value, count: filteredSessions().length }));
113
+ });
114
+ const controlSearch = $("#controlRoomSearch");
115
+ const controlSearchInput = $("#controlRoomSearchInput");
116
+ const controlSearchButton = $("#controlRoomSearchBtn");
117
+ let controlSearchTimer = null;
118
+ const setControlSearchOpen = (open) => {
119
+ controlSearch?.classList.toggle("is-open", open);
120
+ controlSearchButton?.setAttribute("aria-expanded", open ? "true" : "false");
121
+ if (controlSearchInput) {
122
+ controlSearchInput.tabIndex = open ? 0 : -1;
123
+ controlSearchInput.setAttribute("aria-hidden", open ? "false" : "true");
124
+ }
125
+ if (open) requestAnimationFrame(() => controlSearchInput?.focus());
126
+ };
127
+ controlSearchButton?.addEventListener("click", () => setControlSearchOpen(!controlSearch?.classList.contains("is-open")));
128
+ controlSearchInput?.addEventListener("input", (event) => {
129
+ clearTimeout(controlSearchTimer);
130
+ const value = event.target.value;
131
+ if ($("#searchInput")) $("#searchInput").value = value;
132
+ controlSearchTimer = setTimeout(() => {
133
+ state.search = normalizedSearch(value);
134
+ state.controlRoomPage = 0;
135
+ state.visibleLimit = 30;
136
+ renderSessions("filter");
137
+ syncFilterResetButton();
138
+ saveDashboardPreferences();
139
+ announce(t("filter.search_results", { count: filteredSessions().length }));
140
+ }, 120);
141
+ });
142
+ controlSearchInput?.addEventListener("keydown", (event) => {
143
+ if (event.key !== "Escape") return;
144
+ event.preventDefault();
145
+ if (event.currentTarget.value) {
146
+ event.currentTarget.value = "";
147
+ event.currentTarget.dispatchEvent(new Event("input", { bubbles: true }));
148
+ } else {
149
+ setControlSearchOpen(false);
150
+ controlSearchButton?.focus();
151
+ }
152
+ });
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
+ });
91
163
  let searchTimer = null;
92
164
  $("#searchInput").addEventListener("input", (event) => {
93
165
  clearTimeout(searchTimer);
@@ -96,6 +168,7 @@ window.LoadToAgentAppFactories.createFilterEventBindings = function createFilter
96
168
  syncFilterResetButton();
97
169
  searchTimer = setTimeout(() => {
98
170
  state.search = normalizedSearch(value);
171
+ state.controlRoomPage = 0;
99
172
  state.visibleLimit = 30;
100
173
  renderSessions("filter");
101
174
  announce(window.LoadToAgentI18n.t("filter.search_results", { count: filteredSessions().length }));
@@ -108,6 +181,7 @@ window.LoadToAgentAppFactories.createFilterEventBindings = function createFilter
108
181
  $("#searchInput").value = "";
109
182
  $("#searchClearBtn").classList.add("hidden");
110
183
  state.search = "";
184
+ state.controlRoomPage = 0;
111
185
  state.visibleLimit = 30;
112
186
  renderSessions("filter");
113
187
  announce(window.LoadToAgentI18n.t("filter.search_cleared"));
@@ -138,6 +212,7 @@ window.LoadToAgentAppFactories.createFilterEventBindings = function createFilter
138
212
  const chip = event.target.closest("[data-provider-filter]");
139
213
  if (!chip) return;
140
214
  toggleProviderFilter(chip.dataset.providerFilter);
215
+ state.controlRoomPage = 0;
141
216
  state.visibleLimit = 30;
142
217
  renderProviderFilter();
143
218
  renderProviderOverview();
@@ -154,6 +229,7 @@ window.LoadToAgentAppFactories.createFilterEventBindings = function createFilter
154
229
  });
155
230
  $("#sortSelect").addEventListener("change", (event) => {
156
231
  state.sort = event.target.value;
232
+ state.controlRoomPage = 0;
157
233
  state.visibleLimit = 30;
158
234
  renderSessions("filter");
159
235
  const label = event.target.selectedOptions[0]?.textContent || event.target.value;
@@ -167,6 +243,8 @@ window.LoadToAgentAppFactories.createFilterEventBindings = function createFilter
167
243
  state.providerFilters.clear();
168
244
  state.workspace = "all";
169
245
  state.sort = "recent";
246
+ state.controlRoomSort = "recent";
247
+ state.controlRoomPage = 0;
170
248
  state.visibleLimit = 30;
171
249
  $("#searchInput").value = "";
172
250
  $("#searchClearBtn").classList.add("hidden");