loadtoagent 1.3.7 → 1.3.8

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.
@@ -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
  }
@@ -25,6 +25,14 @@ window.LoadToAgentAppFactories.createGraphOrchestration = function createGraphOr
25
25
  } = context;
26
26
  const t = (key, params) => window.LoadToAgentI18n.t(key, params);
27
27
 
28
+ function syncControlRoomDisclosureButtons() {
29
+ const groups = Array.from(document.querySelectorAll("#liveSessionGrid .control-room-project-group"));
30
+ const canExpand = groups.some(group => !group.open);
31
+ const canCollapse = groups.some(group => group.open);
32
+ if ($("#controlRoomExpandAll")) $("#controlRoomExpandAll").disabled = !canExpand;
33
+ if ($("#controlRoomCollapseAll")) $("#controlRoomCollapseAll").disabled = !canCollapse;
34
+ }
35
+
28
36
  function renderAgentMap(sessions, motionKind = "refresh") {
29
37
  const liveSessionGrid = $("#liveSessionGrid");
30
38
  rememberDisclosureStates(liveSessionGrid);
@@ -45,9 +53,7 @@ window.LoadToAgentAppFactories.createGraphOrchestration = function createGraphOr
45
53
  $("#agentMapToolbar")?.classList.add("hidden");
46
54
  $("#controlRoomProjectToolbar")?.classList.remove("hidden");
47
55
  $("#controlRoomListToolbar")?.classList.remove("hidden");
48
- if ($("#controlRoomPageSummary")) $("#controlRoomPageSummary").textContent = t("control.page_summary", { start: 0, end: 0, total: 0 });
49
- if ($("#controlRoomPagePrev")) $("#controlRoomPagePrev").disabled = true;
50
- if ($("#controlRoomPageNext")) $("#controlRoomPageNext").disabled = true;
56
+ syncControlRoomDisclosureButtons();
51
57
  return 0;
52
58
  }
53
59
 
@@ -71,30 +77,18 @@ window.LoadToAgentAppFactories.createGraphOrchestration = function createGraphOr
71
77
  scheduleAgentWorkflowConnections();
72
78
  } else {
73
79
  const runtime = runtimeAgentSummary(model, liveTmuxEntries(state.snapshot && state.snapshot.tmux));
74
- const pageSize = Math.max(1, Number(state.controlRoomPageSize || 4));
75
- const maxPage = Math.max(0, Math.ceil(roots.length / pageSize) - 1);
76
- state.controlRoomPage = Math.min(maxPage, Math.max(0, Number(state.controlRoomPage || 0)));
77
- const startIndex = state.controlRoomPage * pageSize;
78
- const endIndex = Math.min(roots.length, startIndex + pageSize);
79
- const visibleRoots = roots.slice(startIndex, endIndex);
80
- liveSessionGrid.innerHTML = runtimeSeparatedOverview(visibleRoots, model, roots);
80
+ liveSessionGrid.innerHTML = runtimeSeparatedOverview(roots, model, roots);
81
81
  restoreDisclosureStates(liveSessionGrid);
82
82
  $("#graphBreadcrumbs").innerHTML = "";
83
83
  $("#agentMapToolbar")?.classList.add("hidden");
84
84
  $("#controlRoomProjectToolbar")?.classList.remove("hidden");
85
85
  $("#controlRoomListToolbar")?.classList.remove("hidden");
86
- if ($("#controlRoomPageSummary")) $("#controlRoomPageSummary").textContent = t("control.page_summary", {
87
- start: roots.length ? startIndex + 1 : 0,
88
- end: endIndex,
89
- total: roots.length,
90
- });
91
- if ($("#controlRoomPagePrev")) $("#controlRoomPagePrev").disabled = state.controlRoomPage === 0;
92
- if ($("#controlRoomPageNext")) $("#controlRoomPageNext").disabled = state.controlRoomPage >= maxPage;
86
+ syncControlRoomDisclosureButtons();
93
87
  $("#graphResetBtn").classList.add("hidden");
94
88
  return runtime.activeCount;
95
89
  }
96
90
  return model.nodes.filter(isControlRoomSession).length;
97
91
  }
98
92
 
99
- return { renderAgentMap };
93
+ return { renderAgentMap, syncControlRoomDisclosureButtons };
100
94
  };
@@ -34,6 +34,7 @@ window.LoadToAgentAppFactories.createGraphView = function createGraphView(contex
34
34
  graphDescendantCount,
35
35
  sessionWorkspaceLabel,
36
36
  controlRoomProject = session => ({ key: String(session?.workspace || session?.id || "unknown"), label: sessionWorkspaceLabel(session) }),
37
+ ensureProjectOrder = projectKeys => projectKeys,
37
38
  } = context;
38
39
  const t = (key, params) => window.LoadToAgentI18n.t(key, params);
39
40
  const statusLabel = (status) => ({
@@ -485,7 +486,7 @@ window.LoadToAgentAppFactories.createGraphView = function createGraphView(contex
485
486
  const waitingWithBackground = waiting && activeExecutions.some(item => item.activity.mode === "background" || item.activity.kind === "background");
486
487
  const sessionStateKey = waitingWithBackground
487
488
  ? "control.waiting_background_session"
488
- : (waiting ? "control.waiting_session" : "control.live_session");
489
+ : (waiting ? "control.waiting_session" : (retained ? "control.recently_completed" : "control.live_session"));
489
490
  const retention = retained ? `<small class="control-session-retention">${esc(t("control.auto_history_in_minutes", { minutes: sessionRetentionMinutes(root) }))}</small>` : "";
490
491
  const archive = retained ? `<button type="button" class="control-session-archive" data-session-archive="${esc(root.id)}">${esc(t("control.move_to_history"))}</button>` : "";
491
492
  return `<article class="control-room-session ${waiting ? "is-waiting" : ""} ${waitingWithBackground ? "has-background-work" : ""}" data-control-session="${esc(root.id)}" data-session-sortable="${esc(root.id)}"
@@ -519,20 +520,30 @@ window.LoadToAgentAppFactories.createGraphView = function createGraphView(contex
519
520
  if (!groups.has(key)) groups.set(key, { name, roots: [] });
520
521
  groups.get(key).roots.push(root);
521
522
  });
522
- const projectGroups = [...groups.entries()].map(([key, { name, roots: projectRoots }], index) => {
523
+ const defaultOrderedGroups = [...groups.entries()].sort(([leftKey], [rightKey]) =>
524
+ Number(allGroups.get(rightKey)?.roots.length || 0) - Number(allGroups.get(leftKey)?.roots.length || 0));
525
+ const projectOrder = ensureProjectOrder(defaultOrderedGroups.map(([key]) => key));
526
+ const projectRank = new Map(projectOrder.map((key, index) => [key, index]));
527
+ const orderedGroups = defaultOrderedGroups.sort(([leftKey], [rightKey]) =>
528
+ Number(projectRank.get(leftKey) ?? Number.MAX_SAFE_INTEGER) - Number(projectRank.get(rightKey) ?? Number.MAX_SAFE_INTEGER));
529
+ const projectGroups = orderedGroups.map(([key, { name, roots: projectRoots }], index) => {
523
530
  const projectTotals = allGroups.get(key)?.roots || projectRoots;
524
531
  const activeCount = projectTotals.filter((root) => isLiveSession(root)).length;
525
- const attentionCount = projectTotals.filter((root) => !isLiveSession(root) && isControlRoomSession(root)).length;
532
+ const attentionCount = projectTotals.filter((root) =>
533
+ !isLiveSession(root)
534
+ && isControlRoomSession(root)
535
+ && ["waiting", "failed", "paused"].includes(root.status)).length;
526
536
  const summary = attentionCount
527
537
  ? t("control.project_live_attention_summary", { active: activeCount, attention: attentionCount })
528
538
  : t("control.project_live_summary", { active: activeCount });
529
539
  const disclosureKey = `control-project:${key}`;
530
540
  const presentation = index === 0 ? "is-primary" : "is-secondary";
531
541
  const projectFocusId = projectRoots[0]?.id || "";
532
- return `<details class="control-room-project-group ${presentation}" data-control-project="${esc(name)}" data-disclosure-key="${esc(disclosureKey)}" ${index === 0 ? "open" : ""}>
533
- <summary class="control-project-header" data-project-toggle="${esc(name)}">
542
+ return `<details class="control-room-project-group ${presentation}" data-control-project="${esc(name)}" data-project-sortable="${esc(key)}" data-disclosure-key="${esc(disclosureKey)}">
543
+ <summary class="control-project-header" data-project-toggle="${esc(name)}" draggable="true" aria-grabbed="false"
544
+ aria-keyshortcuts="Alt+ArrowUp Alt+ArrowDown" aria-label="${esc(t("project.drag_label", { name }))}" aria-describedby="projectReorderHelp">
534
545
  <span class="control-project-heading"><i aria-hidden="true">□</i><b>${esc(name)}</b><small>${esc(summary)}</small><em>${projectTotals.length}</em></span>
535
- <span class="control-project-handle" role="img" aria-label="프로젝트 그룹" title="프로젝트 그룹 · 세션은 최근 활동순으로 표시됩니다"></span>
546
+ <span class="control-project-handle" aria-hidden="true" title="${esc(t("project.reorder_hint"))}"></span>
536
547
  </summary>
537
548
  <button type="button" class="control-project-flow-link" data-graph-focus="${esc(projectFocusId)}"><span>${esc(t("control.open_full_flow"))} ↗</span></button>
538
549
  <div class="control-project-body">${projectRoots.map(root => controlRoomSession(root, model)).join("")}</div>
@@ -98,12 +98,16 @@ window.LoadToAgentAppFactories.createQualityEnhancements = function createQualit
98
98
  state.sessionOrder = Array.isArray(dashboard.sessionOrder)
99
99
  ? dashboard.sessionOrder.filter(id => typeof id === "string" && id.length <= 500).slice(0, 1_000)
100
100
  : [];
101
+ state.projectOrder = Array.isArray(dashboard.projectOrder)
102
+ ? dashboard.projectOrder.filter(id => typeof id === "string" && id.length <= 2_000).slice(0, 1_000)
103
+ : [];
101
104
  } else {
102
105
  state.search = "";
103
106
  state.providerFilters.clear();
104
107
  state.workspace = "all";
105
108
  state.sort = "recent";
106
109
  state.sessionOrder = [];
110
+ state.projectOrder = [];
107
111
  }
108
112
  const search = $("#searchInput");
109
113
  if (search) search.value = state.search;
@@ -133,6 +137,7 @@ window.LoadToAgentAppFactories.createQualityEnhancements = function createQualit
133
137
  sort: ["recent", "tokens", "context"].includes(state.sort) ? state.sort : "recent",
134
138
  controlRoomSort: ["recent", "tokens", "context"].includes(state.controlRoomSort) ? state.controlRoomSort : "recent",
135
139
  sessionOrder: (state.sessionOrder || []).filter(id => typeof id === "string" && id.length <= 500).slice(0, 1_000),
140
+ projectOrder: (state.projectOrder || []).filter(id => typeof id === "string" && id.length <= 2_000).slice(0, 1_000),
136
141
  };
137
142
  try {
138
143
  localStorage.setItem(DASHBOARD_STORAGE_KEY, JSON.stringify(value));
package/renderer/app.js CHANGED
@@ -24,6 +24,7 @@ window.LoadToAgentAppFactories.createCore = function createCore(context = {}) {
24
24
  search: "",
25
25
  sort: "recent",
26
26
  sessionOrder: [],
27
+ projectOrder: [],
27
28
  sessionArchives: new Map(),
28
29
  controlRoomObservedIds: new Set(),
29
30
  selectedId: null,
@@ -36,8 +37,6 @@ window.LoadToAgentAppFactories.createCore = function createCore(context = {}) {
36
37
  drawerForceLatest: false,
37
38
  visibleLimit: 30,
38
39
  graphFocusId: null,
39
- controlRoomPage: 0,
40
- controlRoomPageSize: 4,
41
40
  controlRoomSort: "recent",
42
41
  supervisionFocusId: null,
43
42
  graphExpandedProviders: new Set(),
@@ -50,6 +49,7 @@ window.LoadToAgentAppFactories.createCore = function createCore(context = {}) {
50
49
  agentCommandTargets: new Map(),
51
50
  agentCommandRoutes: new Map(),
52
51
  agentCommandSending: new Set(),
52
+ pendingConversationMessages: new Map(),
53
53
  stopRequests: new Set(),
54
54
  runControlRequests: new Set(),
55
55
  managementFilter: "all",
@@ -614,14 +614,18 @@ window.LoadToAgentAppFactories.createCore = function createCore(context = {}) {
614
614
  }
615
615
  function isControlRoomSession(session, now = Date.now()) {
616
616
  if (!session) return false;
617
- if (isLiveSession(session) || hasRunningExecution(session)) {
617
+ if (isLiveSession(session)) {
618
+ state.controlRoomObservedIds.add(String(session.id || ""));
619
+ return true;
620
+ }
621
+ if (isSessionManuallyArchived(session)) return false;
622
+ if (hasRunningExecution(session)) {
618
623
  state.controlRoomObservedIds.add(String(session.id || ""));
619
624
  return true;
620
625
  }
621
626
  if (!state.controlRoomObservedIds.has(String(session.id || ""))) return false;
622
627
  const responseAt = sessionResponseTimestamp(session);
623
628
  const retained = Boolean(responseAt
624
- && !isSessionManuallyArchived(session)
625
629
  && Math.max(0, Number(now) - responseAt) < SESSION_RETENTION_MS);
626
630
  if (!retained && responseAt && Math.max(0, Number(now) - responseAt) >= SESSION_RETENTION_MS) {
627
631
  state.controlRoomObservedIds.delete(String(session.id || ""));
@@ -629,7 +633,11 @@ window.LoadToAgentAppFactories.createCore = function createCore(context = {}) {
629
633
  return retained;
630
634
  }
631
635
  function controlRoomStatus(session, now = Date.now()) {
632
- return isLiveSession(session) ? session.status : (isControlRoomSession(session, now) ? "waiting" : session?.status);
636
+ // Retention controls where a recently active session is shown, not what
637
+ // state it is in. Preserve the observed provider status so an idle or
638
+ // completed session is never presented as waiting for user input.
639
+ isControlRoomSession(session, now);
640
+ return session?.status;
633
641
  }
634
642
  function sessionRetentionMinutes(session, now = Date.now()) {
635
643
  const remaining = SESSION_RETENTION_MS - Math.max(0, Number(now) - sessionResponseTimestamp(session));
@@ -213,6 +213,7 @@
213
213
  "project.all": {"ko":"모든 프로젝트 폴더","en":"All project folders","zh-CN":"所有项目文件夹"},
214
214
  "project.empty": {"ko":"AI 세션이 감지되면 작업 시작 폴더별로 자동 분류됩니다.","en":"Detected AI sessions are grouped automatically by their work-origin folder.","zh-CN":"检测到 AI 会话后,将按任务起始文件夹自动分组。"},
215
215
  "project.filter_named": {"ko":"{name} 폴더에서 시작한 AI 세션 {count}개 보기","en":"Show {count} AI sessions started from the {name} folder","zh-CN":"查看从 {name} 文件夹启动的 {count} 个 AI 会话"},
216
+ "project.in_progress": {"ko":"진행 중","en":"In progress","zh-CN":"进行中"},
216
217
  "project.origin": {"ko":"작업 시작 폴더","en":"Work origin folder","zh-CN":"任务起始文件夹"},
217
218
  "project.origin_named": {"ko":"작업 시작 폴더 · {name}","en":"Work origin folder · {name}","zh-CN":"任务起始文件夹 · {name}"},
218
219
  "bootstrap.module_missing": {"ko":"앱 모듈을 찾지 못했습니다: {name}","en":"App module not found: {name}","zh-CN":"找不到应用模块:{name}"},
@@ -285,6 +286,7 @@
285
286
  "agent.select_target_first": {"ko":"지시를 보낼 터미널을 먼저 선택하세요.","en":"Choose a terminal before sending an instruction.","zh-CN":"请先选择要发送指令的终端。"},
286
287
  "agent.no_writable_terminal": {"ko":"이 AI에 연결된 입력 가능한 터미널이 없습니다.","en":"There is no writable terminal connected to this AI.","zh-CN":"没有连接到此 AI 的可输入终端。"},
287
288
  "agent.command_sent": {"ko":"{target}에 지시를 보냈습니다.","en":"Sent the instruction to {target}.","zh-CN":"已将指令发送到 {target}。"},
289
+ "agent.command_sent_background": {"ko":"대화에 메시지를 보냈습니다. 세션 연결과 AI 응답은 백그라운드에서 이어집니다.","en":"Sent the message. Session reconnection and the AI response will continue in the background.","zh-CN":"消息已发送。会话重连和 AI 回复将在后台继续。"},
288
290
  "agent.recovered_and_sent": {"ko":"원래 터미널 연결이 종료되어 같은 AI 세션으로 복구한 뒤 지시를 보냈습니다.","en":"The original terminal connection ended, so the AI session was restored before sending the instruction.","zh-CN":"原终端连接已结束,因此恢复同一 AI 会话后发送了指令。"},
289
291
  "agent.recovery_failed": {"ko":"터미널 연결이 끊어졌고 세션 복구에도 실패했습니다.","en":"The terminal disconnected and session recovery also failed.","zh-CN":"终端连接已断开,会话恢复也失败。"},
290
292
  "agent.send_failed": {"ko":"터미널에 지시를 보내지 못했습니다.","en":"Could not send the instruction to the terminal.","zh-CN":"无法向终端发送指令。"},
@@ -348,6 +350,9 @@
348
350
  "drawer.current_progress": {"ko":"현재 진행 상황","en":"Current progress","zh-CN":"当前进度"},
349
351
  "drawer.last_progress": {"ko":"마지막 진행 상황","en":"Latest progress","zh-CN":"最近进度"},
350
352
  "drawer.preparing_response": {"ko":"AI가 현재 응답을 준비하고 있습니다","en":"The AI is preparing a response","zh-CN":"AI 正在准备回复"},
353
+ "drawer.message_sending": {"ko":"보내는 중","en":"Sending","zh-CN":"发送中"},
354
+ "drawer.message_sent": {"ko":"전송됨","en":"Sent","zh-CN":"已发送"},
355
+ "drawer.message_failed": {"ko":"전송 실패","en":"Send failed","zh-CN":"发送失败"},
351
356
  "drawer.progress_updates": {"ko":"진행 업데이트 {count}개","en":"{count} progress updates","zh-CN":"{count} 条进度更新"},
352
357
  "drawer.progress_updates_help": {"ko":"중간 작업 설명과 원문 전체 보기","en":"View intermediate notes and full text","zh-CN":"查看中间说明和完整原文"},
353
358
  "drawer.progress_update_item": {"ko":"진행 {count}","en":"Update {count}","zh-CN":"进度 {count}"},
@@ -438,6 +443,10 @@
438
443
  "session.reorder_hint": {"ko":"끌어서 위치 변경 · Alt+↑/↓ 키도 사용 가능","en":"Drag to reorder · Alt+↑/↓ also works","zh-CN":"拖动调整位置 · 也可使用 Alt+↑/↓"},
439
444
  "session.reorder_help": {"ko":"세션 카드를 끌어 놓아 위치를 바꿀 수 있습니다. 키보드에서는 카드에 초점을 둔 뒤 Alt와 위아래 화살표 키를 사용하세요.","en":"Drag and drop a session card to change its position. With a keyboard, focus the card and use Alt with the up or down arrow key.","zh-CN":"拖放会话卡片可调整位置。使用键盘时,请聚焦卡片并按 Alt 加上或下方向键。"},
440
445
  "session.position_changed": {"ko":"세션 위치를 변경했습니다.","en":"Session position updated.","zh-CN":"会话位置已更新。"},
446
+ "project.drag_label": {"ko":"{name} 프로젝트 위치 변경","en":"Reorder {name} project","zh-CN":"调整 {name} 项目的位置"},
447
+ "project.reorder_hint": {"ko":"끌어서 프로젝트 위치 변경 · 닫힌 상태와 Alt+↑/↓ 키도 지원","en":"Drag to reorder · works while collapsed and with Alt+↑/↓","zh-CN":"拖动调整项目位置 · 折叠状态及 Alt+↑/↓ 均可用"},
448
+ "project.reorder_help": {"ko":"프로젝트 헤더를 끌어 놓아 위치를 바꿀 수 있습니다. 프로젝트가 닫혀 있어도 이동할 수 있으며, 키보드에서는 헤더에 초점을 둔 뒤 Alt와 위아래 화살표 키를 사용하세요.","en":"Drag and drop a project header to change its position, even while collapsed. With a keyboard, focus the header and use Alt with the up or down arrow key.","zh-CN":"拖放项目标题可调整位置,折叠时也可以操作。使用键盘时,请聚焦标题并按 Alt 加上或下方向键。"},
449
+ "project.position_changed": {"ko":"프로젝트 위치를 변경했습니다.","en":"Project position updated.","zh-CN":"项目位置已更新。"},
441
450
  "bootstrap.open_in_app": {"ko":"LoadToAgent 프로그램에서 열면 이 컴퓨터의 AI 작업 기록을 불러옵니다.","en":"Open this page in LoadToAgent to load AI work history from this computer.","zh-CN":"请在 LoadToAgent 应用中打开此页面,以加载此电脑上的 AI 工作记录。"},
442
451
  "bootstrap.opened_attention_list": {"ko":"확인이 필요한 세션 목록을 열었습니다.","en":"Opened the list of sessions that need your review.","zh-CN":"已打开需要您确认的会话列表。"},
443
452
  "bootstrap.initialization_failed": {"ko":"초기화 실패: {message}","en":"Initialization failed: {message}","zh-CN":"初始化失败:{message}"},
@@ -698,6 +707,7 @@
698
707
  "terminal.agent.no_input_target": {"ko":"이 AI가 실행 중인 입력 가능한 터미널을 찾지 못했습니다. 외부 터미널에서 시작한 AI는 LoadToAgent가 직접 입력할 수 없습니다.","en":"No active terminal that accepts input was found for this AI. LoadToAgent cannot type into an AI started in an external terminal.","zh-CN":"找不到此 AI 正在运行且可输入的终端。LoadToAgent 无法直接向在外部终端中启动的 AI 输入内容。"},
699
708
  "terminal.agent.reconnected": {"ko":"{provider} 세션 {sessionId}을 다시 연결했습니다. 이어서 지시할 수 있습니다.","en":"Reconnected {provider} session {sessionId}. You can continue giving instructions.","zh-CN":"已重新连接 {provider} 会话 {sessionId},您可以继续发出指令。"},
700
709
  "terminal.agent.resume_terminal_failed": {"ko":"AI 세션을 재개할 터미널을 만들지 못했습니다.","en":"Could not create a terminal to resume the AI session.","zh-CN":"无法创建用于恢复 AI 会话的终端。"},
710
+ "terminal.agent.wsl_distro_missing": {"ko":"이 세션은 WSL에서 시작됐지만 사용할 Linux 배포판을 확인할 수 없습니다. tmux 화면을 새로고침한 뒤 다시 시도해 주세요.","en":"This session started in WSL, but its Linux distribution could not be identified. Refresh the tmux view and try again.","zh-CN":"此会话在 WSL 中启动,但无法识别其 Linux 发行版。请刷新 tmux 视图后重试。"},
701
711
  "terminal.agent.resume_title": {"ko":"{provider} · {session} 이어서 작업","en":"{provider} · Continue {session}","zh-CN":"{provider} · 继续 {session}"},
702
712
  "terminal.agent.resumed_and_sent": {"ko":"{provider} 세션 {sessionId}을 이어받아 지시를 보냈습니다.","en":"Resumed {provider} session {sessionId} and sent the instructions.","zh-CN":"已恢复 {provider} 会话 {sessionId} 并发送指令。"},
703
713
  "terminal.agent.select_target": {"ko":"지시를 보낼 터미널을 먼저 선택하세요.","en":"Select the terminal to send instructions to first.","zh-CN":"请先选择要发送指令的终端。"},
@@ -1363,6 +1373,11 @@
1363
1373
  "control.waiting_background_session": {"ko":"응답 대기 · 백그라운드 실행 중","en":"Waiting for response · background work running","zh-CN":"等待回复 · 后台任务运行中"},
1364
1374
  "control.auto_history_in_minutes": {"ko":"{minutes}분 뒤 지난 기록으로 이동","en":"Moves to history in {minutes} min","zh-CN":"{minutes} 分钟后移至历史记录"},
1365
1375
  "control.move_to_history": {"ko":"지난 기록으로 이동","en":"Move to history","zh-CN":"移至历史记录"},
1376
+ "control.project_group_actions": {"ko":"프로젝트 그룹 열기와 닫기","en":"Project group expand and collapse controls","zh-CN":"项目组展开和折叠控件"},
1377
+ "control.expand_all_projects": {"ko":"모두 열기","en":"Expand all","zh-CN":"全部展开"},
1378
+ "control.collapse_all_projects": {"ko":"모두 닫기","en":"Collapse all","zh-CN":"全部折叠"},
1379
+ "control.all_projects_expanded": {"ko":"모든 프로젝트 그룹을 열었습니다.","en":"Expanded all project groups.","zh-CN":"已展开所有项目组。"},
1380
+ "control.all_projects_collapsed": {"ko":"모든 프로젝트 그룹을 닫았습니다.","en":"Collapsed all project groups.","zh-CN":"已折叠所有项目组。"},
1366
1381
  "control.moved_to_history": {"ko":"세션을 지난 기록으로 옮겼습니다.","en":"Session moved to history.","zh-CN":"会话已移至历史记录。"},
1367
1382
  "control.live_summary": {"ko":"메인 {sessions} · 서브 {helpers} · 실행 {executions}","en":"{sessions} lead · {helpers} helper · {executions} commands","zh-CN":"主 {sessions} · 协助 {helpers} · 执行 {executions}"},
1368
1383
  "control.main_assignment": {"ko":"메인 에이전트가 시킨 일","en":"Assigned by the lead agent","zh-CN":"主智能体分配的任务"},
@@ -294,6 +294,7 @@
294
294
  </section>
295
295
 
296
296
  <p id="sessionReorderHelp" class="sr-only" data-i18n="session.reorder_help">세션 카드를 끌어 놓아 위치를 바꿀 수 있습니다. 키보드에서는 카드에 초점을 둔 뒤 Alt와 위아래 화살표 키를 사용하세요.</p>
297
+ <p id="projectReorderHelp" class="sr-only" data-i18n="project.reorder_help">프로젝트 헤더를 끌어 놓아 위치를 바꿀 수 있습니다. 프로젝트가 닫혀 있어도 이동할 수 있으며, 키보드에서는 헤더에 초점을 둔 뒤 Alt와 위아래 화살표 키를 사용하세요.</p>
297
298
  <section id="liveSection" class="live-section hidden" aria-label="현재 진행 중인 AI 작업" data-i18n-aria-label="ui.ai_work_currently_in_progress">
298
299
  <div class="live-section-head">
299
300
  <div class="live-section-title"><span class="live-beacon"></span><div><p class="eyebrow" data-i18n="ui.active_now">지금 진행 중</p><h2 data-i18n="ui.work_assigned_to_ai">전체 에이전트 작동 지도</h2></div></div>
@@ -328,12 +329,9 @@
328
329
  <input id="controlRoomSearchInput" type="search" maxlength="240" tabindex="-1" aria-hidden="true" aria-label="실행 세션 검색" data-i18n-aria-label="control.search_sessions" placeholder="세션 검색" data-i18n-placeholder="control.search_sessions" autocomplete="off" />
329
330
  <button id="controlRoomSearchBtn" type="button" aria-label="실행 세션 검색 열기" data-i18n-aria-label="control.open_search" aria-controls="controlRoomSearchInput" aria-expanded="false"><span aria-hidden="true">⌕</span></button>
330
331
  </div>
331
- <div class="control-room-pagination">
332
- <output id="controlRoomPageSummary" class="control-room-page-summary" aria-live="polite">0–0 / 0</output>
333
- <div class="control-room-page-buttons">
334
- <button id="controlRoomPagePrev" type="button" aria-label="이전 실행 세션" data-i18n-aria-label="control.previous_page">‹</button>
335
- <button id="controlRoomPageNext" type="button" aria-label="다음 실행 세션" data-i18n-aria-label="control.next_page">›</button>
336
- </div>
332
+ <div class="control-room-disclosure-actions" role="group" aria-label="프로젝트 그룹 열기와 닫기" data-i18n-aria-label="control.project_group_actions">
333
+ <button id="controlRoomExpandAll" type="button" aria-controls="liveSessionGrid" data-i18n="control.expand_all_projects">모두 열기</button>
334
+ <button id="controlRoomCollapseAll" type="button" aria-controls="liveSessionGrid" data-i18n="control.collapse_all_projects">모두 닫기</button>
337
335
  </div>
338
336
  </div>
339
337
  <div id="agentMapToolbar" class="agent-map-toolbar">
@@ -168,6 +168,30 @@ button[aria-busy="true"] {
168
168
  color: #81ddf8;
169
169
  }
170
170
 
171
+ .workspace-live-state {
172
+ display: inline-flex;
173
+ flex: 0 0 auto;
174
+ align-items: center;
175
+ gap: 5px;
176
+ color: #64dca9;
177
+ font-size: 10px;
178
+ }
179
+
180
+ .workspace-live-state i {
181
+ width: 7px;
182
+ height: 7px;
183
+ border-radius: 50%;
184
+ background: #4ce39a;
185
+ box-shadow: 0 0 8px rgba(76,227,154,.6);
186
+ animation: control-room-pulse 1.5s ease-in-out infinite;
187
+ }
188
+
189
+ .workspace-live-state b {
190
+ display: none;
191
+ white-space: nowrap;
192
+ font-size: 10px;
193
+ }
194
+
171
195
  .workspace-item.projectless.selected small {
172
196
  background: rgba(79,209,167,.15);
173
197
  color: #6ee0b8;
@@ -464,6 +488,59 @@ button[aria-busy="true"] {
464
488
  color: var(--drawer-provider);
465
489
  }
466
490
 
491
+ .chat-row.is-optimistic .chat-bubble {
492
+ padding: 9px 11px;
493
+ border: 1px solid rgba(89,223,167,.2);
494
+ border-radius: 12px;
495
+ background: linear-gradient(135deg,rgba(35,104,78,.12),rgba(10,15,22,.72));
496
+ }
497
+
498
+ .chat-row.is-optimistic.is-new {
499
+ animation: chat-message-send 420ms cubic-bezier(.22,1,.36,1) both;
500
+ }
501
+
502
+ .chat-row.is-failed .chat-bubble {
503
+ border-color: rgba(255,107,127,.36);
504
+ background: rgba(94,31,44,.12);
505
+ }
506
+
507
+ .chat-delivery-status {
508
+ margin-left: auto;
509
+ padding: 2px 6px;
510
+ border-radius: 999px;
511
+ background: rgba(89,223,167,.08);
512
+ color: #79c9a8 !important;
513
+ font-size: 10px !important;
514
+ font-weight: 750;
515
+ }
516
+
517
+ .chat-delivery-status.sending::before {
518
+ display: inline-block;
519
+ width: 5px;
520
+ height: 5px;
521
+ margin-right: 5px;
522
+ border-radius: 50%;
523
+ background: #59dfa7;
524
+ content: "";
525
+ animation: control-room-pulse 1s ease-in-out infinite;
526
+ }
527
+
528
+ .chat-delivery-status.failed {
529
+ background: rgba(255,107,127,.1);
530
+ color: #ff9baa !important;
531
+ }
532
+
533
+ @keyframes chat-message-send {
534
+ from {
535
+ opacity: 0;
536
+ transform: translateY(14px) scale(.975);
537
+ }
538
+ to {
539
+ opacity: 1;
540
+ transform: translateY(0) scale(1);
541
+ }
542
+ }
543
+
467
544
  .chat-row.tool .chat-avatar {
468
545
  color: #b19ae9;
469
546
  }