loadtoagent 1.0.0 → 1.1.0

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.
package/main.js CHANGED
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const { app, BrowserWindow, ipcMain, dialog, shell, clipboard, Tray, Menu, net } = require('electron');
3
+ const { app, BrowserWindow, ipcMain, dialog, shell, clipboard, Tray, Menu, net, Notification } = require('electron');
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
6
  const os = require('os');
@@ -21,6 +21,12 @@ const { registerTerminalIpc } = require('./src/ipc/registerTerminalIpc');
21
21
  const { registerTmuxIpc } = require('./src/ipc/registerTmuxIpc');
22
22
  const { registerWorkspaceIpc } = require('./src/ipc/registerWorkspaceIpc');
23
23
  const { reportRecoverableError } = require('./src/diagnostics');
24
+ const { AttentionNotifier } = require('./src/attentionNotifier');
25
+
26
+ const PRODUCT_NAME = 'LoadToAgent';
27
+ app.setName(PRODUCT_NAME);
28
+ process.title = PRODUCT_NAME;
29
+ if (process.platform === 'win32') app.setAppUserModelId('com.wincube.loadtoagent');
24
30
 
25
31
  const demoCapture = process.env.LOADTOAGENT_DEMO_CAPTURE === '1';
26
32
  let mainWindow = null;
@@ -31,6 +37,7 @@ let bridgeServer = null;
31
37
  let bridgeLauncher = null;
32
38
  let backgroundTray = null;
33
39
  let updateManager = null;
40
+ let attentionNotifier = null;
34
41
  let isQuitting = false;
35
42
  let appLocale = 'ko';
36
43
  const tmuxController = new TmuxController({ platform: process.platform });
@@ -45,6 +52,8 @@ const MAIN_COPY = {
45
52
  trayQuit: '프로그램 끝내기 · AI 세션도 종료',
46
53
  addWorkspaces: 'AI 작업 폴더 선택',
47
54
  pickWorkspace: '작업 폴더 선택',
55
+ attentionTitle: '내 확인이 필요합니다',
56
+ attentionBody: '{provider} · {title}',
48
57
  },
49
58
  en: {
50
59
  trayTooltip: 'LoadToAgent · {count} background AI sessions',
@@ -53,6 +62,8 @@ const MAIN_COPY = {
53
62
  trayQuit: 'Quit app · End AI sessions too',
54
63
  addWorkspaces: 'Choose AI workspaces',
55
64
  pickWorkspace: 'Choose workspace',
65
+ attentionTitle: 'Your review is needed',
66
+ attentionBody: '{provider} · {title}',
56
67
  },
57
68
  'zh-CN': {
58
69
  trayTooltip: 'LoadToAgent · {count} 个后台 AI 会话',
@@ -61,6 +72,8 @@ const MAIN_COPY = {
61
72
  trayQuit: '退出应用 · 同时结束 AI 会话',
62
73
  addWorkspaces: '选择 AI 工作文件夹',
63
74
  pickWorkspace: '选择工作文件夹',
75
+ attentionTitle: '需要你的确认',
76
+ attentionBody: '{provider} · {title}',
64
77
  },
65
78
  };
66
79
  let lastSnapshot = {
@@ -254,6 +267,39 @@ function sendSnapshot(snapshot) {
254
267
  try { mainWindow.webContents.send('agents:snapshot', snapshot); } catch (error) { reportRecoverableError('ipc-send:agents:snapshot', error); }
255
268
  }
256
269
 
270
+ function openAttentionSession(session) {
271
+ showMainWindow();
272
+ if (!mainWindow || mainWindow.isDestroyed()) return;
273
+ mainWindow.flashFrame(false);
274
+ try {
275
+ mainWindow.webContents.send('agents:attention-requested', { sessionId: String(session && session.id || '') });
276
+ } catch (error) {
277
+ reportRecoverableError('ipc-send:agents:attention-requested', error);
278
+ }
279
+ }
280
+
281
+ function createAttentionNotifier() {
282
+ return new AttentionNotifier({
283
+ Notification,
284
+ isSupported: () => Notification.isSupported(),
285
+ copy: session => {
286
+ const provider = providerList().find(item => item.id === session.provider);
287
+ return {
288
+ title: mainText('attentionTitle'),
289
+ body: mainText('attentionBody', {
290
+ provider: provider && provider.label || session.provider || 'AI',
291
+ title: session.title || '이름 없는 세션',
292
+ }),
293
+ };
294
+ },
295
+ onOpen: openAttentionSession,
296
+ onFallback: session => {
297
+ if (mainWindow && !mainWindow.isDestroyed()) mainWindow.flashFrame(true);
298
+ openAttentionSession(session);
299
+ },
300
+ });
301
+ }
302
+
257
303
  function sendUpdateState(update) {
258
304
  if (!mainWindow || mainWindow.isDestroyed()) return;
259
305
  try { mainWindow.webContents.send('app:update-state', update); } catch (error) { reportRecoverableError('ipc-send:app:update-state', error); }
@@ -278,6 +324,7 @@ function setupRuntime() {
278
324
  downloadsDir: path.join(app.getPath('userData'), 'updates'),
279
325
  });
280
326
  updateManager.on('state', sendUpdateState);
327
+ attentionNotifier = createAttentionNotifier();
281
328
  updateManager.check().catch(error => reportRecoverableError('startup-update-check', error));
282
329
  if (demoCapture) {
283
330
  availability = Object.fromEntries(providerList().map(provider => [provider.id, true]));
@@ -305,6 +352,7 @@ function setupRuntime() {
305
352
  monitorWorker.on('message', message => {
306
353
  if (message && message.type === 'snapshot') {
307
354
  lastSnapshot = message.snapshot;
355
+ attentionNotifier.sync(lastSnapshot);
308
356
  sendSnapshot(lastSnapshot);
309
357
  }
310
358
  if (message && message.type === 'detail-result') {
@@ -479,6 +527,7 @@ app.on('window-all-closed', () => {
479
527
 
480
528
  app.on('before-quit', () => {
481
529
  isQuitting = true;
530
+ if (attentionNotifier) attentionNotifier.dispose();
482
531
  if (bridgeServer) bridgeServer.dispose();
483
532
  if (terminalManager) terminalManager.dispose();
484
533
  if (monitorWorker) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loadtoagent",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "A local desktop dashboard for Claude, Codex, Gemini, and Grok sessions.",
5
5
  "main": "main.js",
6
6
  "author": "wincube AX",
@@ -51,7 +51,7 @@
51
51
  "test:interaction": "electron scripts/interaction-check.js",
52
52
  "demo:readme": "electron scripts/readme-demo.js",
53
53
  "dist": "electron-builder",
54
- "dist:win": "electron-builder --win portable",
54
+ "dist:win": "electron-builder --win",
55
55
  "dist:mac": "electron-builder --mac --publish never",
56
56
  "pack": "electron-builder --dir",
57
57
  "prepublishOnly": "npm test"
@@ -91,6 +91,7 @@
91
91
  "node_modules/node-pty/**/*"
92
92
  ],
93
93
  "win": {
94
+ "icon": "build/icon.ico",
94
95
  "target": [
95
96
  {
96
97
  "target": "portable",
@@ -107,6 +108,7 @@
107
108
  ]
108
109
  },
109
110
  "mac": {
111
+ "icon": "build/icon.png",
110
112
  "category": "public.app-category.developer-tools",
111
113
  "target": [
112
114
  {
package/preload.js CHANGED
@@ -67,6 +67,11 @@ contextBridge.exposeInMainWorld('loadtoagent', {
67
67
  ipcRenderer.on('agents:snapshot', handler);
68
68
  return () => ipcRenderer.removeListener('agents:snapshot', handler);
69
69
  },
70
+ onAttentionRequested: callback => {
71
+ const handler = (_event, payload) => callback(payload);
72
+ ipcRenderer.on('agents:attention-requested', handler);
73
+ return () => ipcRenderer.removeListener('agents:attention-requested', handler);
74
+ },
70
75
  onUpdateState: callback => {
71
76
  const handler = (_event, update) => callback(update);
72
77
  ipcRenderer.on('app:update-state', handler);
@@ -30,7 +30,7 @@
30
30
  ].forEach(install);
31
31
  window.LoadToAgentApp = app;
32
32
 
33
- const { $, esc, state, loadGuideState, bindEvents, render, timeOnly, loadSessionDetail, renderUpdateSettings, syncViewChrome, toast } = app;
33
+ const { $, esc, state, loadGuideState, bindEvents, render, timeOnly, loadSessionDetail, renderUpdateSettings, syncViewChrome, selectView, openDrawer, openSubagentConversation, toast } = app;
34
34
 
35
35
  async function init() {
36
36
  loadGuideState();
@@ -64,6 +64,16 @@
64
64
  if (card && detail && card.updatedAt !== detail.updatedAt) loadSessionDetail(state.selectedId, true);
65
65
  }
66
66
  });
67
+ if (window.loadtoagent.onAttentionRequested)
68
+ window.loadtoagent.onAttentionRequested((payload) => {
69
+ const sessionId = String(payload && payload.sessionId || '');
70
+ const session = (state.snapshot && state.snapshot.sessions || []).find(item => item.id === sessionId);
71
+ selectView('waiting');
72
+ if (session) {
73
+ if (session.parentId) openSubagentConversation(session.id);
74
+ else openDrawer(session.id);
75
+ } else toast('확인이 필요한 세션 목록을 열었습니다.');
76
+ });
67
77
  if (window.loadtoagent.onUpdateState)
68
78
  window.loadtoagent.onUpdateState((update) => {
69
79
  state.update = update;
@@ -245,8 +245,11 @@ window.LoadToAgentAppFactories.createDashboard = function createDashboard(contex
245
245
  <div class="poc-head">
246
246
  <span class="provider-mark">${esc(provider.mark)}</span>
247
247
  <div><strong>${esc(provider.label)}</strong><small>${esc(provider.company)}</small></div>
248
- <span class="poc-state ${provider.installed ? "online" : ""}">
249
- ${provider.installed ? window.LoadToAgentI18n.t("ui.available") : window.LoadToAgentI18n.t("ui.setup_required")}
248
+ <span class="poc-head-states">
249
+ <span class="poc-filter-state ${selected ? "visible" : ""}" aria-hidden="true">✓ ${window.LoadToAgentI18n.t("filter.applied")}</span>
250
+ <span class="poc-state ${provider.installed ? "online" : ""}">
251
+ ${provider.installed ? window.LoadToAgentI18n.t("ui.available") : window.LoadToAgentI18n.t("ui.setup_required")}
252
+ </span>
250
253
  </span>
251
254
  </div>
252
255
  <div class="poc-metrics">
@@ -280,7 +283,8 @@ window.LoadToAgentAppFactories.createDashboard = function createDashboard(contex
280
283
  const selected = id === "all" ? allSelected : state.providerFilters.has(id);
281
284
  return `<button type="button" class="provider-filter-chip ${selected ? "selected" : ""}"
282
285
  data-provider-filter="${esc(id)}" aria-pressed="${selected ? "true" : "false"}">
283
- ${mark ? `<span aria-hidden="true">${esc(mark)}</span>` : ""}<b>${esc(label)}</b>
286
+ <i class="provider-filter-check" aria-hidden="true">✓</i>
287
+ ${mark ? `<span class="provider-filter-mark" aria-hidden="true">${esc(mark)}</span>` : ""}<b>${esc(label)}</b>
284
288
  </button>`;
285
289
  };
286
290
  $("#providerFilter").innerHTML =
@@ -5,9 +5,12 @@ window.LoadToAgentAppFactories = window.LoadToAgentAppFactories || {};
5
5
  window.LoadToAgentAppFactories.createDrawerContent = function createDrawerContent(context = {}) {
6
6
  const { esc, uiLocale, state, messageContentHtml, compact, fullNumber, timeOnly, providerInfo, statusIcon, agentPathTaskName, snapshotSession } = context;
7
7
 
8
- function chatHtml(session) {
8
+ function chatHtml(session, options = {}) {
9
9
  const messages = session.messages || [];
10
10
  if (!messages.length) return '<div class="empty-state"><h3>표시할 대화가 없습니다</h3></div>';
11
+ const userLabel = options.userLabel || "사용자";
12
+ const assistantLabel = options.assistantLabel || providerInfo(session.provider).label;
13
+ const conversationLabel = options.conversationLabel || "대화";
11
14
  const conversation = messages.filter((message) => message.role === "user" || message.role === "assistant");
12
15
  const activities = messages.filter((message) => message.role !== "user" && message.role !== "assistant");
13
16
  const omitted = Number(session.omittedMessages || 0);
@@ -25,12 +28,12 @@ window.LoadToAgentAppFactories.createDrawerContent = function createDrawerConten
25
28
  const role = message.role === "assistant" ? "assistant" : message.role === "tool" ? "tool" : message.role === "system" ? "system" : "user";
26
29
  const label =
27
30
  role === "assistant"
28
- ? providerInfo(session.provider).label
31
+ ? assistantLabel
29
32
  : role === "tool"
30
33
  ? message.title || "도구"
31
34
  : message.role === "system"
32
35
  ? "시스템"
33
- : "사용자";
36
+ : userLabel;
34
37
  const avatar = role === "assistant" ? providerInfo(session.provider).mark : role === "tool" ? "⌘" : role === "system" ? "i" : "ME";
35
38
  const fullTime = new Date(message.timestamp).toLocaleString(uiLocale());
36
39
  return `<div class="chat-row ${role}" data-message-id="${esc(message.id || "")}">
@@ -60,7 +63,7 @@ window.LoadToAgentAppFactories.createDrawerContent = function createDrawerConten
60
63
  : "";
61
64
  const emptyConversation = conversation.length ? "" : '<div class="empty-state compact"><h3>사용자와 AI의 대화는 아직 없습니다</h3></div>';
62
65
  return `${notice}<div class="chat-history-head">
63
- <span>대화 ${conversation.length}개${activities.length ? ` · 활동 ${activities.length}건` : ""}</span>
66
+ <span>${esc(conversationLabel)} ${conversation.length}개${activities.length ? ` · 활동 ${activities.length}건` : ""}</span>
64
67
  <button type="button" data-scroll-latest>가장 최근 대화 ↓</button>
65
68
  </div>
66
69
  <div class="chat-list">${rows}${emptyConversation}${activityHtml}<div class="chat-latest-anchor" aria-label="가장 최근 대화">
@@ -136,68 +139,78 @@ window.LoadToAgentAppFactories.createDrawerContent = function createDrawerConten
136
139
  return { text: `${text.slice(0, maxCharacters).trimEnd()}…`, truncated: true };
137
140
  }
138
141
 
139
- function protectedSubagentEventText(event, taskName) {
140
- if (event.kind === "followup") return "보호된 추가 작업 지시를 전달했습니다.";
141
- if (event.kind === "message") return "보호된 메시지를 전달했습니다.";
142
- return `${taskName || " 작업"}을 서브에이전트에게 배정했습니다.`;
142
+ function subagentCoordinationEvents(session) {
143
+ return subagentCommunicationEvents(session).filter((event) => {
144
+ if (!event || event.protected || event.kind === "started") return false;
145
+ if (!["assignment", "followup", "message", "result", "interrupt"].includes(event.kind)) return false;
146
+ const text = String(event.text || "").trim();
147
+ return Boolean(text && text.toLowerCase() !== "started");
148
+ });
143
149
  }
144
150
 
145
- function subagentConversationHtml(session) {
146
- const events = subagentCommunicationEvents(session);
151
+ function subagentWorkMessages(session) {
152
+ const messages = [...(session.messages || [])];
153
+ const hasConversation = messages.some((message) =>
154
+ (message.role === "user" || message.role === "assistant") && String(message.text || "").trim(),
155
+ );
156
+ if (hasConversation) return messages;
157
+ const delegation = session.delegation || {};
158
+ if (delegation.assignmentObserved && !delegation.assignmentProtected && String(delegation.assignment || "").trim()) {
159
+ messages.push({
160
+ id: `${session.id}:delegation`, role: "user", text: delegation.assignment,
161
+ timestamp: delegation.startedAt || session.startedAt || session.updatedAt,
162
+ });
163
+ }
164
+ if (String(session.result || delegation.result || "").trim()) {
165
+ messages.push({
166
+ id: `${session.id}:result`, role: "assistant", text: session.result || delegation.result,
167
+ timestamp: session.completedAt || delegation.completedAt || session.updatedAt,
168
+ });
169
+ }
170
+ return messages.sort((left, right) => Date.parse(left.timestamp || 0) - Date.parse(right.timestamp || 0));
171
+ }
172
+
173
+ function subagentCoordinationHtml(session) {
174
+ const events = subagentCoordinationEvents(session);
175
+ if (!events.length) return "";
147
176
  const taskName = session.taskName || (session.delegation && session.delegation.taskName) || session.title;
148
177
  const childPath = String(session.agentPath || "");
149
178
  const endpointIsChild = (value) => {
150
179
  const endpoint = String(value || "");
151
180
  return endpoint === childPath || endpoint === session.id || agentPathTaskName(endpoint) === taskName;
152
181
  };
153
- const enriched = events.map((event) => ({ ...event, fromChild: event.kind === "result" || endpointIsChild(event.from) }));
154
- const received = enriched.filter((event) => !event.fromChild && event.kind !== "started").length;
155
- const answered = enriched.filter((event) => event.fromChild).length;
156
- if (!events.length)
157
- return '<div class="empty-state"><h3>메인 AI와 주고받은 기록이 없습니다</h3><p>세션 로그에서 배정·추가 지시·결과 반환 이벤트를 찾지 못했습니다.</p></div>';
158
- const provider = providerInfo(session.provider);
159
- const rows = enriched
182
+ const rows = events
160
183
  .map((event) => {
161
- const runtime = event.kind === "started";
162
- const role = runtime ? "system" : event.fromChild ? "assistant" : "user";
163
- const label = runtime ? "실행 상태" : event.fromChild ? `서브 AI · ${session.agentName || taskName}` : "메인 AI";
164
- const avatar = runtime ? "↗" : event.fromChild ? provider.mark : "M";
165
- const route = runtime ? "런타임 → 서브 AI" : event.fromChild ? "서브 AI → 메인 AI" : "메인 AI → 서브 AI";
166
- const text = event.protected
167
- ? protectedSubagentEventText(event, taskName)
168
- : event.text || (runtime ? "서브에이전트 실행이 시작되었습니다." : "내용 없이 통신 상태만 기록되었습니다.");
169
- const preview = subagentTextPreview(text);
170
- const eventLabel = `${event.label || event.kind}${event.assignmentSource === "parent-narration" ? " · 작업 시작 직전 메인 AI 설명" : ""}`;
171
- return `<div class="chat-row ${role} subagent-dialog-row" data-subagent-communication="${esc(event.kind)}">
172
- <span class="chat-avatar">${esc(avatar)}</span>
173
- <div class="chat-bubble">
174
- <div class="chat-bubble-head">
175
- <b>${esc(label)}</b><span class="subagent-route">${esc(route)}</span>
176
- <span>${esc(timeOnly(event.timestamp))}</span>
177
- </div>
178
- <small class="subagent-event-label">${esc(eventLabel)}</small>
179
- <div class="chat-content subagent-message-preview${preview.truncated ? " is-truncated" : ""}"
180
- data-subagent-message-preview data-truncated="${preview.truncated ? "true" : "false"}">
181
- <p>${esc(preview.text)}</p>
182
- </div>
183
- </div>
184
- </div>`;
184
+ const fromChild = event.kind === "result" || endpointIsChild(event.from);
185
+ const preview = subagentTextPreview(event.text);
186
+ const label = fromChild ? `${session.agentName || taskName} → 메인 AI` : "메인 AI → 서브에이전트";
187
+ return `<article data-subagent-communication="${esc(event.kind)}">
188
+ <header><b>${esc(event.label || event.kind)}</b><span>${esc(label)} · ${esc(timeOnly(event.timestamp))}</span></header>
189
+ <div class="chat-content plain subagent-message-preview${preview.truncated ? " is-truncated" : ""}"
190
+ data-subagent-message-preview data-truncated="${preview.truncated ? "true" : "false"}"><p>${esc(preview.text)}</p></div>
191
+ </article>`;
185
192
  })
186
193
  .join("");
187
- return `<div class="subagent-conversation-summary" data-subagent-dialog-count="${events.length}">
188
- <span>
189
- <small>받은 지시·응답</small>
190
- <b>${received}</b>건</span>
191
- <span>
192
- <small>메인에 보낸 답변</small>
193
- <b>${answered}</b>건</span>
194
- <span>
195
- <small>전체 소통</small>
196
- <b>${events.length}</b>건</span>
197
- </div>
198
- <div class="chat-history-head"><span><b>${esc(taskName)}</b>와 메인 AI가 주고받은 내용만 표시</span><button type="button" data-scroll-latest>가장 최근 대화 ↓</button></div>
199
- <div class="chat-list subagent-dialog-list">${rows}<div class="chat-latest-anchor" aria-label="가장 최근 대화"></div></div>`;
194
+ return `<details class="chat-activities subagent-coordination" data-subagent-coordination-count="${events.length}">
195
+ <summary>메인 AI와 주고받은 지시·결과 ${events.length}건</summary><div>${rows}</div>
196
+ </details>`;
197
+ }
198
+
199
+ function subagentConversationHtml(session) {
200
+ const messages = subagentWorkMessages(session);
201
+ const conversationCount = messages.filter((message) => message.role === "user" || message.role === "assistant").length;
202
+ const workSession = { ...session, messages };
203
+ const sourceCopy = session.source === "collaboration-history"
204
+ ? "별도 세션 로그가 없어 협업 기록에서 복원한 작업 결과입니다."
205
+ : " 서브에이전트 세션에 실제로 저장된 작업 지시와 답변을 시간순으로 표시합니다.";
206
+ return `<section class="subagent-work-source" data-subagent-work-messages="${conversationCount}">
207
+ <b>서브에이전트 실제 작업 기록</b><span>${esc(sourceCopy)}</span>
208
+ </section>${chatHtml(workSession, {
209
+ userLabel: "작업 지시",
210
+ assistantLabel: session.agentName || "서브 AI",
211
+ conversationLabel: "작업 기록",
212
+ })}${subagentCoordinationHtml(session)}`;
200
213
  }
201
214
 
202
- return { chatHtml, lifecycleHtml, tokensHtml, subagentCommunicationEvents, subagentTextPreview, protectedSubagentEventText, subagentConversationHtml };
215
+ return { chatHtml, lifecycleHtml, tokensHtml, subagentCommunicationEvents, subagentCoordinationEvents, subagentTextPreview, subagentConversationHtml };
203
216
  };
@@ -7,7 +7,7 @@ window.LoadToAgentAppFactories.createDrawer = function createDrawer(context = {}
7
7
  $, $$, esc, state, motionPreference, motionState, STATUS, markGuideStep, rememberDialogTrigger, restoreDialogTrigger,
8
8
  providerInfo, isLiveSession, subagentWorkState, subagentWorkLabel, isProjectlessSession, sessionWorkspaceLabel,
9
9
  agentResumeSupport, originAppInfo, selectedSession, snapshotSession, loadSessionDetail, loadSubagentParentDetail,
10
- chatHtml, lifecycleHtml, tokensHtml, subagentCommunicationEvents, subagentConversationHtml,
10
+ chatHtml, lifecycleHtml, tokensHtml, subagentCoordinationEvents, subagentConversationHtml,
11
11
  } = context;
12
12
 
13
13
  function openDrawer(id) {
@@ -44,6 +44,7 @@ window.LoadToAgentAppFactories.createDrawer = function createDrawer(context = {}
44
44
  $("#detailDrawer").removeAttribute("inert");
45
45
  $("#detailDrawer").setAttribute("aria-hidden", "false");
46
46
  renderDrawer();
47
+ loadSessionDetail(id);
47
48
  loadSubagentParentDetail(child);
48
49
  setTimeout(() => $("#closeDrawerBtn").focus(), 0);
49
50
  }
@@ -71,12 +72,12 @@ window.LoadToAgentAppFactories.createDrawer = function createDrawer(context = {}
71
72
  if (!session) return closeDrawer();
72
73
  const provider = providerInfo(session.provider);
73
74
  const subagentMode = state.drawerMode === "subagent" && Boolean(session.parentId);
74
- const detailLoading = !subagentMode && state.detailLoadingIds.has(state.selectedId);
75
+ const detailLoading = state.detailLoadingIds.has(state.selectedId);
75
76
  $("#detailDrawer").style.setProperty("--drawer-provider", provider.accent);
76
77
  $("#drawerProviderMark").style.setProperty("--provider", provider.accent);
77
78
  $("#drawerProviderMark").textContent = provider.mark;
78
79
  $("#drawerProvider").textContent = subagentMode
79
- ? `${session.agentName || provider.label} · 메인 AI와의 소통`
80
+ ? `${session.agentName || provider.label} · 서브에이전트 작업 기록`
80
81
  : `${provider.company} · ${STATUS[session.status] || session.status}`;
81
82
  $("#drawerTitle").textContent = subagentMode ? session.taskName || (session.delegation && session.delegation.taskName) || session.title : session.title;
82
83
  const stopping = session.runId && state.stopRequests.has(session.runId);
@@ -93,14 +94,19 @@ window.LoadToAgentAppFactories.createDrawer = function createDrawer(context = {}
93
94
  <b>${originAppInfo(session) ? "백그라운드 터미널로 이어가기" : "터미널로 다시 일 시키기"}</b>
94
95
  </button>`
95
96
  : "";
96
- const communicationCount = subagentMode ? subagentCommunicationEvents(session).length : 0;
97
+ const communicationCount = subagentMode ? subagentCoordinationEvents(session).length : 0;
98
+ const subagentMessageCount = subagentMode
99
+ ? (session.messages || []).filter((message) => message.role === "user" || message.role === "assistant").length
100
+ : 0;
97
101
  $("#drawerMeta").innerHTML = subagentMode
98
102
  ? `<span class="meta-chip work-state ${subagentWorkState(session)}">
99
103
  <b>${esc(subagentWorkLabel(session))}</b>
100
104
  </span>
101
105
  <span class="meta-chip">사용 모델 <b>${esc(session.model || "정보 없음")}</b>
102
106
  </span>
103
- <span class="meta-chip">메인과 소통 <b>${communicationCount}건</b>
107
+ <span class="meta-chip">작업 기록 <b>${subagentMessageCount}건</b>
108
+ </span>
109
+ <span class="meta-chip">메인 지시·결과 <b>${communicationCount}건</b>
104
110
  </span>${resume}`
105
111
  : `<span class="meta-chip">사용 모델 <b>${esc(session.model || "정보 없음")}</b>
106
112
  </span>
@@ -124,7 +130,7 @@ window.LoadToAgentAppFactories.createDrawer = function createDrawer(context = {}
124
130
  $$(".drawer-tab").forEach((tab) => {
125
131
  const hidden = subagentMode && tab.dataset.tab !== "chat";
126
132
  tab.classList.toggle("hidden", hidden);
127
- if (tab.dataset.tab === "chat") tab.textContent = subagentMode ? "메인과의 대화" : window.LoadToAgentI18n.t("ui.conversation");
133
+ if (tab.dataset.tab === "chat") tab.textContent = subagentMode ? "작업 내용" : window.LoadToAgentI18n.t("ui.conversation");
128
134
  const active = tab.dataset.tab === state.drawerTab;
129
135
  tab.classList.toggle("active", active);
130
136
  tab.setAttribute("aria-selected", active ? "true" : "false");
@@ -144,7 +150,7 @@ window.LoadToAgentAppFactories.createDrawer = function createDrawer(context = {}
144
150
  const detailError = state.detailErrors.get(state.selectedId);
145
151
  content.innerHTML = detailLoading
146
152
  ? '<div class="drawer-loading"><span></span><b>전체 작업 기록을 불러오는 중</b><small>잠시만 기다리면 대화와 진행 과정을 볼 수 있어요.</small></div>'
147
- : detailError && !subagentMode
153
+ : detailError
148
154
  ? `<div class="drawer-error">
149
155
  <b>작업 기록을 불러오지 못했습니다</b>
150
156
  <span>${esc(detailError)}</span>
@@ -48,7 +48,11 @@ window.LoadToAgentAppFactories.createFilterEventBindings = function createFilter
48
48
  renderProviderOverview();
49
49
  renderSessions("filter");
50
50
  announceProviderFilter();
51
- requestAnimationFrame(() => $("#providerFilter").querySelector(`[data-provider-filter="${CSS.escape(chip.dataset.providerFilter)}"]`)?.focus());
51
+ requestAnimationFrame(() => {
52
+ const next = $("#providerFilter").querySelector(`[data-provider-filter="${CSS.escape(chip.dataset.providerFilter)}"]`);
53
+ next?.classList.add("filter-clicked");
54
+ next?.focus();
55
+ });
52
56
  });
53
57
  $("#sortSelect").addEventListener("change", (event) => {
54
58
  state.sort = event.target.value;
@@ -33,7 +33,7 @@ window.LoadToAgentAppFactories.createNavigationEventBindings = function createNa
33
33
  state.guideExpanded = false;
34
34
  saveGuideState();
35
35
  renderSessions("guide");
36
- $("#guideBtn").focus();
36
+ $("#guideBtn").focus({ preventScroll: true });
37
37
  });
38
38
  $("#beginnerGuide").addEventListener("click", (event) => {
39
39
  const action = event.target.closest("[data-guide-action]")?.dataset.guideAction;
@@ -18,7 +18,11 @@ window.LoadToAgentAppFactories.createSessionEventBindings = function createSessi
18
18
  renderProviderOverview();
19
19
  renderSessions("filter");
20
20
  announceProviderFilter();
21
- requestAnimationFrame(() => $("#providerOverview").querySelector(`[data-provider-card="${CSS.escape(card.dataset.providerCard)}"]`)?.focus());
21
+ requestAnimationFrame(() => {
22
+ const next = $("#providerOverview").querySelector(`[data-provider-card="${CSS.escape(card.dataset.providerCard)}"]`);
23
+ next?.classList.add("filter-clicked");
24
+ next?.focus();
25
+ });
22
26
  });
23
27
  $("#sessionGrid").addEventListener("click", (event) => {
24
28
  const card = event.target.closest("[data-session-id]");
@@ -150,6 +154,22 @@ window.LoadToAgentAppFactories.createSessionEventBindings = function createSessi
150
154
 
151
155
  function bindTmuxMapEvents() {
152
156
  $("#tmuxMap").addEventListener("click", (event) => {
157
+ const subagentToggle = event.target.closest("[data-tmux-subagents-toggle]");
158
+ if (subagentToggle) {
159
+ event.stopPropagation();
160
+ const paneId = subagentToggle.dataset.tmuxSubagentsToggle;
161
+ if (state.expandedTmuxSubagents.has(paneId)) state.expandedTmuxSubagents.delete(paneId);
162
+ else state.expandedTmuxSubagents.add(paneId);
163
+ renderTmuxMap();
164
+ requestAnimationFrame(() => $("#tmuxMap").querySelector(`[data-tmux-subagents-toggle="${CSS.escape(paneId)}"]`)?.focus());
165
+ return;
166
+ }
167
+ const subagentChat = event.target.closest("[data-open-subagent-chat]");
168
+ if (subagentChat) {
169
+ event.stopPropagation();
170
+ openSubagentConversation(subagentChat.dataset.openSubagentChat);
171
+ return;
172
+ }
153
173
  const control = event.target.closest("[data-control-tmux]");
154
174
  if (control) {
155
175
  event.stopPropagation();
@@ -103,5 +103,51 @@ window.LoadToAgentAppFactories.createGraphModel = function createGraphModel(cont
103
103
  return seen.size;
104
104
  }
105
105
 
106
- return { graphPath, connectedGraphSessions, sortGraphNodes, graphChildren, agentExecutionMode, executionModeBadge, graphDescendantCount };
106
+ function runtimeAgentSummary(model, entries = []) {
107
+ const liveNodes = model.nodes.filter(isLiveSession);
108
+ const liveById = new Map(liveNodes.map((session) => [session.id, session]));
109
+ const activeEntries = entries.filter((entry) => entry && entry.pane && !entry.pane.dead);
110
+ const tmuxKeys = new Set();
111
+ const tmuxSessionIds = new Set();
112
+ const paneKeys = new Map();
113
+
114
+ for (const entry of activeEntries) {
115
+ const linkedSessionId = String(entry.agent && entry.agent.linkedSessionId || "");
116
+ if (linkedSessionId && liveById.has(linkedSessionId)) {
117
+ tmuxSessionIds.add(linkedSessionId);
118
+ tmuxKeys.add(`session:${linkedSessionId}`);
119
+ continue;
120
+ }
121
+ const paneId = String(entry.pane.nativeId || entry.pane.id || "");
122
+ const distroId = String(entry.distro && (entry.distro.id || entry.distro.name) || "");
123
+ if (paneId) {
124
+ const key = `pane:${distroId}:${paneId}`;
125
+ tmuxKeys.add(key);
126
+ paneKeys.set(String(entry.pane.id || ""), key);
127
+ paneKeys.set(String(entry.pane.nativeId || ""), key);
128
+ }
129
+ }
130
+
131
+ for (const session of liveNodes) {
132
+ const presence = (session.runtimePresence || []).find((item) => item.kind === "tmux");
133
+ if (!presence) continue;
134
+ tmuxSessionIds.add(session.id);
135
+ const paneKey = [presence.paneId, presence.paneNativeId, presence.nativeId]
136
+ .map((value) => paneKeys.get(String(value || "")))
137
+ .find(Boolean);
138
+ tmuxKeys.add(paneKey || `session:${session.id}`);
139
+ }
140
+
141
+ const standardCount = liveNodes.filter((session) => !tmuxSessionIds.has(session.id)).length;
142
+ const tmuxCount = tmuxKeys.size;
143
+ return {
144
+ activeCount: standardCount + tmuxCount,
145
+ standardCount,
146
+ tmuxCount,
147
+ activeHelperCount: liveNodes.filter((session) => session.parentId).length,
148
+ helperRecordCount: model.nodes.filter((session) => session.parentId).length,
149
+ };
150
+ }
151
+
152
+ return { graphPath, connectedGraphSessions, sortGraphNodes, graphChildren, agentExecutionMode, executionModeBadge, graphDescendantCount, runtimeAgentSummary };
107
153
  };
@@ -13,8 +13,8 @@ window.LoadToAgentAppFactories.createGraphOrchestration = function createGraphOr
13
13
  graphPath,
14
14
  connectedGraphSessions,
15
15
  sortGraphNodes,
16
- agentExecutionMode,
17
16
  liveTmuxEntries,
17
+ runtimeAgentSummary,
18
18
  runtimeSeparatedOverview,
19
19
  focusedGraph,
20
20
  scheduleAgentWorkflowConnections,
@@ -50,24 +50,25 @@ window.LoadToAgentAppFactories.createGraphOrchestration = function createGraphOr
50
50
  scheduleAgentWorkflowConnections();
51
51
  } else {
52
52
  const tmuxEntries = liveTmuxEntries();
53
- const standardRoots = roots.filter((root) => agentExecutionMode(root).kind !== "tmux");
54
- const activeCount = model.nodes.filter(isLiveSession).length + tmuxEntries.filter((entry) => !entry.agent.linkedSessionId).length;
53
+ const runtime = runtimeAgentSummary(model, tmuxEntries);
55
54
  $("#liveSessionGrid").innerHTML = `<details class="runtime-disclosure" open>
56
55
  <summary>
57
56
  <span>
58
- <b>${activeCount}개 AI가 작업 중입니다</b>
59
- <small>일반 실행과 tmux의 세부 흐름은 필요할 펼쳐보세요.</small>
57
+ <b>${runtime.activeCount}개 AI가 작업 중입니다</b>
58
+ <small>일반 실행 AI ${runtime.standardCount}개와 TMUX AI ${runtime.tmuxCount}개를 합산했습니다. 작업 중 도움 AI ${runtime.activeHelperCount}개는 이 수에 포함됩니다.</small>
60
59
  </span>
61
60
  <em>상세 흐름 보기 <i aria-hidden="true">↓</i>
62
61
  </em>
63
62
  </summary>${runtimeSeparatedOverview(roots, model)}</details>`;
64
63
  $("#graphBreadcrumbs").innerHTML =
65
64
  `<span class="map-hint">
66
- 일반 실행 <b>${standardRoots.length}</b>개 ·
67
- TMUX AI <b>${tmuxEntries.length}</b>개 ·
68
- <b>${model.nodes.filter((item) => item.parentId).length}</b>개 도움 AI
65
+ 일반 실행 AI <b>${runtime.standardCount}</b>개 ·
66
+ TMUX AI <b>${runtime.tmuxCount}</b>개 ·
67
+ 작업 중 도움 AI <b>${runtime.activeHelperCount}</b>개 ·
68
+ 도움 AI 기록 <b>${runtime.helperRecordCount}</b>개
69
69
  </span>`;
70
70
  $("#graphResetBtn").classList.add("hidden");
71
+ return runtime.activeCount;
71
72
  }
72
73
  return model.nodes.filter(isLiveSession).length + liveTmuxEntries().filter((entry) => !entry.agent.linkedSessionId).length;
73
74
  }