zigmini 1.0.0-beta.3 → 1.1.0-beta.1

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.
Files changed (138) hide show
  1. package/README.ko.md +27 -2
  2. package/README.md +27 -2
  3. package/assets/agent/gateway-request-rules.md +1 -1
  4. package/assets/agent/gateway-rules.md +6 -1
  5. package/assets/android/zigmini.apk +0 -0
  6. package/assets/android/zigmini.apk.json +3 -3
  7. package/assets/build.json +1 -1
  8. package/assets/document-reader/document-reader +0 -0
  9. package/assets/document-reader/host-selector +0 -0
  10. package/assets/document-reader/manifest.json +27 -0
  11. package/docs/manual.ko.md +114 -10
  12. package/docs/manual.md +117 -11
  13. package/host/document-reader/DocumentRead.swift +370 -0
  14. package/host/document-reader/HostSelector.swift +192 -0
  15. package/host/document-reader/Protocol.swift +647 -0
  16. package/host/document-reader/main.swift +22 -0
  17. package/host/omo-bridge/agent-aside-tool.ts +119 -0
  18. package/host/omo-bridge/agent-aside.ts +522 -0
  19. package/host/omo-bridge/agent-collaboration-files.ts +282 -0
  20. package/host/omo-bridge/agent-collaboration.ts +385 -0
  21. package/host/omo-bridge/agent-control.ts +400 -28
  22. package/host/omo-bridge/agent-execution.sb +42 -0
  23. package/host/omo-bridge/agent-execution.ts +158 -0
  24. package/host/omo-bridge/agent-tool-adapters.ts +388 -0
  25. package/host/omo-bridge/agent-tool-permissions.ts +96 -0
  26. package/host/omo-bridge/aside-tabs.ts +41 -0
  27. package/host/omo-bridge/attachment-read.ts +100 -0
  28. package/host/omo-bridge/bridge.ts +10 -0
  29. package/host/omo-bridge/managed-call.ts +46 -0
  30. package/host/omo-bridge/task-control.ts +39 -5
  31. package/host/omo-check/patches/attachments-293/README.md +138 -0
  32. package/host/omo-check/patches/attachments-293/document-content.js +234 -0
  33. package/host/omo-check/patches/attachments-293/document-content.types.txt +91 -0
  34. package/host/omo-check/patches/attachments-293/omo-5.1.51-documents.patch +831 -0
  35. package/host/omo-check/patches/attachments-293/omo-5.1.51-gateway-port.manifest.json +471 -0
  36. package/host/omo-check/patches/omo-5.1.51-agent-tool-authority.patch +471 -0
  37. package/host/omo-check/patches/omo-5.1.51-gateway-port.manifest.json +28 -6
  38. package/host/omo-check/patches/omo-5.1.51-memory-receipts.patch +5 -0
  39. package/package.json +5 -3
  40. package/src/agent/api.ts +345 -20
  41. package/src/agent/artifacts.ts +196 -0
  42. package/src/agent/aside-target-routes.ts +369 -0
  43. package/src/agent/aside-targets.ts +215 -0
  44. package/src/agent/collaboration-admission.ts +47 -0
  45. package/src/agent/collaboration-read.ts +117 -0
  46. package/src/agent/collaboration-session.ts +196 -0
  47. package/src/agent/collaboration-types.ts +258 -0
  48. package/src/agent/collaboration.ts +399 -0
  49. package/src/agent/definition.ts +65 -3
  50. package/src/agent/fields.ts +9 -3
  51. package/src/agent/file-core.ts +325 -0
  52. package/src/agent/files.ts +87 -8
  53. package/src/agent/list.ts +3 -3
  54. package/src/agent/managed-call.ts +144 -0
  55. package/src/agent/tool-catalog.ts +184 -0
  56. package/src/agent/tool-delivery.ts +359 -0
  57. package/src/agent/tool-permission-types.ts +81 -0
  58. package/src/agent/tool-permissions.ts +1366 -0
  59. package/src/attachments/api.ts +161 -0
  60. package/src/attachments/document-reader.ts +325 -0
  61. package/src/attachments/host-selector.ts +410 -0
  62. package/src/attachments/managed-binding.ts +83 -0
  63. package/src/attachments/prepare.ts +214 -0
  64. package/src/attachments/selector-port.ts +28 -0
  65. package/src/attachments/service.ts +729 -0
  66. package/src/attachments/store.ts +698 -0
  67. package/src/attachments/types.ts +123 -0
  68. package/src/cli/doctor.ts +30 -10
  69. package/src/decisions.ts +109 -10
  70. package/src/engine/apply-patch.ts +21 -0
  71. package/src/engine/compat.json +2 -1
  72. package/src/engine/install.ts +45 -4
  73. package/src/engine/sandbox-cell.ts +2 -0
  74. package/src/gc.ts +33 -225
  75. package/src/housekeeping/api.ts +163 -0
  76. package/src/housekeeping/audit.ts +61 -0
  77. package/src/housekeeping/collectors/agent-collaboration.ts +33 -0
  78. package/src/housekeeping/collectors/agent-drafts.ts +43 -0
  79. package/src/housekeeping/collectors/agent-file-trash.ts +36 -0
  80. package/src/housekeeping/collectors/agent-trash.ts +28 -0
  81. package/src/housekeeping/collectors/attachment-cleanup.ts +35 -0
  82. package/src/housekeeping/collectors/db-backup.ts +25 -0
  83. package/src/housekeeping/collectors/docker-build-cache.ts +29 -0
  84. package/src/housekeeping/collectors/docker-image.ts +135 -0
  85. package/src/housekeeping/collectors/omo-version.ts +32 -0
  86. package/src/housekeeping/collectors/orphan-test-process.ts +205 -0
  87. package/src/housekeeping/collectors/reconcile-work.ts +55 -0
  88. package/src/housekeeping/collectors/rpc-daemon.ts +39 -0
  89. package/src/housekeeping/collectors/shared.ts +149 -0
  90. package/src/housekeeping/collectors/temp-dir.ts +28 -0
  91. package/src/housekeeping/collectors/update-file.ts +25 -0
  92. package/src/housekeeping/collectors/worktree.ts +193 -0
  93. package/src/housekeeping/engine.ts +125 -0
  94. package/src/housekeeping/reconcile/work.ts +130 -0
  95. package/src/housekeeping/registry.ts +63 -0
  96. package/src/housekeeping/trash.ts +59 -0
  97. package/src/housekeeping/types.ts +31 -0
  98. package/src/hub.ts +1095 -41
  99. package/src/main.ts +228 -30
  100. package/src/message-references.ts +14 -5
  101. package/src/profile-prompt.ts +13 -1
  102. package/src/protocol.ts +41 -6
  103. package/src/security-allow.ts +79 -2
  104. package/src/security-check.ts +1 -1
  105. package/src/store.ts +1946 -23
  106. package/src/test-crash.ts +20 -0
  107. package/src/work-watch.ts +55 -7
  108. package/src/work.ts +192 -3
  109. package/web/DESIGN.md +33 -0
  110. package/web/agents.d.ts +189 -12
  111. package/web/agents.js +898 -39
  112. package/web/app.js +2634 -210
  113. package/web/attachments.d.ts +91 -0
  114. package/web/attachments.js +422 -0
  115. package/web/collaboration.d.ts +87 -0
  116. package/web/collaboration.js +290 -0
  117. package/web/decisions.d.ts +39 -2
  118. package/web/decisions.js +354 -2
  119. package/web/gc.d.ts +26 -5
  120. package/web/gc.js +113 -13
  121. package/web/i18n/agents.d.ts +4 -0
  122. package/web/i18n/agents.js +200 -8
  123. package/web/i18n/attachments.js +78 -0
  124. package/web/i18n/decisions.d.ts +4 -0
  125. package/web/i18n/decisions.js +68 -0
  126. package/web/i18n/gc.d.ts +4 -0
  127. package/web/i18n/gc.js +184 -2
  128. package/web/i18n/main.js +80 -8
  129. package/web/i18n/tour.d.ts +4 -0
  130. package/web/i18n/tour.js +37 -0
  131. package/web/index.html +20 -3
  132. package/web/profile.d.ts +2 -2
  133. package/web/profile.js +11 -6
  134. package/web/style.css +342 -21
  135. package/web/tooltip.d.ts +2 -0
  136. package/web/tooltip.js +12 -0
  137. package/web/tour.d.ts +57 -0
  138. package/web/tour.js +450 -0
package/web/app.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { ClapDetector } from "./clap.js";
2
+ import { hostFileRequest, mountHostFiles, replaceAttachmentQuery, sentDocumentBlock, validDocument, visibleOwnerText } from "./attachments.js";
2
3
  import { renderDiagramMarkdown } from "./diagram.js";
3
4
  import { setupMemo } from "./memo.js";
4
5
  import { setupAutoCopy } from "./autocopy.js";
@@ -12,6 +13,9 @@ import {
12
13
  isReviewRequired, isSubmitting, isUnresolved, noteConflict, originalActionId, originalChoices, presentation, pruneDetails, questionTitle, reference,
13
14
  sortedDecisions, speechTextForTurn, dismissMode, DISMISS_TEXT,
14
15
  STATUS_LABELS, submitKey, bindingLabel, waitReason, sourceContext,
16
+ AGENT_TOOL_CLIENT_HEADER, AGENT_TOOL_CLIENT_PATH, candidatesPath, clientExpired, composeToolGrantResponse, designateRequest, featureHeaders, interpretClientIssue,
17
+ interpretDesignate, isToolGrant, operationLabel, ownerAnswerRequired, ownerHeaders, scopeText, targetCandidates, toolErrorText, toolGrantOutcomeText, toolGrantView, toolName,
18
+ artifactReviewOutcome, artifactReviewRequest, artifactReviewView, isArtifactReview,
15
19
  } from "./decisions.js";
16
20
  import { chatTurnEndLine, describeEvent } from "./devlog.js";
17
21
  import { applyActivityEvent, applyActivitySnapshot, beginGeneration, createActivity, endGeneration, isBusy, isUnknown } from "./activity.js";
@@ -45,16 +49,22 @@ import { createEnrollment, NO_DEVICES_LINE, REVOKE_ARMED_LABEL, REVOKE_LABEL } f
45
49
  import { canUpdate, checkFailedLine, noticeLine, parseNotice, progressLine, questionGate, resultLine, UPDATE_CHECK_PATH, UPDATE_CONSENT_PATH, UPDATE_PATH, UPDATE_STATE_PATH, UPDATE_STATUS_PATH, updateStep, updateView } from "./update.js";
46
50
  import { createAppDownload } from "./app-download.js";
47
51
  import { setupNotificationSettings } from "./notifications.js";
48
- import { cleanRequest, cleanResultLine, formatBytes, GC_CLEAN_PATH, GC_PATH, gcView, selectAllState, selectAllTarget } from "./gc.js";
52
+ import { AUDIT_LINES, auditView, cleanRequest, cleanResultLine, formatBytes, gcView, HOUSEKEEPING_AUDIT_PATH, HOUSEKEEPING_CLEAN_PATH, HOUSEKEEPING_PATH, selectAllState, selectAllTarget } from "./gc.js";
49
53
  import {
50
- applyAgentApplied, applyAgentUpdate, clearField, cloneRequest, DEFAULTS_PATH, DELETE_HINT, deleteEnabled, deleteRequest, editorRows, editorState,
51
- EMPTY_FILES_LINE, EMPTY_AGENTS_LINE, EMPTY_TRASH_LINE, errorLine, fileRows, filesPath, headerView, mainConversations, shownConversation, AGENTS_PATH,
52
- restoreRequest, saveRequest, setBody, setField, setUnknownRaw, sidebarItems, sourceUploadPath, STALE_TITLE, TRASH_PATH, trashRows, validateEditor, widgetValue,
54
+ advanceUpload, applyAgentApplied, freshListAnswer, reconcileAgents, applyAgentUpdate, APPLY_WAIT_MS, applyLine, applyNowShown, applyNowTarget, applyWatch, capabilities, clearField, cloneRequest, composeSource, createDraftSaver, createPreviewRequester, DEFAULTS_PATH, DRAFT_REOPEN_WAIT_MS, DELETE_HINT, deletableFile, deleteEnabled, deleteRequest, discardDraftRequest, downloadable, downloadPath, draftLoadRequest,
55
+ draftFileList, draftRequest, draftRows, DRAFTS_PATH, editorRows, editorState, editorUpdateAction, fileDeleteRequest, fileRestoreRequest, fileTrashLabel, fileTrashPath, fileTrashRows, modifiedLabel, nextUpload, queuedUploadRows, retryUpload, turnEndRefreshes, UPLOAD_MAX_BYTES, uploadRows, uploadsAfterCreate, uploadStateLine,
56
+ EMPTY_AGENTS_LINE, EMPTY_TRASH_LINE, errorLine, fileAreas, fileRows, filesPath, headerView, lastDailyLine, mainConversations, newDraftId, PREVIEW_DELAY_MS, previewRequest, previewState, shownConversation, AGENTS_PATH,
57
+ restoreOutcome, restoreRequest, roleGuideItems, rotateRequest, saveErrorLine, saveOutcome, saveRequest, setBody, setField, setUnknownRaw, sidebarItems, skillOptions, sourceUploadPath, speakerView, STALE_TITLE, staleNotice, TRASH_PATH, trashRows,
58
+ unchangedSource, uploadedLine, validateEditor, widgetValue,
59
+ accessSummary, applyRevoke, classifyEdit, editLine, editRequest, interpretPermissionResponse, parseScopeText, policyEventStale, revokeAllRequest, revokeLine, revokeRequest,
60
+ scopeDraft, toolGrantRows, toolPermissionErrorLine, toolPermissionsRequest,
53
61
  } from "./agents.js";
62
+ import { applyCollaborationEvent, artifactPath, cancelOutcome, cancelRequest, cardView, downloadOutcome, emptyCards, fileDeleteText, fileOriginLabel, isCollaborationFallback, listingOutcome, listingRequest, mergeRequestListing, targetOptions } from "./collaboration.js";
54
63
  import { manualFailureText, manualLinkLang, manualUrl, otherLang } from "./manual.js";
55
64
  import { canRestoreHeld, completeDetail, createAllowedValues, createSecurityBaseline, createSecurityInbox, repoAllowed, repoScopeBlock } from "./security-inbox.js";
56
65
  import { getLanguage, isUiLanguage, onLanguageChange, registerStrings, setLanguage, t, UI_LANGUAGES } from "./i18n.js";
57
66
  import mainStrings from "./i18n/main.js";
67
+ import { autoStartWanted, installTour } from "./tour.js";
58
68
 
59
69
  installTooltips(document, window);
60
70
  registerStrings(mainStrings);
@@ -140,6 +150,11 @@ const state = {
140
150
  sessionTransitions: new Set(),
141
151
  ws: null,
142
152
  };
153
+ // The agents the gateway lists (GET /api/agents, agent.updated); the sidebar group, header, profile and bubble speaker read it.
154
+ // `writes` stamps every write to the list (writeAgents); a list answer lands only at the stamp it was sent at (T21W-B2).
155
+ const agentState = { list: [], loaded: false, writes: 0 };
156
+ // Listeners of "지금 적용" requests that wait for their rotation and the new session's agent.applied (agent-ux todo 21).
157
+ const applyWaits = new Set();
143
158
  // Work activity the gateway reports per conversation (snapshot + activity.updated). It drives the avatar only;
144
159
  // state.running stays the sole input of #interrupt, and history pages never write here.
145
160
  const activity = createActivity();
@@ -182,9 +197,20 @@ function bubbleIconUrl() {
182
197
  return agentIconUrl(agentIcon);
183
198
  }
184
199
 
185
- /** The reference labels: the agent's references carry the agent's current name. */
200
+ /** Who speaks in the open conversation (bug 284): the agent's name and text icon, or main's name and icon picture. */
201
+ function speaker() {
202
+ return speakerView(currentConversation(), agentState.list, { name: agentName, iconUrl: bubbleIconUrl() });
203
+ }
204
+
205
+ /** A bot row of the open conversation, named and pictured after its speaker. */
206
+ function botRow(extra) {
207
+ const view = speaker();
208
+ return messageRow({ who: view.name, icon: view.icon, bot: true, ...extra });
209
+ }
210
+
211
+ /** The reference labels: the speaker's references carry the speaker's current name. */
186
212
  function roleLabels() {
187
- return { ...ROLE_LABELS, assistant: agentName };
213
+ return { ...ROLE_LABELS, assistant: speaker().name };
188
214
  }
189
215
 
190
216
  function receiveProfile(data) {
@@ -229,11 +255,13 @@ function applyNickname() {
229
255
 
230
256
  function applyAgentIcon() {
231
257
  const url = agentIconUrl(agentIcon);
232
- for (const img of document.querySelectorAll("#agent-profile .agent-avatar img, #head-avatar, #status-back img")) {
258
+ for (const img of document.querySelectorAll("#head-avatar, #status-back img")) {
233
259
  img.src = url;
234
260
  img.classList.toggle("custom-icon", Boolean(agentIcon));
235
261
  }
236
- iconAgentRows(document.querySelectorAll(".msg:not(.mine)"), bubbleIconUrl());
262
+ // The rows drawn belong to the open conversation: main's follow main's icon, an agent's keep its own text icon (bug 284).
263
+ if (!speaker().agent) iconAgentRows(document.querySelectorAll(".msg:not(.mine)"), bubbleIconUrl());
264
+ renderAgentProfile();
237
265
  hubEffects?.setIcon(url);
238
266
  }
239
267
 
@@ -249,16 +277,38 @@ async function changeAgentIcon(method, body) {
249
277
  }
250
278
 
251
279
  function applyAgentName() {
252
- const nameEl = document.querySelector("#agent-profile .agent-name");
253
- if (!nameEl.querySelector("input")) nameEl.textContent = agentName;
254
- nameEl.title = agentName;
255
- // The agent's bubbles and reference labels already drawn follow the name, like the user's rows in applyNickname.
256
- nameAgentRows(document.querySelectorAll(".msg:not(.mine)"), agentName);
257
- for (const label of document.querySelectorAll('[data-role="assistant"]')) label.textContent = agentName;
280
+ // Main's bubbles and reference labels already drawn follow the name, like the user's rows in applyNickname; an
281
+ // agent conversation's rows keep the agent's own name (bug 284).
282
+ if (!speaker().agent) {
283
+ nameAgentRows(document.querySelectorAll(".msg:not(.mine)"), agentName);
284
+ for (const label of document.querySelectorAll('[data-role="assistant"]')) label.textContent = agentName;
285
+ }
286
+ renderAgentProfile();
258
287
  statusParts.agentName = agentName;
259
288
  renderWorkStatusAll();
260
289
  }
261
290
 
291
+ /**
292
+ * The sidebar profile (bug 284): main's name, icon picture and "Default agent" in main; the agent's name, text icon
293
+ * and "Agent" in an agent conversation, where a double-click opens that agent's settings instead of editing main.
294
+ */
295
+ function renderAgentProfile() {
296
+ const view = speaker();
297
+ const profile = document.querySelector("#agent-profile");
298
+ const nameEl = profile.querySelector(".agent-name");
299
+ if (!nameEl.querySelector("input")) nameEl.textContent = view.name;
300
+ nameEl.title = view.name;
301
+ const avatar = profile.querySelector(".agent-avatar");
302
+ renderAgentAvatar(avatar, view.iconUrl, view.icon);
303
+ avatar.querySelector("img")?.classList.toggle("custom-icon", Boolean(agentIcon));
304
+ const sub = profile.querySelector(".me-sub");
305
+ sub.dataset.i18n = view.agent ? "profile.agentConversation" : "profile.agent";
306
+ sub.textContent = t(sub.dataset.i18n);
307
+ profile.dataset.i18nAttr = `title:${view.agent ? "profile.editAgent.tip" : "profile.editAgentName.tip"}`;
308
+ avatar.dataset.i18nAttr = `title:${view.agent ? "profile.editAgent.tip" : "profile.editAgentIcon.tip"}`;
309
+ for (const el of [profile, avatar]) el.title = t(el.dataset.i18nAttr.slice("title:".length));
310
+ }
311
+
262
312
  document.querySelector("#owner-profile").addEventListener("dblclick", () => {
263
313
  editNickname(document.querySelector("#owner-profile .me-name"), nickname, (value) => {
264
314
  nickname = writeNickname(localStorage, value);
@@ -270,6 +320,11 @@ document.querySelector("#owner-profile").addEventListener("dblclick", () => {
270
320
 
271
321
  document.querySelector("#agent-profile .agent-avatar").addEventListener("dblclick", (event) => {
272
322
  event.stopPropagation();
323
+ const view = speaker();
324
+ if (view.agent) {
325
+ openAgents("editor", view.agentId);
326
+ return;
327
+ }
273
328
  editAgentIcon(document.querySelector(".sidebar-foot"), agentIconUrl(agentIcon), {
274
329
  onSave: (png) => changeAgentIcon("PUT", png),
275
330
  onReset: () => changeAgentIcon("DELETE"),
@@ -277,6 +332,11 @@ document.querySelector("#agent-profile .agent-avatar").addEventListener("dblclic
277
332
  });
278
333
 
279
334
  document.querySelector("#agent-profile").addEventListener("dblclick", () => {
335
+ const view = speaker();
336
+ if (view.agent) {
337
+ openAgents("editor", view.agentId);
338
+ return;
339
+ }
280
340
  editNickname(document.querySelector("#agent-profile .agent-name"), agentName, (value) => {
281
341
  agentName = writeAgentName(localStorage, value);
282
342
  queueMicrotask(applyAgentName);
@@ -438,6 +498,7 @@ function renderSessionTransition() {
438
498
  for (const id of ["input", "send", "attach", "mic", "rotate-session"]) {
439
499
  $(id).disabled = active;
440
500
  }
501
+ documents.render();
441
502
  }
442
503
 
443
504
  const els = {
@@ -576,12 +637,13 @@ nav.next.ondblclick = () => {
576
637
  els.messages.scrollTo({ top: els.messages.scrollHeight, behavior: "smooth" });
577
638
  };
578
639
 
579
- function messageRow({ who, bot, ts, tag }) {
640
+ /** `icon`: a custom agent's text icon for its bot rows (bug 284); main's rows take the icon picture. */
641
+ function messageRow({ who, bot, ts, tag, icon = null }) {
580
642
  const row = document.createElement("div");
581
643
  row.className = bot ? "msg" : "msg mine";
582
644
  const avatar = document.createElement("div");
583
645
  avatar.className = `avatar ${bot ? "bot-avatar" : "me-avatar"}`;
584
- if (bot) renderAgentAvatar(avatar, bubbleIconUrl());
646
+ if (bot) renderAgentAvatar(avatar, bubbleIconUrl(), icon);
585
647
  else avatar.textContent = nicknameInitial(nickname);
586
648
  const body = document.createElement("div");
587
649
  body.className = "msg-body";
@@ -654,7 +716,7 @@ function ensureLiveItem(itemId, ts, fromThinking = false) {
654
716
  liveOut = true;
655
717
  let body;
656
718
  try {
657
- body = messageRow({ who: agentName, bot: true, ts });
719
+ body = botRow({ ts });
658
720
  } finally {
659
721
  liveOut = false;
660
722
  }
@@ -861,19 +923,21 @@ function renderCardBody(card, decision) {
861
923
  card.dataset.decisionRevision = String(decision.revision);
862
924
  card.classList.toggle("resolved", p.terminal || !isUnresolved(decision));
863
925
  card.setAttribute("aria-busy", String(busy));
864
- const nodes = [make("div", "approval-title", payload.title || request?.title || t(decision.permission ? "approval.permissionRequest" : "approval.question"))];
865
- if (payload.detail) nodes.push(make("div", "approval-detail", payload.detail));
926
+ const tool = isToolGrant(decision);
927
+ const nodes = [make("div", "approval-title", tool ? toolGrantView(decision).title : payload.title || request?.title || t(decision.permission ? "approval.permissionRequest" : "approval.question"))];
928
+ if (!tool && payload.detail) nodes.push(make("div", "approval-detail", payload.detail));
866
929
  // A question cancelled by deleting it from the inbox stores { cancelled: true }, which is not an answer the user gave.
867
- if (decision.answer && decision.reason !== "user_cancelled") nodes.push(make("div", "inbox-note", t("decision.myAnswer", { answer: describeAnswer(decision.answer) })));
868
- if (p.form) nodes.push(...renderAnswerFields(decision, form, locked, () => syncDecisionSurfaces(decisionById(inbox.state, decision.id) ?? decision, card)));
930
+ if (decision.answer && !tool && decision.reason !== "user_cancelled") nodes.push(make("div", "inbox-note", t("decision.myAnswer", { answer: describeAnswer(decision.answer) })));
931
+ if (tool) nodes.push(...renderToolGrantFields(decision, p, locked));
932
+ else if (p.form) nodes.push(...renderAnswerFields(decision, form, locked, () => syncDecisionSurfaces(decisionById(inbox.state, decision.id) ?? decision, card)));
869
933
  else {
870
934
  const saved = renderSavedChoices(decision);
871
935
  if (saved) nodes.push(saved);
872
936
  }
873
937
  const lost = renderReference(decision);
874
938
  if (lost) nodes.push(lost);
875
- const kind = decision.permission ? t("approval.permission") : decision.method === "question" ? t("approval.question") : decision.method;
876
- const binding = p.kind === "live" ? "" : ` · ${bindingLabel(decision)}`;
939
+ const kind = tool ? toolGrantView(decision).title : decision.permission ? t("approval.permission") : decision.method === "question" ? t("approval.question") : decision.method;
940
+ const binding = p.kind === "live" || tool ? "" : ` · ${bindingLabel(decision)}`;
877
941
  nodes.push(make("div", "approval-meta", `${deadlineLabel(decision.deadlineAt)} · ${kind}${binding}`));
878
942
  const status = cardStatus(decision, p, busy, review);
879
943
  if (status) {
@@ -900,7 +964,7 @@ function renderApproval(req, resolved, ts) {
900
964
  const explicitId = typeof req.decisionId === "string" ? req.decisionId : null;
901
965
  if (explicitId) card.dataset.decisionId = explicitId;
902
966
  cardRequests.set(card, req);
903
- const body = messageRow({ who: agentName, bot: true, ts: req.createdAt ? new Date(req.createdAt).toISOString() : ts, tag: req.permission ? "approval.permissionRequest" : "approval.question" });
967
+ const body = botRow({ ts: req.createdAt ? new Date(req.createdAt).toISOString() : ts, tag: req.permission ? "approval.permissionRequest" : "approval.question" });
904
968
  body.appendChild(card);
905
969
  const decision = decisionForCard(inbox.state, req);
906
970
  if (decision) {
@@ -1031,6 +1095,249 @@ function markResolved(card, res) {
1031
1095
  card.appendChild(note);
1032
1096
  }
1033
1097
 
1098
+ // ---- 에이전트 협업 카드 (agent-collaboration-292 task 11, C-10). web/collaboration.js decides; this block draws one card per request
1099
+ // in the open conversation's transcript (at the event that first named it), keeps it current from later events and the
1100
+ // owner listing, and sends the owner actions (cancel, recheck, download) whose refusals are shown as they are.
1101
+ const collab = { conv: null, cards: emptyCards(), lines: new Map(), listing: 0 };
1102
+
1103
+ function resetCollaboration(conv) {
1104
+ collab.conv = conv;
1105
+ collab.cards = emptyCards();
1106
+ collab.lines = new Map();
1107
+ collab.listing += 1;
1108
+ }
1109
+
1110
+ function collaborationCardEl(requestId) {
1111
+ const selector = `.collaboration-card[data-request-id="${CSS.escape(requestId)}"]`;
1112
+ return sink?.querySelector(selector) ?? els.messages.querySelector(selector);
1113
+ }
1114
+
1115
+ function collaborationContext() {
1116
+ return { agents: agentState.list, agentId: currentConversation()?.agentId ?? null, mainName: agentName };
1117
+ }
1118
+
1119
+ function renderCollaborationEvent(e, fromHistory) {
1120
+ const outcome = applyCollaborationEvent(collab.cards, e);
1121
+ collab.cards = outcome.cards;
1122
+ if (outcome.notice) {
1123
+ systemLine(outcome.notice, e.type === "agent.collaboration.blocked");
1124
+ return;
1125
+ }
1126
+ if (!outcome.requestId || !outcome.change) return;
1127
+ const card = collab.cards.get(outcome.requestId);
1128
+ if (!card || card.seq === null) return;
1129
+ const existing = collaborationCardEl(outcome.requestId);
1130
+ if (existing) fillCollaborationCard(existing, card);
1131
+ else if (outcome.change === "created") out(buildCollaborationCard(card));
1132
+ if (!fromHistory && !existing && card.state === null && collab.conv === state.current) void loadCollaborationListing(collab.conv);
1133
+ }
1134
+
1135
+ function buildCollaborationCard(card) {
1136
+ const el = make("div", "collaboration-card");
1137
+ el.dataset.requestId = card.requestId;
1138
+ el.setAttribute("role", "group");
1139
+ fillCollaborationCard(el, card);
1140
+ return el;
1141
+ }
1142
+
1143
+ function collaborationFileList(requestId, files, withSize) {
1144
+ const list = make("ul", "collab-files");
1145
+ for (const file of files) {
1146
+ const li = make("li", "collab-file");
1147
+ li.dataset.artifactId = file.artifactId;
1148
+ li.dataset.name = file.name;
1149
+ if (file.sha256) li.dataset.sha256 = file.sha256;
1150
+ const name = make("span", "collab-file-name", file.name);
1151
+ name.title = file.artifactId;
1152
+ li.append(name);
1153
+ if (withSize && file.size) li.append(make("span", "collab-file-size", file.size));
1154
+ const download = peButton(t("collab.download"), null, null, "text-btn");
1155
+ download.dataset.action = "collaboration-download";
1156
+ download.setAttribute("aria-label", t("collab.download.label", { name: file.name }));
1157
+ li.append(download);
1158
+ list.append(li);
1159
+ }
1160
+ return list;
1161
+ }
1162
+
1163
+ function fillCollaborationCard(el, card) {
1164
+ const view = cardView(card, collaborationContext());
1165
+ el.dataset.state = view.state ?? "unconfirmed";
1166
+ el.classList.toggle("outgoing", view.outgoing);
1167
+ el.setAttribute("aria-label", `${view.title} · ${view.stateLabel}`);
1168
+ const head = make("div", "collab-head");
1169
+ head.append(make("span", "collab-title", view.title));
1170
+ if (view.kindLabel) head.append(make("span", "collab-kind", view.kindLabel));
1171
+ const stateEl = make("span", `collab-state ${view.tone}`, view.stateLabel);
1172
+ stateEl.dataset.state = view.state ?? "unconfirmed";
1173
+ head.append(stateEl);
1174
+ if (view.ts) head.append(make("span", "collab-time", timeLabel(view.ts)));
1175
+ const parties = make("div", "collab-parties");
1176
+ const from = make("span", "collab-party", `${t("collab.from")} `);
1177
+ from.append(make("b", "", view.from));
1178
+ const to = make("span", "collab-party", `${t("collab.to")} `);
1179
+ to.append(make("b", "", view.to));
1180
+ parties.append(from, make("span", "collab-arrow", "→"), to);
1181
+ const children = [head, parties];
1182
+ if (view.text) children.push(make("div", "collab-text", view.text));
1183
+ if (view.inputs.length) {
1184
+ const section = make("div", "collab-section");
1185
+ section.append(make("div", "collab-section-label", t("collab.inputs")), collaborationFileList(card.requestId, view.inputs, false));
1186
+ children.push(section);
1187
+ }
1188
+ if (view.result) {
1189
+ const section = make("div", "collab-section collab-result");
1190
+ section.append(make("div", "collab-section-label", t("collab.result")));
1191
+ section.append(view.result.text ? make("div", "collab-text", view.result.text) : make("div", "collab-note", t("collab.result.none")));
1192
+ if (view.result.files.length) section.append(make("div", "collab-section-label", t("collab.files")), collaborationFileList(card.requestId, view.result.files, true));
1193
+ children.push(section);
1194
+ }
1195
+ if (view.replied) children.push(make("div", "collab-note", t("collab.replied")));
1196
+ for (const note of view.notes) children.push(make("div", `collab-note${note.error ? " error" : ""}`, note.text));
1197
+ const line = make("div", "collab-line");
1198
+ line.setAttribute("role", "status");
1199
+ const last = collab.lines.get(card.requestId);
1200
+ if (last) {
1201
+ line.textContent = last.text;
1202
+ line.classList.toggle("error", last.error);
1203
+ }
1204
+ children.push(line);
1205
+ const actions = make("div", "collab-actions");
1206
+ if (view.cancellable) {
1207
+ const cancel = peButton(t("collab.cancel"), null, null, "text-btn danger");
1208
+ cancel.dataset.action = "collaboration-cancel";
1209
+ cancel.setAttribute("aria-label", t("collab.cancel.label", { id: card.requestId.slice(0, 8) }));
1210
+ cancel.disabled = cancelRequest(card) === null;
1211
+ actions.append(cancel);
1212
+ }
1213
+ const recheck = peButton(t("collab.recheck"), null, null, "text-btn");
1214
+ recheck.dataset.action = "collaboration-recheck";
1215
+ actions.append(recheck);
1216
+ children.push(actions);
1217
+ el.replaceChildren(...children);
1218
+ }
1219
+
1220
+ function refreshCollaborationCards() {
1221
+ for (const [requestId, card] of collab.cards) {
1222
+ const el = collaborationCardEl(requestId);
1223
+ if (el) fillCollaborationCard(el, card);
1224
+ }
1225
+ }
1226
+
1227
+ function setCollaborationLine(requestId, text, error) {
1228
+ collab.lines.set(requestId, { text, error });
1229
+ const el = collaborationCardEl(requestId);
1230
+ const card = collab.cards.get(requestId);
1231
+ if (el && card) fillCollaborationCard(el, card);
1232
+ }
1233
+
1234
+ // Owner actions (cancel, download) carry the 294 caller helper proof: the HttpOnly client cookie plus a page nonce
1235
+ // issued by POST /api/agent-tool-client. A stale nonce is renewed once.
1236
+ let collaborationNonce = null;
1237
+ async function collaborationOwnerHeaders(fresh) {
1238
+ if (fresh || !collaborationNonce) {
1239
+ collaborationNonce = null;
1240
+ try {
1241
+ const issued = await fetch("/api/agent-tool-client", { method: "POST", headers: { "x-zigmini-agent-tool-client": "1" } });
1242
+ const body = await issued.json().catch(() => null);
1243
+ if (issued.ok && typeof body?.pageNonce === "string") collaborationNonce = body.pageNonce;
1244
+ } catch {}
1245
+ }
1246
+ return collaborationNonce ? { "x-zigmini-agent-tool-client": "1", "x-zigmini-page-nonce": collaborationNonce } : { "x-zigmini-agent-tool-client": "1" };
1247
+ }
1248
+
1249
+ async function collaborationFetch(request, retried = false) {
1250
+ const owner = request.method === "POST";
1251
+ let res;
1252
+ try {
1253
+ const headers = owner ? { ...(request.headers ?? {}), ...(await collaborationOwnerHeaders(retried)) } : request.headers;
1254
+ res = await fetch(request.path, { method: request.method, headers, body: request.body });
1255
+ } catch {
1256
+ return { status: 0, body: null, res: null };
1257
+ }
1258
+ const body = await res.clone().json().catch(() => null);
1259
+ if (owner && !retried && res.status === 403 && (body?.error === "page_nonce_invalid" || body?.error === "agent_tool_client_required")) return collaborationFetch(request, true);
1260
+ return { status: res.status, body, res };
1261
+ }
1262
+
1263
+ async function loadCollaborationListing(conv, requestId = null) {
1264
+ if (!conv) return;
1265
+ const stamp = ++collab.listing;
1266
+ const { status, body } = await collaborationFetch(listingRequest(conv));
1267
+ if (collab.conv !== conv || collab.listing !== stamp) return;
1268
+ const outcome = listingOutcome(status, body);
1269
+ if (outcome.ok) {
1270
+ const merged = mergeRequestListing(collab.cards, body);
1271
+ collab.cards = merged.cards;
1272
+ for (const id of merged.changed) {
1273
+ const el = collaborationCardEl(id);
1274
+ const card = collab.cards.get(id);
1275
+ if (el && card) fillCollaborationCard(el, card);
1276
+ }
1277
+ } else console.warn("collaboration listing:", status, body);
1278
+ if (requestId) setCollaborationLine(requestId, outcome.line, !outcome.ok);
1279
+ }
1280
+
1281
+ async function cancelCollaboration(requestId, button) {
1282
+ const request = cancelRequest(collab.cards.get(requestId));
1283
+ if (!request) return;
1284
+ button.disabled = true;
1285
+ const conv = collab.conv;
1286
+ const { status, body } = await collaborationFetch(request);
1287
+ if (collab.conv !== conv) return;
1288
+ const outcome = cancelOutcome(status, body);
1289
+ const prior = collab.cards.get(requestId);
1290
+ if (outcome.ok && outcome.state && prior) collab.cards = new Map(collab.cards).set(requestId, { ...prior, state: outcome.state, revision: outcome.revision ?? prior.revision });
1291
+ setCollaborationLine(requestId, outcome.line, !outcome.ok);
1292
+ }
1293
+
1294
+ function saveBlob(blob, name) {
1295
+ const url = URL.createObjectURL(blob);
1296
+ const link = document.createElement("a");
1297
+ link.href = url;
1298
+ link.download = name;
1299
+ document.body.append(link);
1300
+ link.click();
1301
+ link.remove();
1302
+ URL.revokeObjectURL(url);
1303
+ }
1304
+
1305
+ async function sha256Hex(bytes) {
1306
+ if (!globalThis.crypto?.subtle) return null;
1307
+ const hash = await crypto.subtle.digest("SHA-256", bytes);
1308
+ return [...new Uint8Array(hash)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
1309
+ }
1310
+
1311
+ async function downloadCollaborationFile(requestId, item, button) {
1312
+ const artifactId = item?.dataset.artifactId;
1313
+ if (!artifactId) return;
1314
+ const name = item.dataset.name || artifactId;
1315
+ button.disabled = true;
1316
+ const conv = collab.conv;
1317
+ const { status, body, res } = await collaborationFetch({ method: "POST", path: artifactPath(artifactId) });
1318
+ let digests = { expected: item.dataset.sha256 ?? null, header: null, computed: null };
1319
+ let blob = null;
1320
+ if (status === 200 && res) {
1321
+ blob = await res.blob();
1322
+ digests = { ...digests, header: res.headers.get("x-zgw-artifact-sha256"), computed: await sha256Hex(new Uint8Array(await blob.arrayBuffer())) };
1323
+ }
1324
+ const outcome = downloadOutcome(name, status, body, digests);
1325
+ if (outcome.ok && blob) saveBlob(blob, name);
1326
+ if (collab.conv === conv) setCollaborationLine(requestId, outcome.line, !outcome.ok);
1327
+ }
1328
+
1329
+ els.messages.addEventListener("click", (ev) => {
1330
+ const button = ev.target instanceof Element ? ev.target.closest("[data-action^='collaboration-']") : null;
1331
+ if (!(button instanceof HTMLButtonElement) || button.disabled) return;
1332
+ const requestId = button.closest(".collaboration-card")?.dataset.requestId;
1333
+ if (!requestId) return;
1334
+ switch (button.dataset.action) {
1335
+ case "collaboration-cancel": void cancelCollaboration(requestId, button); break;
1336
+ case "collaboration-recheck": void loadCollaborationListing(collab.conv, requestId); break;
1337
+ case "collaboration-download": void downloadCollaborationFile(requestId, button.closest("[data-artifact-id]"), button); break;
1338
+ }
1339
+ });
1340
+
1034
1341
  function renderEvent(e, fromHistory = false) {
1035
1342
  renderSeq = typeof e.seq === "number" ? e.seq : null;
1036
1343
  switch (e.type) {
@@ -1041,8 +1348,13 @@ function renderEvent(e, fromHistory = false) {
1041
1348
  const quoted = sentReferences(e.data);
1042
1349
  if (quoted.length) body.appendChild(sentReferenceBlock(quoted, roleLabels()));
1043
1350
  if (Array.isArray(e.data.images) && e.data.images.length) body.appendChild(imageStrip(e.data.images));
1044
- textBlock(body, e.data.text ?? "");
1045
- const source = referenceSource(e);
1351
+ if (Array.isArray(e.data.attachments)) body.appendChild(sentDocumentBlock(e.data.attachments));
1352
+ textBlock(body, visibleOwnerText(e.data));
1353
+ const documents = (Array.isArray(e.data.attachments) ? e.data.attachments : []).filter(validDocument);
1354
+ if (documents.length) documentSources.set(`${e.conv}:${e.seq}`, documents);
1355
+ const source = referenceSource(e) ?? (documents.length ? {
1356
+ seq: e.seq, role: "user", text: documents.map(d => d.name).join(", "), images: [],
1357
+ } : null);
1046
1358
  if (source) markReferenceable(body.parentElement, source);
1047
1359
  setLastSent(nextLastSent(lastSent.rec, e));
1048
1360
  rememberSent(e);
@@ -1051,7 +1363,7 @@ function renderEvent(e, fromHistory = false) {
1051
1363
  }
1052
1364
  case "message.precomment": {
1053
1365
  // Main's one-line notice before longer work: its own lighter, smaller message, never merged into the answer.
1054
- const body = messageRow({ who: agentName, bot: true, ts: e.ts, tag: "chat.tag.notice" });
1366
+ const body = botRow({ ts: e.ts, tag: "chat.tag.notice" });
1055
1367
  body.parentElement.classList.add("precomment");
1056
1368
  textBlock(body, e.data.text ?? "");
1057
1369
  break;
@@ -1084,7 +1396,7 @@ function renderEvent(e, fromHistory = false) {
1084
1396
  const row = body.parentElement;
1085
1397
  if (row.parentNode) placeRow(row.parentNode, row, renderSeq ?? hist.newest);
1086
1398
  } else {
1087
- body = messageRow({ who: agentName, bot: true, ts: e.ts });
1399
+ body = botRow({ ts: e.ts });
1088
1400
  textBlock(body, e.data.text);
1089
1401
  }
1090
1402
  const source = referenceSource(e);
@@ -1130,10 +1442,19 @@ function renderEvent(e, fromHistory = false) {
1130
1442
  case "conversation.thread":
1131
1443
  els.thread.textContent = `thread ${e.data.threadId}`;
1132
1444
  break;
1445
+ case "message.collaboration":
1446
+ case "agent.collaboration.status":
1447
+ case "agent.collaboration.blocked":
1448
+ case "agent.collaboration.waiting_owner":
1449
+ case "agent.collaboration.delivery_unknown":
1450
+ renderCollaborationEvent(e, fromHistory);
1451
+ break;
1133
1452
  case "error":
1134
1453
  systemLine(e.data?.kind === "agent-definition" ? t("agent.definitionError") : t("chat.error", { message: e.data.message }), true);
1135
1454
  break;
1136
1455
  case "notice":
1456
+ // The C-4 collaboration fallback repeats a message this page already draws as its card.
1457
+ if (isCollaborationFallback(e)) break;
1137
1458
  systemLine(t("chat.notice", { message: e.data.message }), e.data.level === "error");
1138
1459
  break;
1139
1460
  }
@@ -1207,6 +1528,7 @@ function rememberSent(e) {
1207
1528
 
1208
1529
  function clearMessages(conv) {
1209
1530
  els.messages.innerHTML = "";
1531
+ resetCollaboration(conv);
1210
1532
  turnStats.reset(conv);
1211
1533
  setLastSent(null);
1212
1534
  recall.sent.length = 0;
@@ -1223,19 +1545,25 @@ function clearMessages(conv) {
1223
1545
  function selectConversation(requested, resume = false) {
1224
1546
  // Improvement 262: a hidden conversation (old "+" conversation, a decision or link that names one) opens main.
1225
1547
  const id = shownConversation(state.conversations, requested);
1548
+ const changing = id !== state.current;
1226
1549
  const canResume = resume && id === state.current && hist.conv === id && hist.newest > 0;
1227
1550
  if (id !== state.current) {
1551
+ saveDocumentDraftView();
1228
1552
  clearPendingReportSpeech(state.current);
1229
1553
  clearReferences(refs.state);
1230
1554
  renderReferences();
1231
1555
  resetSlashCommands();
1232
1556
  }
1233
1557
  state.current = id;
1558
+ if (changing) restoreDocumentDraftView();
1559
+ documents.close();
1560
+ documents.render();
1234
1561
  const conv = state.conversations.find((c) => c.id === id);
1235
1562
  els.title.textContent = conv ? conv.title : id;
1236
1563
  els.thread.textContent = conv?.threadId ? `thread ${conv.threadId}` : "";
1237
1564
  renderAgentHeader();
1238
1565
  renderConversations();
1566
+ renderAgentProfile();
1239
1567
  renderInterrupt();
1240
1568
  renderActivity();
1241
1569
  renderSessionTransition();
@@ -1300,6 +1628,7 @@ function applyHistoryPage(page) {
1300
1628
  applyHistory(page.events);
1301
1629
  fillViewport();
1302
1630
  }
1631
+ if (page.mode !== "before" && collab.cards.size > 0) void loadCollaborationListing(state.current);
1303
1632
  if (page.mode !== "before" && !lastSent.rec && hist.hasMore && hist.oldest !== null) seekLastSent(state.current, hist.oldest);
1304
1633
  const referencedIds = new Set();
1305
1634
  for (const card of els.messages.querySelectorAll(".approval")) {
@@ -1358,10 +1687,15 @@ function recountPending(approvals) {
1358
1687
  }
1359
1688
 
1360
1689
  function onServerEvent(e, generation, statusGeneration) {
1690
+ if (e.type === "message.user") finishDocumentSubmission(e, true);
1361
1691
  if (e.type !== "security-check.held") devLog("in", e);
1692
+ // "지금 적용" requests waiting for their rotation see every event first (agent-ux todo 21).
1693
+ for (const wait of applyWaits) wait(e);
1694
+ if (reconcileAgents(e, agentState.list)) void loadAgents();
1362
1695
  if (e.seq && e.conv === state.current && hist.conv === e.conv && hist.seen.has(e.seq)) return;
1363
1696
  switch (e.type) {
1364
1697
  case "state.snapshot":
1698
+ documents.snapshot(e.data);
1365
1699
  state.conversations = e.data.conversations;
1366
1700
  recountPending(e.data.approvals);
1367
1701
  state.sessionTransitions = new Set(e.data.sessionTransitions ?? []);
@@ -1373,10 +1707,14 @@ function onServerEvent(e, generation, statusGeneration) {
1373
1707
  void loadAgents();
1374
1708
  selectConversation(state.current, true);
1375
1709
  return;
1710
+ case "attachment.updated":
1711
+ documents.event(e);
1712
+ return;
1376
1713
  case "decision.updated":
1377
1714
  onDecisionUpdated(e.data);
1378
1715
  return;
1379
1716
  case "error":
1717
+ if (e.data?.submissionId) finishDocumentSubmission(e, false);
1380
1718
  if (typeof e.data?.actionId === "string") onActionError(e.data);
1381
1719
  break;
1382
1720
  case "history":
@@ -1420,10 +1758,11 @@ function onServerEvent(e, generation, statusGeneration) {
1420
1758
  receiveAgentUpdate(e.data);
1421
1759
  return;
1422
1760
  case "agent.applied":
1423
- agentState.list = applyAgentApplied(agentState.list, e.conv, e.data);
1424
- renderAgentHeader();
1425
- renderAgentGroup();
1761
+ receiveAgentApplied(e.conv, e.data);
1426
1762
  break;
1763
+ case "agent.tool_permissions.updated":
1764
+ receiveToolPermissionsEvent(e.data);
1765
+ return;
1427
1766
  case "action.request":
1428
1767
  state.pendingByConv.set(e.conv, (state.pendingByConv.get(e.conv) ?? 0) + 1);
1429
1768
  renderConversations();
@@ -1520,6 +1859,8 @@ function onServerEvent(e, generation, statusGeneration) {
1520
1859
  state.running.delete(e.conv);
1521
1860
  renderInterrupt();
1522
1861
  renderAgentGroup();
1862
+ // An open editor of this conversation's agent reads its files again: the outputs the answer wrote (agent-ux todo 17).
1863
+ if (turnEndRefreshes(agentView.editor, e.conv)) void refreshKnowledgeAfterTurn(agentView.editor.id);
1523
1864
  // A report-only turn names its published message (report_event_seq); user turns keep the last answer.
1524
1865
  const speech = e.conv === state.current ? speechTextForTurn(e.data, tts.lastText.get(e.conv), (seq) => tts.finals.get(`${e.conv}/${seq}`)) : null;
1525
1866
  if (tts.enabled && speech) speakSummary(speech);
@@ -1817,7 +2158,7 @@ function connect() {
1817
2158
  void allowedValues.connect();
1818
2159
  void securityBaseline.connect();
1819
2160
  renderInboxOnline();
1820
- send({ type: "hello", data: { client: "web-dev", version: "0.1.0" } });
2161
+ send({ type: "hello", data: { client: "web-dev", version: "0.1.0", capabilities: { hostFileAttachments: 1 } } });
1821
2162
  sendWebPresence();
1822
2163
  resendPendingReportSpeech();
1823
2164
  void readUiLanguage();
@@ -1839,6 +2180,7 @@ function connect() {
1839
2180
  endGeneration(activity, generation);
1840
2181
  if (endStatusGeneration(workStatus, statusGeneration)) renderWorkStatusAll();
1841
2182
  connState = "waiting";
2183
+ documents.disconnect();
1842
2184
  renderConn();
1843
2185
  $("head-avatar").classList.remove("live");
1844
2186
  renderActivity();
@@ -1889,6 +2231,7 @@ els.messages.addEventListener("click", (ev) => {
1889
2231
 
1890
2232
  function sendText() {
1891
2233
  if (state.sessionTransitions.has(state.current)) return;
2234
+ if (attach.interceptSend?.()) return;
1892
2235
  cancelAutoSend();
1893
2236
  const text = els.input.value.trim();
1894
2237
  const plan = planSend(text, refs.state, parseOptionCommand, attach.items.length);
@@ -1911,6 +2254,7 @@ function sendText() {
1911
2254
  }
1912
2255
  // Not taken by the socket (offline): the draft and the tags stay for a retry.
1913
2256
  if (!send({ type: "input.text", conv: state.current, data: plan.data })) return;
2257
+ attach.onSent?.(state.current);
1914
2258
  clearReferences(refs.state);
1915
2259
  renderReferences();
1916
2260
  els.input.value = "";
@@ -1923,6 +2267,136 @@ const MAX_ATTACH = 4;
1923
2267
  const MAX_SIDE = 2048;
1924
2268
  const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
1925
2269
  const attach = { items: [], busy: false, tray: $("attach-tray"), input: $("attach-input"), button: $("attach") };
2270
+ const documentSources = new Map();
2271
+ const documentDraftViews = new Map();
2272
+ const documents = mountHostFiles({
2273
+ input: els.input, menu: $("host-file-menu"), tray: $("document-tray"), note: $("document-note"), button: $("host-files"),
2274
+ }, {
2275
+ conv: () => state.current,
2276
+ conversations: () => state.conversations.map(c => c.id),
2277
+ request: hostFileRequest,
2278
+ locked: () => attach.busy || state.sessionTransitions.has(state.current),
2279
+ closeSlash: () => closeSlashMenu(),
2280
+ changed: () => {
2281
+ const draft = documents.current();
2282
+ attach.documentLocked = !!draft.submission;
2283
+ attach.documentCount = draft.items.length;
2284
+ els.input.readOnly = !!draft.submission;
2285
+ $("send").disabled = state.sessionTransitions.has(state.current)
2286
+ || (!!draft.items.length && !documents.ready()) || !!draft.submission;
2287
+ $("attach").disabled = !!draft.submission || state.sessionTransitions.has(state.current);
2288
+ },
2289
+ selected: (draft, span) => {
2290
+ if (draft.conv === state.current && documents.current() === draft) {
2291
+ els.input.value = replaceAttachmentQuery(els.input.value, span);
2292
+ els.input.dispatchEvent(new Event("input"));
2293
+ } else {
2294
+ const view = documentDraftViews.get(draft.conv);
2295
+ if (view?.draftId === draft.id) view.text = replaceAttachmentQuery(view.text, span);
2296
+ }
2297
+ },
2298
+ retry: draft => void transmitDocumentSubmission(draft),
2299
+ });
2300
+ attach.interceptSend = () => {
2301
+ if (documents.composing) return true;
2302
+ if (!documents.current().items.length && !documents.current().submission && !referencedDocuments().length) return false;
2303
+ void sendDocumentComposition();
2304
+ return true;
2305
+ };
2306
+ attach.onSent = conv => { documentDraftViews.delete(conv); documents.drafts.reset(conv); };
2307
+ function saveDocumentDraftView() {
2308
+ documentDraftViews.set(state.current, {
2309
+ draftId: documents.current().id, text: els.input.value,
2310
+ references: { ...refs.state, items: [...refs.state.items] }, images: attach.items,
2311
+ });
2312
+ }
2313
+ function restoreDocumentDraftView() {
2314
+ const view = documentDraftViews.get(state.current);
2315
+ els.input.value = view?.text ?? "";
2316
+ refs.state = view?.references ?? createReferences();
2317
+ attach.items = view?.images ?? [];
2318
+ renderReferences();
2319
+ renderTray();
2320
+ }
2321
+ function referencedDocuments() {
2322
+ return refs.state.items.flatMap(r => documentSources.get(`${state.current}:${r.seq}`) ?? []);
2323
+ }
2324
+ async function sendDocumentComposition() {
2325
+ const draft = documents.current();
2326
+ if (draft.submission || attach.busy) return;
2327
+ if (!documents.ready()) { documents.refuseSend("pending"); return; }
2328
+ const ids = draft.items.map(i => i.id);
2329
+ if (new Set([...ids, ...referencedDocuments().map(d => d.id)]).size > 4) {
2330
+ documents.refuseSend("limit"); return;
2331
+ }
2332
+ // Preserve the existing image+reference preflight and upload semantics.
2333
+ const plan = planSend(els.input.value, refs.state, () => null, attach.items.length);
2334
+ if (plan.kind === "blocked") { refs.state.notice = plan.notice; renderReferences(); return; }
2335
+ draft.submission = {
2336
+ id: crypto.randomUUID(), state: "uploading", text: els.input.value,
2337
+ references: referenceSeqs(refs.state), attachments: ids, images: [...attach.items], uploaded: [],
2338
+ };
2339
+ const referenceState = refs.state;
2340
+ referenceState.locked = true;
2341
+ documents.close();
2342
+ documents.render();
2343
+ try {
2344
+ for (const item of draft.submission.images) draft.submission.uploaded.push(await uploadImage(item.file));
2345
+ await transmitDocumentSubmission(draft);
2346
+ } catch (e) {
2347
+ if (!(e instanceof Error)) throw e;
2348
+ draft.notice = e.message;
2349
+ draft.submission = null;
2350
+ } finally {
2351
+ if (!draft.submission) referenceState.locked = false;
2352
+ documents.render();
2353
+ renderTray();
2354
+ renderReferences();
2355
+ els.input.readOnly = !!documents.current().submission;
2356
+ }
2357
+ }
2358
+ async function transmitDocumentSubmission(draft) {
2359
+ const s = draft.submission;
2360
+ if (!s || (s.state !== "uploading" && s.state !== "uncertain")) return;
2361
+ if (!documents.ready(draft)) return;
2362
+ s.state = "awaiting";
2363
+ const data = inputData(s.text, { images: s.uploaded, references: s.references });
2364
+ if (s.attachments.length) data.attachments = [...s.attachments];
2365
+ data.submissionId = s.id;
2366
+ if (!send({ type: "input.text", conv: draft.conv, data })) s.state = "uncertain";
2367
+ draft.notice = t(s.state === "uncertain" ? "documents.uncertain" : "documents.sending");
2368
+ documents.render();
2369
+ }
2370
+ function finishDocumentSubmission(event, accepted) {
2371
+ const draft = documents.drafts.get(event.conv);
2372
+ const s = draft.submission;
2373
+ if (!s || event.data?.submissionId !== s.id) return;
2374
+ if (accepted && s.attachments.length) {
2375
+ const received = (Array.isArray(event.data.attachments) ? event.data.attachments : []).filter(validDocument).map(d => d.id);
2376
+ if (JSON.stringify(received) !== JSON.stringify(s.attachments)) return;
2377
+ }
2378
+ if (!accepted) {
2379
+ draft.notice = event.data.message ?? event.data.error ?? "send_failed";
2380
+ draft.submission = null;
2381
+ const view = documentDraftViews.get(draft.conv);
2382
+ if (view) view.references.locked = false;
2383
+ } else {
2384
+ for (const image of s.images) URL.revokeObjectURL(image.url);
2385
+ documentDraftViews.delete(draft.conv);
2386
+ documents.drafts.reset(draft.conv);
2387
+ if (state.current === draft.conv) {
2388
+ attach.items = [];
2389
+ clearReferences(refs.state);
2390
+ els.input.value = "";
2391
+ els.input.style.height = "";
2392
+ }
2393
+ }
2394
+ if (state.current === draft.conv) {
2395
+ refs.state.locked = false;
2396
+ els.input.readOnly = false;
2397
+ renderTray(); renderReferences(); documents.render();
2398
+ }
2399
+ }
1926
2400
 
1927
2401
  function imageStrip(names) {
1928
2402
  const strip = document.createElement("div");
@@ -1974,7 +2448,7 @@ function renderTray() {
1974
2448
  }
1975
2449
 
1976
2450
  function addAttachments(files) {
1977
- if (attach.busy) {
2451
+ if (attach.busy || attach.documentLocked) {
1978
2452
  systemLine(t("attach.busy"));
1979
2453
  return;
1980
2454
  }
@@ -2025,6 +2499,8 @@ async function sendWithImages(text) {
2025
2499
  // The conversation and the tags as they are now go out with the images; the tags are locked until the upload ends.
2026
2500
  const conv = state.current;
2027
2501
  const draft = els.input.value;
2502
+ const items = [...attach.items];
2503
+ const referenceState = refs.state;
2028
2504
  const references = referenceSeqs(refs.state);
2029
2505
  attach.busy = true;
2030
2506
  refs.state.locked = true;
@@ -2032,12 +2508,13 @@ async function sendWithImages(text) {
2032
2508
  renderReferences();
2033
2509
  try {
2034
2510
  const names = [];
2035
- for (const item of attach.items) names.push(await uploadImage(item.file));
2511
+ for (const item of items) names.push(await uploadImage(item.file));
2036
2512
  if (state.sessionTransitions.has(conv)) return;
2037
2513
  if (!send({ type: "input.text", conv, data: inputData(text, { images: names, references }) })) return;
2038
- for (const item of attach.items) URL.revokeObjectURL(item.url);
2039
- attach.items = [];
2040
- clearReferences(refs.state);
2514
+ for (const item of items) URL.revokeObjectURL(item.url);
2515
+ if (state.current === conv) attach.items = [];
2516
+ clearReferences(referenceState);
2517
+ attach.onSent?.(conv);
2041
2518
  if (state.current === conv && els.input.value === draft) {
2042
2519
  els.input.value = "";
2043
2520
  els.input.style.height = "";
@@ -2047,7 +2524,7 @@ async function sendWithImages(text) {
2047
2524
  systemLine(t("attach.uploadFailed", { error: error.message }), true);
2048
2525
  } finally {
2049
2526
  attach.busy = false;
2050
- refs.state.locked = false;
2527
+ referenceState.locked = false;
2051
2528
  renderTray();
2052
2529
  renderReferences();
2053
2530
  }
@@ -2163,6 +2640,7 @@ async function applyOptionCommand(command) {
2163
2640
 
2164
2641
  els.composer.addEventListener("submit", (ev) => {
2165
2642
  ev.preventDefault();
2643
+ if ($("host-file-menu").contains(document.activeElement)) return;
2166
2644
  sendText();
2167
2645
  });
2168
2646
  const slash = { el: $("slash-menu"), omo: null, loading: null, failed: false, items: [], index: 0 };
@@ -2248,7 +2726,7 @@ function pickSlashCommand(i) {
2248
2726
  }
2249
2727
 
2250
2728
  function slashMenuKey(ev) {
2251
- if (slash.el.hidden || ev.isComposing) return false;
2729
+ if (slash.el.hidden || ev.isComposing || ev.keyCode === 229 || documents.composing) return false;
2252
2730
  if (ev.key === "Escape") {
2253
2731
  closeSlashMenu();
2254
2732
  return true;
@@ -2268,6 +2746,8 @@ function slashMenuKey(ev) {
2268
2746
  }
2269
2747
 
2270
2748
  els.input.addEventListener("keydown", (ev) => {
2749
+ if (ev.isComposing || ev.keyCode === 229 || documents.composing) return;
2750
+ if (documents.key(ev)) { ev.preventDefault(); return; }
2271
2751
  if (slashMenuKey(ev)) {
2272
2752
  ev.preventDefault();
2273
2753
  return;
@@ -2298,15 +2778,26 @@ els.input.addEventListener("input", (ev) => {
2298
2778
  els.input.style.height = "auto";
2299
2779
  els.input.style.height = `${Math.min(els.input.scrollHeight, 200)}px`;
2300
2780
  slash.index = 0;
2301
- renderSlashMenu();
2781
+ if (!documents.composing && !ev.isComposing) {
2782
+ documents.input();
2783
+ if (!documents.visible) renderSlashMenu();
2784
+ }
2302
2785
  });
2786
+ els.input.addEventListener("compositionstart", () => { closeSlashMenu(); documents.composition(true); });
2787
+ els.input.addEventListener("compositionend", () => { documents.composition(false); if (!documents.visible) renderSlashMenu(); });
2788
+ els.input.addEventListener("click", () => documents.input());
2789
+ els.input.addEventListener("blur", (ev) => { if (!$("host-file-menu").contains(ev.relatedTarget)) documents.close(); });
2303
2790
  els.input.addEventListener("blur", closeSlashMenu);
2304
2791
  slash.el.addEventListener("pointerdown", (ev) => ev.preventDefault());
2305
2792
  $("interrupt").onclick = () => send({ type: "input.interrupt", conv: state.current, data: {} });
2306
- $("rotate-session").onclick = () => {
2307
- if (!confirm(t("head.newSession.confirm"))) return;
2308
- send({ type: "conversation.rotate", conv: state.current });
2309
- };
2793
+ // Bug 283: while the open agent's definition changed, the question says that the new session applies it, and the
2794
+ // request opens that session at once (agent-ux todo 21); main and an unchanged agent keep the plain rotation.
2795
+ function rotateCurrentSession() {
2796
+ const view = headerView(currentConversation(), agentState.list);
2797
+ if (!confirm(t(staleNotice(view).confirmKey))) return;
2798
+ send(rotateRequest(state.current, view));
2799
+ }
2800
+ $("rotate-session").onclick = rotateCurrentSession;
2310
2801
  function toggleDevPanel() {
2311
2802
  const open = els.app.classList.toggle("dev-hidden") === false;
2312
2803
  $("toggle-dev").setAttribute("aria-expanded", String(open));
@@ -2896,7 +3387,7 @@ renderFormal();
2896
3387
 
2897
3388
  /** A held text comes back into an empty composer of its conversation, so the user can fix it and send again. */
2898
3389
  function restoreDraft(conv, text) {
2899
- if (typeof text !== "string" || state.current !== conv || els.input.value !== "" || attach.items.length || refs.state.items.length) return;
3390
+ if (typeof text !== "string" || state.current !== conv || els.input.value !== "" || attach.items.length || refs.state.items.length || attach.documentCount) return;
2900
3391
  els.input.value = text;
2901
3392
  els.input.focus();
2902
3393
  }
@@ -3205,6 +3696,9 @@ const inbox = {
3205
3696
  /** Items whose delete confirmation is open, and those whose delete request is in flight. */
3206
3697
  dismissing: new Set(),
3207
3698
  dismissBusy: new Set(),
3699
+ /** Held file reads this screen allowed or denied (the latest copy, kept listed with the outcome until deleted), and the ids with a review call in flight. */
3700
+ reviewed: new Map(),
3701
+ reviewBusy: new Set(),
3208
3702
  el: $("inbox"),
3209
3703
  list: $("inbox-list"),
3210
3704
  toggle: $("toggle-inbox"),
@@ -3247,12 +3741,57 @@ function describeAnswer(a) {
3247
3741
 
3248
3742
  async function fetchDecisions(cursor) {
3249
3743
  const query = `limit=${INBOX_PAGE}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`;
3250
- const res = await fetch(`/api/decisions?${query}`, { cache: "no-store", signal: AbortSignal.timeout(30_000) });
3744
+ const res = await fetch(`/api/decisions?${query}`, { cache: "no-store", headers: featureHeaders(), signal: AbortSignal.timeout(30_000) });
3251
3745
  const body = await res.json().catch(() => null);
3252
3746
  if (!res.ok) throw new Error(body?.error ?? String(res.status));
3253
3747
  return body;
3254
3748
  }
3255
3749
 
3750
+ // The confirmation session of this screen for agent tool permissions (294 C3): one page nonce from POST
3751
+ // /api/agent-tool-client beside the HttpOnly cookie the gateway sets. A gateway restart or an expired nonce drops the
3752
+ // token so the next call takes a new one; the refused POST itself is never replayed.
3753
+ let agentToolClient = null;
3754
+ let agentToolClientPromise = null;
3755
+ let agentToolClientRefused = false;
3756
+
3757
+ async function ensureAgentToolClient() {
3758
+ if (agentToolClient) return agentToolClient;
3759
+ agentToolClientPromise ??= (async () => {
3760
+ try {
3761
+ const res = await fetch(AGENT_TOOL_CLIENT_PATH, { method: "POST", headers: { [AGENT_TOOL_CLIENT_HEADER]: "1" }, cache: "no-store", signal: AbortSignal.timeout(30_000) });
3762
+ const result = interpretClientIssue(res.status, await res.json().catch(() => null));
3763
+ agentToolClient = result.kind === "ready" ? result.client : null;
3764
+ agentToolClientRefused = result.kind === "refused";
3765
+ } catch {
3766
+ agentToolClient = null;
3767
+ agentToolClientRefused = false;
3768
+ } finally {
3769
+ agentToolClientPromise = null;
3770
+ }
3771
+ return agentToolClient;
3772
+ })();
3773
+ return agentToolClientPromise;
3774
+ }
3775
+
3776
+ /** An owner call (list, revoke, edit, tab picker) with this screen's session; `client: false` means no session could be had. */
3777
+ async function agentToolFetch(path, init = {}) {
3778
+ const client = await ensureAgentToolClient();
3779
+ if (!client) return { status: 0, body: null, client: false };
3780
+ let res;
3781
+ try {
3782
+ res = await fetch(path, { ...init, cache: "no-store", headers: { ...(init.headers ?? {}), ...ownerHeaders(client) }, signal: AbortSignal.timeout(30_000) });
3783
+ } catch {
3784
+ return { status: 0, body: null, client: true };
3785
+ }
3786
+ const body = await res.json().catch(() => null);
3787
+ if (res.status === 403 && clientExpired(body?.error)) agentToolClient = null;
3788
+ return { status: res.status, body, client: true };
3789
+ }
3790
+
3791
+ function agentToolClientLine(prefix) {
3792
+ return t(`${prefix}.${agentToolClientRefused ? "clientUnavailable" : "clientOffline"}`);
3793
+ }
3794
+
3256
3795
  /** `refresh`: load the current copy even though one is cached (a mounted card the reset page no longer lists). */
3257
3796
  /** The mounted cards a detail load reports to: every card of the decision id, so a second card never waits on the first. */
3258
3797
  function detailCards(id, card) {
@@ -3265,7 +3804,7 @@ async function loadDecisionDetail(id, card, { refresh = false } = {}) {
3265
3804
  if ((!refresh && decisionById(inbox.state, id)) || !inbox.online || !beginDetailLoad(inbox.state, id, generation)) return;
3266
3805
  const retry = () => void loadDecisionDetail(id, card, { refresh });
3267
3806
  try {
3268
- const res = await fetch(`/api/decisions/${encodeURIComponent(id)}`, { cache: "no-store", signal: AbortSignal.timeout(30_000) });
3807
+ const res = await fetch(`/api/decisions/${encodeURIComponent(id)}`, { cache: "no-store", headers: featureHeaders(), signal: AbortSignal.timeout(30_000) });
3269
3808
  const body = await res.json().catch(() => null);
3270
3809
  if (!res.ok) {
3271
3810
  if (!failDetailLoad(inbox.state, id, generation)) return;
@@ -3390,7 +3929,9 @@ function renderInboxOnline() {
3390
3929
 
3391
3930
  /** Keyed nodes: only the changed item is rebuilt, so typing in another item survives updates. */
3392
3931
  function renderInboxList() {
3393
- const ordered = sortedDecisions(inbox.state);
3932
+ // A held file read this screen reviewed stays listed with its outcome after the gateway resolved it, until 삭제 hides it.
3933
+ const kept = new Map([...inbox.reviewed].filter(([id]) => !inbox.state.items.has(id)));
3934
+ const ordered = kept.size === 0 ? sortedDecisions(inbox.state) : sortedDecisions({ items: new Map([...kept, ...inbox.state.items]) });
3394
3935
  const keep = new Set(ordered.map((d) => d.id));
3395
3936
  for (const [id, node] of inbox.nodes) {
3396
3937
  if (keep.has(id)) continue;
@@ -3589,7 +4130,8 @@ function renderDecisionActions(decision, p, { locked, busy, review, surface }) {
3589
4130
  };
3590
4131
  if (review) actions.append(button(t("decision.reviewLatest"), "ok", () => reviewLatest(decision), !busy));
3591
4132
  else {
3592
- if (p.mode) {
4133
+ // A tool grant answers through its own two buttons (allow this scope / do not allow), never a generic send.
4134
+ if (p.mode && !isToolGrant(decision)) {
3593
4135
  // The retry label appears once an earlier attempt is uncertain, never while that attempt is still in flight.
3594
4136
  const label = !busy && inbox.state.drafts.has(decision.id) ? t("decision.resend") : t(`decision.primary.${p.kind}`);
3595
4137
  actions.append(button(label, "ok", () => void submitDecisionForm(decision, p.mode)));
@@ -3640,32 +4182,261 @@ function renderSourceContext(payload) {
3640
4182
  return box;
3641
4183
  }
3642
4184
 
4185
+ function agentDisplayName(id) {
4186
+ return agentState.list.find((agent) => agent.id === id)?.name ?? id;
4187
+ }
4188
+
4189
+ /** decision id -> { status, candidates, note }: the open tabs the gateway listed for a card's target picker (C7, this screen only). */
4190
+ const toolTargets = new Map();
4191
+
4192
+ /**
4193
+ * The tool grant card (294 C8): the design rows (agent, request source, tool, target, operation, duration), the two
4194
+ * notes, the tab picker while a borrowed tab is still unchosen, and the two direct answers. Allow stays off until the
4195
+ * tab is chosen; deny never needs one.
4196
+ */
4197
+ function renderToolGrantFields(decision, p, locked) {
4198
+ const view = toolGrantView(decision, { agentName: agentDisplayName });
4199
+ const dl = make("dl", "inbox-tool");
4200
+ for (const row of view.rows) dl.append(make("dt", null, row.label), make("dd", null, row.value));
4201
+ const nodes = [dl, make("p", "inbox-tool-note", view.note), make("p", "inbox-tool-restriction", view.restriction)];
4202
+ const outcome = toolGrantOutcomeText(decision);
4203
+ if (outcome) nodes.push(make("div", "inbox-note", outcome));
4204
+ if (!p.form) return nodes;
4205
+ if (view.targetNeeded) nodes.push(renderToolTargetPicker(decision, locked));
4206
+ const choices = make("div", "approval-options inbox-tool-choices");
4207
+ for (const choice of view.choices) {
4208
+ const button = make("button", `btn ${choice.cls}`, choice.label);
4209
+ button.type = "button";
4210
+ button.dataset.choice = choice.choice;
4211
+ button.disabled = locked || (choice.choice === "allow" && view.targetNeeded);
4212
+ button.onclick = () => {
4213
+ const composed = composeToolGrantResponse(decision, { choice: choice.choice });
4214
+ if (!composed.ok) {
4215
+ noteFor(decision.id, t(composed.reason === "target_required" ? "toolGrant.pickTabFirst" : "decision.incomplete"), true);
4216
+ syncDecisionSurfaces(decisionById(inbox.state, decision.id) ?? decision);
4217
+ return;
4218
+ }
4219
+ void submitDecision(decision.id, "answer", composed.response);
4220
+ };
4221
+ choices.append(button);
4222
+ }
4223
+ nodes.push(choices);
4224
+ return nodes;
4225
+ }
4226
+
4227
+ function renderToolTargetPicker(decision, locked) {
4228
+ const targets = toolTargets.get(decision.id) ?? { status: "idle", candidates: [], note: null };
4229
+ const box = make("div", "inbox-tool-targets");
4230
+ box.setAttribute("role", "group");
4231
+ box.setAttribute("aria-label", t("toolGrant.targets"));
4232
+ const head = make("div", "inbox-tool-targets-head");
4233
+ const load = make("button", "text-btn", t(targets.status === "ready" ? "toolGrant.reloadTabs" : "toolGrant.loadTabs"));
4234
+ load.type = "button";
4235
+ load.disabled = locked || targets.status === "loading" || targets.status === "designating";
4236
+ load.onclick = () => void loadToolTargets(decision);
4237
+ head.append(make("span", "inbox-tool-targets-label", t("toolGrant.pickTab")), load);
4238
+ const status = make("div", `inbox-note${targets.note?.error ? " error" : ""}`, targets.status === "loading" ? t("common.checking") : targets.note?.text ?? "");
4239
+ status.setAttribute("role", "status");
4240
+ box.append(head, status);
4241
+ if (targets.status !== "ready" && targets.status !== "designating") return box;
4242
+ if (targets.candidates.length === 0) box.append(make("p", "inbox-tool-targets-empty", t("toolGrant.tabsEmpty")));
4243
+ const list = make("div", "approval-options inbox-tool-target-list");
4244
+ for (const candidate of targets.candidates) {
4245
+ const button = make("button", "btn neutral inbox-tool-target", candidate.title);
4246
+ button.type = "button";
4247
+ button.dataset.candidate = candidate.id;
4248
+ button.disabled = locked || targets.status === "designating";
4249
+ if (candidate.suggested) button.append(make("small", "inbox-tool-target-suggested", t("toolGrant.suggested")));
4250
+ button.append(make("small", "inbox-tool-target-url", candidate.url));
4251
+ button.onclick = () => void designateToolTarget(decision, candidate);
4252
+ list.append(button);
4253
+ }
4254
+ box.append(list);
4255
+ return box;
4256
+ }
4257
+
4258
+ async function loadToolTargets(decision) {
4259
+ const current = toolTargets.get(decision.id);
4260
+ if (current?.status === "loading" || current?.status === "designating") return;
4261
+ toolTargets.set(decision.id, { status: "loading", candidates: current?.candidates ?? [], note: null });
4262
+ syncDecisionSurfaces(decision);
4263
+ const { status, body, client } = await agentToolFetch(candidatesPath(decision), { method: "GET" });
4264
+ let next;
4265
+ if (!client) next = { status: "failed", candidates: [], note: { text: agentToolClientLine("toolGrant"), error: true } };
4266
+ else if (status === 200) next = { status: "ready", candidates: targetCandidates(body), note: null };
4267
+ else if (status === 404) next = { status: "unsupported", candidates: [], note: { text: t("toolGrant.tabsUnsupported"), error: true } };
4268
+ else if (status === 0) next = { status: "failed", candidates: [], note: { text: t("toolGrant.tabsFailed"), error: true } };
4269
+ else next = { status: "failed", candidates: [], note: { text: toolErrorText(body?.error, status), error: true } };
4270
+ toolTargets.set(decision.id, next);
4271
+ syncDecisionSurfaces(decisionById(inbox.state, decision.id) ?? decision);
4272
+ }
4273
+
4274
+ /** Binds the chosen tab; the gateway answers with the re-proposed card, whose digest the allow button then confirms (C7). */
4275
+ async function designateToolTarget(decision, candidate) {
4276
+ const current = toolTargets.get(decision.id);
4277
+ if (!current || current.status !== "ready") return;
4278
+ toolTargets.set(decision.id, { ...current, status: "designating", note: null });
4279
+ syncDecisionSurfaces(decision);
4280
+ const request = designateRequest(decision, candidate.id);
4281
+ const { status, body, client } = await agentToolFetch(request.path, { method: request.method, headers: { "content-type": "application/json" }, body: JSON.stringify(request.body) });
4282
+ const result = client ? interpretDesignate(status, body) : { kind: "client", error: null };
4283
+ switch (result.kind) {
4284
+ case "accepted":
4285
+ toolTargets.delete(decision.id);
4286
+ applyDecision(inbox.state, result.decision);
4287
+ noteFor(decision.id, t("toolGrant.tabChosen"));
4288
+ break;
4289
+ case "conflict":
4290
+ if (result.decision) applyDecision(inbox.state, result.decision);
4291
+ toolTargets.set(decision.id, { ...current, status: "ready", note: { text: t("toolGrant.tabConflict"), error: true } });
4292
+ break;
4293
+ case "unsupported":
4294
+ toolTargets.set(decision.id, { status: "unsupported", candidates: [], note: { text: t("toolGrant.tabsUnsupported"), error: true } });
4295
+ break;
4296
+ case "client":
4297
+ toolTargets.set(decision.id, { ...current, status: "ready", note: { text: agentToolClientLine("toolGrant"), error: true } });
4298
+ break;
4299
+ default:
4300
+ toolTargets.set(decision.id, { ...current, status: "ready", note: { text: toolErrorText(result.error, status), error: true } });
4301
+ }
4302
+ syncDecisionSurfaces(decisionById(inbox.state, decision.id) ?? decision);
4303
+ renderInboxList();
4304
+ }
4305
+
4306
+ /**
4307
+ * The owner card of a held collaboration file read (purpose collaboration_artifact_review): who reads, which file, which
4308
+ * part, what the security check found (kinds and counts only) or could not check, and the two direct answers through
4309
+ * the owner hold route. The outcome line stays on the card after the gateway resolves the hold.
4310
+ */
4311
+ function renderArtifactReviewItem(decision) {
4312
+ const view = artifactReviewView(decision, { agentName: agentDisplayName, mainName: agentName });
4313
+ const busy = inbox.reviewBusy.has(decision.id);
4314
+ const li = make("li", "inbox-item kind-artifact-review inbox-item-tool");
4315
+ li.dataset.id = decision.id;
4316
+ li.dataset.revision = String(decision.revision);
4317
+ li.dataset.holdId = view.holdId;
4318
+ li.dataset.reviewState = view.state;
4319
+ li.setAttribute("aria-busy", String(busy));
4320
+ const head = make("div", "inbox-item-head");
4321
+ head.append(
4322
+ make("span", "inbox-work", decision.ownerConv === "main" ? t("inbox.mainConversation") : conversationTitle(decision.ownerConv)),
4323
+ make("span", `inbox-chip chip-${decision.status}`, view.chip ?? STATUS_LABELS[decision.status] ?? decision.status),
4324
+ );
4325
+ const question = make("div", "inbox-question", view.title);
4326
+ question.id = `inbox-q-${decision.id}`;
4327
+ li.setAttribute("aria-labelledby", question.id);
4328
+ li.append(head, question);
4329
+ const dl = make("dl", "inbox-tool");
4330
+ for (const row of view.rows) {
4331
+ const dd = make("dd", null, row.value);
4332
+ dd.dataset.row = row.key;
4333
+ dl.append(make("dt", null, row.label), dd);
4334
+ }
4335
+ li.append(dl);
4336
+ if (view.warning) {
4337
+ const warning = make("p", "inbox-tool-note inbox-review-unscanned", view.warning);
4338
+ warning.setAttribute("role", "note");
4339
+ li.append(warning);
4340
+ }
4341
+ if (view.question) li.append(make("p", "inbox-tool-restriction", view.question));
4342
+ if (view.outcome) li.append(make("div", "inbox-note inbox-review-outcome", view.outcome));
4343
+ const meta = make("dl", "inbox-meta");
4344
+ meta.append(make("dt", null, t("inbox.meta.created")), make("dd", null, dateTimeLabel(decision.createdAt)));
4345
+ li.append(meta);
4346
+ // The receipt's line stands in until the gateway's own copy carries the outcome; a refusal stays until the next click.
4347
+ const note = inbox.notes.get(decision.id);
4348
+ const shown = note && (note.error || !view.outcome) ? note : null;
4349
+ const noteEl = make("div", `inbox-note inbox-review-line${shown?.error ? " error" : ""}`, shown?.text ?? "");
4350
+ noteEl.setAttribute("role", "status");
4351
+ li.append(noteEl);
4352
+ if (view.pending) {
4353
+ const choices = make("div", "approval-options inbox-tool-choices");
4354
+ for (const choice of view.choices) {
4355
+ const button = make("button", `btn ${choice.cls}`, choice.label);
4356
+ button.type = "button";
4357
+ button.dataset.choice = choice.choice;
4358
+ button.disabled = busy || !inbox.online;
4359
+ button.onclick = () => void reviewArtifactHold(decision, choice.choice);
4360
+ choices.append(button);
4361
+ }
4362
+ li.append(choices);
4363
+ } else if (!isUnresolved(decision) && inbox.reviewed.has(decision.id)) {
4364
+ const actions = make("div", "inbox-actions");
4365
+ const hide = make("button", "text-btn inbox-review-hide", t("common.delete"));
4366
+ hide.type = "button";
4367
+ hide.onclick = () => {
4368
+ inbox.reviewed.delete(decision.id);
4369
+ inbox.notes.delete(decision.id);
4370
+ renderInboxList();
4371
+ };
4372
+ actions.append(hide);
4373
+ li.append(actions);
4374
+ }
4375
+ const previous = inbox.nodes.get(decision.id);
4376
+ if (previous?.isConnected) previous.replaceWith(li);
4377
+ inbox.nodes.set(decision.id, li);
4378
+ return li;
4379
+ }
4380
+
4381
+ function conversationTitle(convId) {
4382
+ return state.conversations.find((row) => row.id === convId)?.title ?? convId;
4383
+ }
4384
+
4385
+ /**
4386
+ * One click = one owner call (POST /api/agent-collaboration/holds/:holdId) with the HttpOnly client cookie and the page
4387
+ * nonce (collaborationFetch renews a stale nonce once), the revision on screen and a fresh submissionId. The answer is
4388
+ * shown as it is: the receipt's outcome, or the refusal; a conflict reloads the latest decision so the next click uses it.
4389
+ */
4390
+ async function reviewArtifactHold(decision, choice) {
4391
+ const request = artifactReviewRequest(decision, choice);
4392
+ if (!request || inbox.reviewBusy.has(decision.id) || !inbox.online) return;
4393
+ inbox.reviewBusy.add(decision.id);
4394
+ inbox.reviewed.set(decision.id, decision);
4395
+ noteFor(decision.id, null);
4396
+ renderInboxItem(decision);
4397
+ const { status, body } = await collaborationFetch(request);
4398
+ inbox.reviewBusy.delete(decision.id);
4399
+ const outcome = artifactReviewOutcome(status, body);
4400
+ devLog("in", { type: "artifact-review.answer", data: { id: decision.id, choice, status, result: outcome.kind, error: body?.error ?? null } });
4401
+ if (!outcome.ok) inbox.reviewed.delete(decision.id);
4402
+ noteFor(decision.id, outcome.line, !outcome.ok);
4403
+ if (outcome.kind === "conflict" || outcome.kind === "gone" || outcome.kind === "network") void loadDecisionDetail(decision.id, null, { refresh: true });
4404
+ const current = decisionById(inbox.state, decision.id) ?? inbox.reviewed.get(decision.id) ?? decision;
4405
+ if (outcome.ok) inbox.reviewed.set(decision.id, current);
4406
+ renderInboxItem(current);
4407
+ renderInboxList();
4408
+ }
4409
+
3643
4410
  function renderInboxItem(decision) {
3644
4411
  if (decision.security) return renderSecurityInboxItem(decision);
4412
+ if (isArtifactReview(decision)) return renderArtifactReviewItem(decision);
3645
4413
  const p = presentation(decision);
3646
4414
  const payload = decision.payload && typeof decision.payload === "object" ? decision.payload : {};
4415
+ const tool = isToolGrant(decision);
3647
4416
  const form = formFor(decision);
3648
4417
  const busy = isSubmitting(inbox.state, decision);
3649
4418
  const review = isReviewRequired(inbox.state, decision.id);
3650
4419
  const locked = busy || !inbox.online || review;
3651
- const li = make("li", `inbox-item kind-${p.kind}`);
4420
+ const li = make("li", `inbox-item kind-${p.kind}${tool ? " inbox-item-tool" : ""}`);
3652
4421
  li.dataset.id = decision.id;
3653
4422
  li.dataset.revision = String(decision.revision);
3654
4423
  li.setAttribute("aria-busy", String(busy));
3655
4424
  const head = make("div", "inbox-item-head");
3656
4425
  head.append(
3657
- make("span", "inbox-work", decision.work ? decision.work.title : decision.ownerConv === "main" ? t("inbox.mainConversation") : decision.ownerConv),
4426
+ // F3 D3: an agent conversation's card is headed by the agent's name, as the review card is, never by its conversation id.
4427
+ make("span", "inbox-work", decision.work ? decision.work.title : decision.ownerConv === "main" ? t("inbox.mainConversation") : conversationTitle(decision.ownerConv)),
3658
4428
  make("span", `inbox-chip chip-${decision.status}`, STATUS_LABELS[decision.status] ?? decision.status),
3659
4429
  );
3660
- const question = make("div", "inbox-question", payload.title || t(decision.permission ? "approval.permissionRequest" : "approval.question"));
4430
+ const question = make("div", "inbox-question", tool ? toolGrantView(decision).title : payload.title || t(decision.permission ? "approval.permissionRequest" : "approval.question"));
3661
4431
  question.id = `inbox-q-${decision.id}`;
3662
4432
  li.setAttribute("aria-labelledby", question.id);
3663
4433
  li.append(head, question);
3664
- if (payload.detail) li.append(make("div", "approval-detail", payload.detail));
4434
+ if (!tool && payload.detail) li.append(make("div", "approval-detail", payload.detail));
3665
4435
  const context = renderSourceContext(payload);
3666
4436
  if (context) li.append(context);
3667
- if (decision.answer) li.append(make("div", "inbox-note", t("decision.myAnswer", { answer: describeAnswer(decision.answer) })));
3668
- if (p.form) li.append(...renderAnswerFields(decision, form, locked, () => syncDecisionSurfaces(decisionById(inbox.state, decision.id) ?? decision, li)));
4437
+ if (decision.answer && !tool) li.append(make("div", "inbox-note", t("decision.myAnswer", { answer: describeAnswer(decision.answer) })));
4438
+ if (tool) li.append(...renderToolGrantFields(decision, p, locked));
4439
+ else if (p.form) li.append(...renderAnswerFields(decision, form, locked, () => syncDecisionSurfaces(decisionById(inbox.state, decision.id) ?? decision, li)));
3669
4440
  else {
3670
4441
  // No answer is taken now, but the question and its choices still read here, not only on the original card.
3671
4442
  const saved = renderSavedChoices(decision);
@@ -3680,8 +4451,10 @@ function renderInboxItem(decision) {
3680
4451
  };
3681
4452
  row(t("inbox.meta.created"), dateTimeLabel(decision.createdAt));
3682
4453
  row(t("inbox.meta.deadline"), decision.deadlineAt === null ? (decision.bindingState === "live" ? t("inbox.meta.none") : "") : `${dateTimeLabel(decision.deadlineAt)}${decision.bindingState === "expired" ? t("inbox.meta.expired") : ""}`);
3683
- row(t("inbox.meta.state"), `${bindingLabel(decision)}${decision.required ? t("inbox.meta.required") : ""}`);
3684
- row(t("inbox.meta.waitReason"), waitReason(decision));
4454
+ if (!tool) {
4455
+ row(t("inbox.meta.state"), `${bindingLabel(decision)}${decision.required ? t("inbox.meta.required") : ""}`);
4456
+ row(t("inbox.meta.waitReason"), waitReason(decision));
4457
+ }
3685
4458
  li.append(meta);
3686
4459
  const note = inbox.notes.get(decision.id);
3687
4460
  const noteEl = make("div", `inbox-note${note?.error ? " error" : ""}`, note?.text ?? "");
@@ -3821,17 +4594,28 @@ async function submitDecision(id, mode, response) {
3821
4594
  if (!beginSubmit(inbox.state, id, "http")) return;
3822
4595
  noteFor(id, null);
3823
4596
  syncDecisionSurfaces(decision);
4597
+ // A tool card answer (294 C3: grant, action, migration) carries this screen's confirmation session; without one nothing is sent.
4598
+ const tool = ownerAnswerRequired(decision);
4599
+ const client = tool ? await ensureAgentToolClient() : null;
3824
4600
  let result;
3825
- try {
3826
- const res = await fetch(`/api/decisions/${encodeURIComponent(id)}/answer`, {
3827
- method: "POST",
3828
- headers: { "content-type": "application/json" },
3829
- body: JSON.stringify(body),
3830
- signal: AbortSignal.timeout(30_000),
3831
- });
3832
- result = interpretAnswerResponse(res.status, await res.json().catch(() => null));
3833
- } catch (err) {
3834
- result = { kind: "offline", error: err instanceof Error ? err.message : String(err) };
4601
+ if (tool && !client) result = { kind: "client", error: null };
4602
+ else {
4603
+ try {
4604
+ const res = await fetch(`/api/decisions/${encodeURIComponent(id)}/answer`, {
4605
+ method: "POST",
4606
+ headers: { "content-type": "application/json", ...(client ? ownerHeaders(client) : {}) },
4607
+ body: JSON.stringify(body),
4608
+ signal: AbortSignal.timeout(30_000),
4609
+ });
4610
+ const answer = await res.json().catch(() => null);
4611
+ result = interpretAnswerResponse(res.status, answer);
4612
+ if (tool && res.status === 403 && clientExpired(answer?.error)) {
4613
+ agentToolClient = null;
4614
+ result = { kind: "expired", error: answer.error };
4615
+ }
4616
+ } catch (err) {
4617
+ result = { kind: "offline", error: err instanceof Error ? err.message : String(err) };
4618
+ }
3835
4619
  }
3836
4620
  endSubmit(inbox.state, id);
3837
4621
  devLog("in", { type: "decision.answer", data: { id, mode: body.mode, submissionId: body.submissionId, result: result.kind, error: result.error ?? null } });
@@ -3840,6 +4624,7 @@ async function submitDecision(id, mode, response) {
3840
4624
 
3841
4625
  function settleSubmit(decision, result) {
3842
4626
  const id = decision.id;
4627
+ const tool = ownerAnswerRequired(decision);
3843
4628
  switch (result.kind) {
3844
4629
  case "accepted":
3845
4630
  inbox.forms.delete(id);
@@ -3850,7 +4635,7 @@ function settleSubmit(decision, result) {
3850
4635
  case "conflict":
3851
4636
  inbox.forms.delete(id);
3852
4637
  noteConflict(inbox.state, decision, result.decision);
3853
- noteFor(id, t("decision.conflict"), true);
4638
+ noteFor(id, tool ? toolErrorText(result.error, 409) : t("decision.conflict"), true);
3854
4639
  break;
3855
4640
  case "blocked":
3856
4641
  // Improvement 165: the answer is held; the card decides. The typed answer stays in the form for a fix.
@@ -3861,7 +4646,16 @@ function settleSubmit(decision, result) {
3861
4646
  case "rejected":
3862
4647
  inbox.state.drafts.delete(id);
3863
4648
  if (result.decision) applyDecision(inbox.state, result.decision);
3864
- noteFor(id, t("decision.rejected", { error: result.error ?? "invalid" }), true);
4649
+ noteFor(id, tool ? toolErrorText(result.error, 400) : t("decision.rejected", { error: result.error ?? "invalid" }), true);
4650
+ break;
4651
+ case "client":
4652
+ // No confirmation session: the gateway refused this origin, or it could not be reached. Nothing was sent.
4653
+ inbox.state.drafts.delete(id);
4654
+ noteFor(id, agentToolClientLine("toolGrant"), true);
4655
+ break;
4656
+ case "expired":
4657
+ inbox.state.drafts.delete(id);
4658
+ noteFor(id, toolErrorText(result.error, 403), true);
3865
4659
  break;
3866
4660
  case "gone": {
3867
4661
  forgetDecision(inbox.state, id);
@@ -3872,7 +4666,7 @@ function settleSubmit(decision, result) {
3872
4666
  break;
3873
4667
  }
3874
4668
  case "unavailable":
3875
- noteFor(id, t("decision.unavailable", { status: result.status, error: result.error ? ` ${result.error}` : "" }), true);
4669
+ noteFor(id, tool ? toolErrorText(result.error, result.status) : t("decision.unavailable", { status: result.status, error: result.error ? ` ${result.error}` : "" }), true);
3876
4670
  break;
3877
4671
  default:
3878
4672
  noteFor(id, t("decision.offline.kept"), true);
@@ -3891,6 +4685,7 @@ function onDecisionUpdated(data) {
3891
4685
  securityInbox.merge(decision.security);
3892
4686
  if (decision.security.status === "pending") void securityInbox.detail(decision.id);
3893
4687
  }
4688
+ if (inbox.reviewed.has(decision.id) && decision.revision >= (inbox.reviewed.get(decision.id)?.revision ?? 0)) inbox.reviewed.set(decision.id, decision);
3894
4689
  renderInboxBadge();
3895
4690
  if (!result.changed) return;
3896
4691
  if (endSocketSubmit(inbox.state, decision)) setCardBusy(originalActionId(decision), false);
@@ -4290,11 +5085,16 @@ const appDownload = createAppDownload({
4290
5085
  });
4291
5086
  apkEls.renew.onclick = () => void appDownload.renew();
4292
5087
 
4293
- // 정리 (improvement 66): nothing is cleaned until the user ticks rows and presses the button.
4294
- const gcEls = { summary: $("gc-summary"), list: $("gc-list"), message: $("gc-message"), clean: $("gc-clean"), all: $("gc-all"), allLabel: $("gc-all-label") };
5088
+ // 정리 (improvement 66, housekeeping todo 13): two roles in one list. Nothing is cleaned until the user ticks 회수 rows and
5089
+ // presses the button; 상태 정리 rows wait for main and kept items are information only.
5090
+ const gcEls = {
5091
+ summary: $("gc-summary"), list: $("gc-list"), message: $("gc-message"), clean: $("gc-clean"), all: $("gc-all"), allLabel: $("gc-all-label"),
5092
+ audit: $("gc-audit"), auditList: $("gc-audit-list"),
5093
+ };
4295
5094
 
5095
+ // Only an enabled box belongs to a row that can be ticked: a kept item has a disabled box and a 상태 정리 row has none.
4296
5096
  function gcBoxes() {
4297
- return [...gcEls.list.querySelectorAll("input[type=checkbox]")];
5097
+ return [...gcEls.list.querySelectorAll("input[type=checkbox]:enabled")];
4298
5098
  }
4299
5099
 
4300
5100
  // Improvement 142: the 전체 선택 box and the clean button follow the ticked rows.
@@ -4315,47 +5115,92 @@ gcEls.all.onchange = () => {
4315
5115
  };
4316
5116
 
4317
5117
  function gcTicked() {
4318
- return [...gcEls.list.querySelectorAll("input[type=checkbox]:checked")].map((box) => box.value);
5118
+ return [...gcEls.list.querySelectorAll("input[type=checkbox]:enabled:checked")].map((box) => box.value);
4319
5119
  }
4320
5120
 
4321
5121
  function gcRow(row) {
4322
5122
  const li = document.createElement("li");
4323
- const label = document.createElement("label");
4324
- label.className = "gc-row";
5123
+ const line = document.createElement(row.role === "reconcile" ? "div" : "label");
5124
+ const kept = row.role === "gc" && !row.selectable;
5125
+ line.className = kept ? "gc-row readonly" : "gc-row";
5126
+ if (row.role === "reconcile") {
5127
+ const mark = document.createElement("span");
5128
+ mark.className = "gc-mark";
5129
+ line.append(mark);
5130
+ } else {
5131
+ const box = document.createElement("input");
5132
+ box.type = "checkbox";
5133
+ box.value = row.id;
5134
+ box.disabled = !row.selectable;
5135
+ box.onchange = gcSync;
5136
+ line.append(box);
5137
+ }
4325
5138
  const main = document.createElement("span");
4326
5139
  main.className = "gc-main";
4327
- const box = document.createElement("input");
4328
- box.type = "checkbox";
4329
- box.value = row.id;
4330
- box.onchange = gcSync;
5140
+ const head = document.createElement("span");
5141
+ head.className = "gc-line";
4331
5142
  const name = document.createElement("span");
4332
5143
  name.className = "gc-name";
4333
5144
  name.textContent = row.label;
4334
5145
  name.title = row.title;
5146
+ head.append(name);
5147
+ if (row.badge !== null) {
5148
+ const badge = document.createElement("span");
5149
+ badge.className = "gc-badge";
5150
+ badge.textContent = row.badge;
5151
+ head.append(badge);
5152
+ }
4335
5153
  const sub = document.createElement("span");
4336
5154
  sub.className = "gc-sub";
4337
5155
  sub.textContent = row.sub;
4338
- main.append(name, sub);
4339
- label.append(box, main);
4340
- li.append(label);
5156
+ main.append(head, sub);
5157
+ if (row.note !== null) {
5158
+ const note = document.createElement("span");
5159
+ note.className = "gc-note";
5160
+ note.textContent = row.note;
5161
+ main.append(note);
5162
+ }
5163
+ line.append(main);
5164
+ li.append(line);
5165
+ return li;
5166
+ }
5167
+
5168
+ function gcGroup(group) {
5169
+ const li = document.createElement("li");
5170
+ li.className = "gc-group";
5171
+ li.hidden = group.hidden;
5172
+ const head = document.createElement("div");
5173
+ head.className = "gc-group-head";
5174
+ const title = document.createElement("span");
5175
+ title.textContent = group.title;
5176
+ const count = document.createElement("span");
5177
+ count.className = "gc-count";
5178
+ count.textContent = String(group.count);
5179
+ head.append(title, count);
5180
+ const rows = document.createElement("ul");
5181
+ rows.className = "gc-rows";
5182
+ rows.setAttribute("aria-label", group.title);
5183
+ rows.append(...group.rows.map(gcRow));
5184
+ li.append(head, rows);
4341
5185
  return li;
4342
5186
  }
4343
5187
 
4344
- /** The last GET /api/gc answer (null when the gateway did not answer), drawn again in a new language. */
5188
+ /** The last GET /api/housekeeping answer (null when the gateway did not answer), drawn again in a new language. */
4345
5189
  function renderGc(body) {
4346
5190
  gcEls.body = body;
4347
5191
  const view = gcView(body);
4348
5192
  gcEls.summary.textContent = view.summary;
4349
5193
  gcEls.summary.classList.toggle("error", view.error);
4350
- gcEls.list.replaceChildren(...view.rows.map(gcRow));
5194
+ gcEls.list.replaceChildren(...view.groups.map(gcGroup));
4351
5195
  gcSync();
5196
+ renderGcAudit();
4352
5197
  }
4353
5198
 
4354
5199
  async function loadGc() {
4355
5200
  gcEls.clean.disabled = true;
4356
5201
  let body = null;
4357
5202
  try {
4358
- const res = await fetch(GC_PATH, { cache: "no-store" });
5203
+ const res = await fetch(HOUSEKEEPING_PATH, { cache: "no-store" });
4359
5204
  if (res.ok) body = await res.json();
4360
5205
  } catch {
4361
5206
  body = null;
@@ -4363,12 +5208,53 @@ async function loadGc() {
4363
5208
  renderGc(body);
4364
5209
  }
4365
5210
 
5211
+ // 기록 보기: the last 20 audit lines, newest first. The button text follows the open state in the current language.
5212
+ function renderGcAudit() {
5213
+ const open = !gcEls.auditList.hidden;
5214
+ gcEls.audit.textContent = t(open ? "settings.gc.auditHide" : "settings.gc.audit");
5215
+ gcEls.audit.setAttribute("aria-expanded", String(open));
5216
+ if (!open) return;
5217
+ const view = auditView(gcEls.auditBody);
5218
+ const items = view.rows.map((row) => {
5219
+ const li = document.createElement("li");
5220
+ li.textContent = `${dateTimeLabel(row.at)} · ${row.text}`;
5221
+ li.title = row.title;
5222
+ li.classList.toggle("error", row.error);
5223
+ return li;
5224
+ });
5225
+ if (view.line !== "") {
5226
+ const li = document.createElement("li");
5227
+ li.className = view.error ? "gc-audit-line error" : "gc-audit-line";
5228
+ li.textContent = view.line;
5229
+ items.push(li);
5230
+ }
5231
+ gcEls.auditList.replaceChildren(...items);
5232
+ }
5233
+
5234
+ async function loadGcAudit() {
5235
+ let body = null;
5236
+ try {
5237
+ const res = await fetch(`${HOUSEKEEPING_AUDIT_PATH}?limit=${AUDIT_LINES}`, { cache: "no-store" });
5238
+ if (res.ok) body = await res.json();
5239
+ } catch {
5240
+ body = null;
5241
+ }
5242
+ gcEls.auditBody = body;
5243
+ renderGcAudit();
5244
+ }
5245
+
5246
+ gcEls.audit.onclick = async () => {
5247
+ gcEls.auditList.hidden = !gcEls.auditList.hidden;
5248
+ if (gcEls.auditList.hidden) renderGcAudit();
5249
+ else await loadGcAudit();
5250
+ };
5251
+
4366
5252
  gcEls.clean.onclick = async () => {
4367
5253
  const ids = gcTicked();
4368
5254
  if (ids.length === 0) return;
4369
5255
  gcEls.clean.disabled = true;
4370
5256
  try {
4371
- const res = await fetch(GC_CLEAN_PATH, cleanRequest(ids));
5257
+ const res = await fetch(HOUSEKEEPING_CLEAN_PATH, cleanRequest(ids));
4372
5258
  gcEls.message.textContent = res.ok ? cleanResultLine(await res.json()) : t("settings.gc.failedHttp", { status: res.status });
4373
5259
  gcEls.message.classList.toggle("error", !res.ok);
4374
5260
  } catch (err) {
@@ -4376,6 +5262,7 @@ gcEls.clean.onclick = async () => {
4376
5262
  gcEls.message.classList.add("error");
4377
5263
  }
4378
5264
  await loadGc();
5265
+ if (!gcEls.auditList.hidden) await loadGcAudit();
4379
5266
  };
4380
5267
 
4381
5268
  function openSettings() {
@@ -4389,6 +5276,7 @@ function openSettings() {
4389
5276
  void loadAccess();
4390
5277
  void appDownload.start();
4391
5278
  gcEls.message.textContent = "";
5279
+ gcEls.auditList.hidden = true;
4392
5280
  void loadGc();
4393
5281
  settings.title.focus();
4394
5282
  }
@@ -4523,53 +5411,279 @@ manualEls.el.addEventListener("keydown", (ev) => {
4523
5411
 
4524
5412
  // 에이전트 (agent-sessions todo 10, design D5): web/agents.js decides; this block draws the sidebar group above 대화, the
4525
5413
  // header's name and stale badge, and the 에이전트 dialog (목록, editor, 공통 기본값, 보관함) over /api/agents.
4526
- const agentState = { list: [], loaded: false };
5414
+ // agentState itself sits beside `state` near the top: the profile and bubble speaker read it from the first paint.
4527
5415
  const agentEls = {
4528
5416
  list: $("agent-list"), manage: $("agent-manage"),
4529
- head: $("agent-head"), headIcon: $("agent-head-icon"), headName: $("agent-head-name"), stale: $("agent-stale"),
4530
- toggle: $("toggle-agents"), el: $("agents"), close: $("agents-close"), title: $("agents-title"), status: $("agents-status"), body: $("agents-body"),
5417
+ head: $("agent-head"), headIcon: $("agent-head-icon"), headName: $("agent-head-name"), stale: $("agent-stale"), staleText: $("agent-stale-text"), rotate: $("rotate-session"),
5418
+ toggle: $("toggle-agents"), el: $("agents"), close: $("agents-close"), title: $("agents-title"), status: $("agents-status"), applyNow: $("agents-apply-now"), body: $("agents-body"),
4531
5419
  nav: { list: $("agents-nav-list"), defaults: $("agents-nav-defaults"), trash: $("agents-nav-trash") },
4532
5420
  };
4533
- const agentView = { open: false, view: "list", editor: null, files: [], returnFocus: null, load: 0 };
5421
+ // `preview` is the can/cannot box's state (todo 12: the gateway's evaluation of the editor's text, its request sequence and
5422
+ // whether an answer is pending); `uploads` holds every agent's upload queue by id (the files picked for its source/ folder,
5423
+ // agent-ux todo 14) and `uploading` the ids whose runner is active: a queue keeps going out when another editor opens and a
5424
+ // second editor's queue starts at once (review fix B1); `lastDaily` is the open agent's newest daily note date from its detail
5425
+ // (todo 15); `fileTrash` its deleted files, `fileConfirm` the path whose in-row delete confirmation is open and `fileTrashOpen`
5426
+ // the disclosure state (todo 16). The files picked on a create screen live on that screen's editor state (`pendingUploads`,
5427
+ // todo 18 fix): the create request and the draft autosave take them from the state they were given, never from another screen.
5428
+ // While the editor's own save is on the wire, its agent.updated is that save, not a change made elsewhere.
5429
+ let editorSaving = false;
5430
+ const agentView = { open: false, view: "list", editor: null, files: [], returnFocus: null, load: 0, skills: { status: "idle", commands: null }, drafts: [], preview: { status: "loading", fields: null, seq: 0, pending: false }, uploads: new Map(), uploading: new Set(), lastDaily: null, fileTrash: [], fileConfirm: null, fileTrashOpen: false, applyNow: null };
5431
+ // The create screen's autosave (todo 11, 18): durable per draft id - one PUT per quiet period with the latest state of each draft, one
5432
+ // request per draft on the wire, and a state that became due is never dropped by closing or by another screen.
5433
+ const draftAutosave = createDraftSaver(saveDraft);
5434
+ // The capability preview (todo 12, 18): disposable and owned by the open editor - the gateway evaluates the editor's current text, one
5435
+ // request on the wire with the latest state after it; closing, opening another editor or a save answer discards what waits.
5436
+ const capabilityPreview = createPreviewRequester(requestPreview, { delayMs: PREVIEW_DELAY_MS });
4534
5437
 
4535
- async function agentApi(request) {
4536
- const res = await fetch(request.path, { method: request.method, headers: request.headers, body: request.body });
4537
- const body = await res.json().catch(() => null);
4538
- if (!res.ok) throw new Error(errorLine(body, res.status));
4539
- return body;
5438
+ /**
5439
+ * Every editor change lands here: the state is replaced, a new agent's draft autosave is touched (todo 11), and the box asks
5440
+ * the gateway again (todo 12). The edit moves the preview sequence on (todo 12 fix 4): an answer still on the wire belongs to
5441
+ * older text and is dropped when it lands, and the box stays busy until the answer for this text arrives.
5442
+ */
5443
+ function commitEditor(next) {
5444
+ agentView.editor = next;
5445
+ if (next.kind === "new" && next.draftId) draftAutosave.touch(next);
5446
+ if (next.kind === "defaults") return;
5447
+ agentView.preview = { ...agentView.preview, seq: agentView.preview.seq + 1, pending: true };
5448
+ document.getElementById("pe-capabilities")?.setAttribute("aria-busy", "true");
5449
+ capabilityPreview.touch(next);
4540
5450
  }
4541
5451
 
4542
- async function loadAgents() {
4543
- try {
4544
- const list = await agentApi({ method: "GET", path: AGENTS_PATH });
4545
- if (!Array.isArray(list)) return;
4546
- agentState.list = list;
4547
- agentState.loaded = true;
4548
- renderAgentGroup();
4549
- renderAgentHeader();
4550
- if (agentView.open && agentView.view === "list") renderAgentList();
4551
- } catch (error) {
4552
- console.warn("agents load:", error);
5452
+ /**
5453
+ * The can/cannot box at the top of an agent's editor (agent-ux todo 12): two columns from the gateway's own evaluation of
5454
+ * the editor's current text (POST /api/agents/preview; web/agents.js capabilities), each line with its detail as the
5455
+ * tooltip. Until the first answer it says it is checking; text the gateway refuses shows a line instead of a guess, and so
5456
+ * does a request that brought no answer (the gateway could not be reached); while a newer answer is pending the last valid
5457
+ * one stays, marked busy.
5458
+ */
5459
+ function renderCapabilities() {
5460
+ const preview = agentView.preview;
5461
+ const box = document.createElement("div");
5462
+ box.className = "pe-capabilities";
5463
+ box.id = "pe-capabilities";
5464
+ box.setAttribute("role", "group");
5465
+ box.setAttribute("aria-label", t("agents.cap.title"));
5466
+ box.setAttribute("aria-busy", String(preview.pending || preview.status === "loading"));
5467
+ box.dataset.state = preview.status;
5468
+ if (preview.status !== "ready") {
5469
+ const line = document.createElement("p");
5470
+ line.className = "pe-cap-line";
5471
+ line.id = "pe-cap-line";
5472
+ line.textContent = t(preview.status === "loading" ? "agents.cap.loading" : preview.status === "failed" ? "agents.cap.failed" : "agents.cap.unavailable");
5473
+ box.append(line);
5474
+ return box;
5475
+ }
5476
+ const result = capabilities(preview.fields);
5477
+ for (const [kind, items] of [["can", result.can], ["cannot", result.cannot]]) {
5478
+ const column = document.createElement("section");
5479
+ column.className = `pe-cap ${kind}`;
5480
+ const heading = document.createElement("h4");
5481
+ heading.className = "pe-cap-title";
5482
+ heading.textContent = t(`agents.cap.${kind}`);
5483
+ const list = document.createElement("ul");
5484
+ for (const item of items) {
5485
+ const li = document.createElement("li");
5486
+ li.dataset.cap = item.key;
5487
+ li.textContent = item.text;
5488
+ // The page tooltip (web/tooltip.js, bug 90): shown on hover and on focus, so the line is focusable.
5489
+ li.tabIndex = 0;
5490
+ li.setAttribute("data-tip-keep", "");
5491
+ li.setAttribute("data-tip", item.tip);
5492
+ list.append(li);
5493
+ }
5494
+ column.append(heading, list);
5495
+ box.append(column);
4553
5496
  }
5497
+ return box;
4554
5498
  }
4555
5499
 
4556
- function agentSidebarView() {
4557
- return { current: state.current, running: state.running, busy: (id) => isBusy(activity, id), pending: state.pendingByConv };
5500
+ function refreshCapabilities() {
5501
+ document.getElementById("pe-capabilities")?.replaceWith(renderCapabilities());
4558
5502
  }
4559
5503
 
4560
- function currentConversation() {
4561
- return state.conversations.find((c) => c.id === state.current) ?? null;
5504
+ /**
5505
+ * POST the editor's text for the box. While the request is on the wire the box keeps its last valid answer, marked busy
5506
+ * (marked here too, since the controller sends a queued state right after an answer cleared the mark). The answer is
5507
+ * applied only while it is the newest request of the editor that owns the preview, for its current text: a later request
5508
+ * or an edit moves the sequence on, and closing, another editor or a save answer discards the preview and moves its owner
5509
+ * token on (todo 18 fix 2) - the older answer is dropped, the box staying busy for the request that follows. A request
5510
+ * that brought no answer leaves the box `failed` (todo 12 fix 4), never an older answer marked ready.
5511
+ */
5512
+ async function requestPreview(state, token) {
5513
+ const seq = ++agentView.preview.seq;
5514
+ agentView.preview = { ...agentView.preview, pending: true };
5515
+ document.getElementById("pe-capabilities")?.setAttribute("aria-busy", "true");
5516
+ let answer = null;
5517
+ try {
5518
+ answer = await agentApi(previewRequest(state));
5519
+ } catch {
5520
+ answer = null;
5521
+ }
5522
+ if (!capabilityPreview.owns(token) || seq !== agentView.preview.seq) return;
5523
+ agentView.preview = { ...previewState(answer), seq, pending: false };
5524
+ refreshCapabilities();
4562
5525
  }
4563
5526
 
4564
- function peSpan(className, text) {
4565
- const el = document.createElement("span");
4566
- el.className = className;
4567
- el.textContent = text;
4568
- return el;
5527
+ /** The box's state for a newly opened editor: the gateway's fields when a detail or save answer already holds them, else checking until the first request answers. */
5528
+ function resetPreview(fields = null) {
5529
+ const seq = agentView.preview.seq + 1;
5530
+ agentView.preview = fields ? { status: "ready", fields, seq, pending: false } : { status: "loading", fields: null, seq, pending: true };
4569
5531
  }
4570
5532
 
4571
- function peButton(text, onClick, id, className = "text-btn", act) {
4572
- const button = document.createElement("button");
5533
+ /**
5534
+ * PUT the draft (one request per draft on the wire, see createDraftSaver). The status line under the create form says
5535
+ * when it was saved, or why not (editing goes on); it is written only while that draft is the open editor's, so an answer
5536
+ * for an earlier draft never shows as the newest save. A 409 draft_closed means the gateway archived the draft meanwhile
5537
+ * (it was created or discarded, maybe from another tab): the line says so, the text stays, and that id is not saved again.
5538
+ */
5539
+ async function saveDraft(state) {
5540
+ const current = () => agentView.editor?.draftId === state.draftId;
5541
+ if (current()) setDraftStatus(t("agents.draft.saving"), "");
5542
+ try {
5543
+ // The draft lists the queued files of the state being saved by name and size (todo 18): a deferred state still carries its own
5544
+ // files once another screen is open; a reopened draft keeps the names it had until new files are picked.
5545
+ const files = state.pendingUploads?.length ? draftFileList(state.pendingUploads) : state.draftFiles ?? [];
5546
+ await agentApi(draftRequest(state, files));
5547
+ if (current()) setDraftStatus(t("agents.draft.saved", { time: new Date().toLocaleTimeString(uiLocale(), { hour: "numeric", minute: "2-digit" }) }), "ok");
5548
+ } catch (error) {
5549
+ if (!current()) return false;
5550
+ if (error.code === "draft_closed") {
5551
+ agentView.editor = { ...agentView.editor, draftId: null };
5552
+ setDraftStatus(error.message, "warn");
5553
+ return false;
5554
+ }
5555
+ setDraftStatus(t("agents.draft.failed", { reason: error.message }), "warn");
5556
+ return false;
5557
+ }
5558
+ }
5559
+
5560
+ function setDraftStatus(text, kind) {
5561
+ const line = document.getElementById("pe-draft-status");
5562
+ if (!line) return;
5563
+ line.textContent = text;
5564
+ line.className = `pe-draft-status${kind ? ` ${kind}` : ""}`;
5565
+ }
5566
+
5567
+ /** GET /api/agents/drafts for the list screen's reopen button (todo 11); a failure leaves the cached rows. */
5568
+ async function refreshDrafts(load) {
5569
+ try {
5570
+ const rows = draftRows(await agentApi({ method: "GET", path: DRAFTS_PATH }));
5571
+ if (load !== agentView.load) return;
5572
+ agentView.drafts = rows;
5573
+ if (agentView.view === "list") renderAgentList();
5574
+ } catch (error) {
5575
+ console.warn("agent drafts:", error);
5576
+ }
5577
+ }
5578
+
5579
+ /**
5580
+ * Reopens a draft from the list (todo 11). What this page still owes that draft lands first - a state due behind its wire
5581
+ * included - so the GET answers the latest persisted state and the reopened editor never saves an older file list over a
5582
+ * newer one (todo 18 fix 3); the list says it is waiting. The wait is bounded (todo 18 fix 4): a save that never answers
5583
+ * must not block the list, so after DRAFT_REOPEN_WAIT_MS the draft opens from the newest state this page holds, with a
5584
+ * note, and the autosave goes on behind it. A failed save also recovers the local state, with retry guidance instead of a stale GET.
5585
+ * A reopen whose navigation token is no longer current (the user opened another
5586
+ * editor, the list or closed the dialog meanwhile) does nothing.
5587
+ */
5588
+ async function openDraft(id) {
5589
+ const load = agentView.load;
5590
+ try {
5591
+ showAgentStatus(t("agents.draft.waiting"));
5592
+ let bound = null;
5593
+ const saved = await Promise.race([draftAutosave.settle(id), new Promise((resolve) => { bound = setTimeout(() => resolve(null), DRAFT_REOPEN_WAIT_MS); })]);
5594
+ if (bound !== null) clearTimeout(bound);
5595
+ if (load !== agentView.load) return;
5596
+ const local = saved === false || saved === null ? draftAutosave.latest(id) : null;
5597
+ const draft = local ? { id, source: composeSource(local), files: local.pendingUploads?.length ? draftFileList(local.pendingUploads) : local.draftFiles ?? [] } : await agentApi(draftLoadRequest(id));
5598
+ if (load !== agentView.load) return;
5599
+ await showAgentView("editor", null, draft);
5600
+ if (agentView.load !== load + 1) return;
5601
+ showAgentStatus(local && saved === null ? t("agents.draft.unsettled") : "", local && saved === null ? "warn" : "");
5602
+ if (local && saved === false) setDraftStatus(t("agents.draft.recovered"), "warn");
5603
+ } catch (error) {
5604
+ if (load === agentView.load) showAgentStatus(error.message, "error");
5605
+ }
5606
+ }
5607
+
5608
+ async function discardDraft(id) {
5609
+ try {
5610
+ await agentApi(discardDraftRequest(id));
5611
+ agentView.drafts = agentView.drafts.filter((draft) => draft.id !== id);
5612
+ showAgentStatus(t("agents.draft.discarded"), "ok");
5613
+ renderAgentList();
5614
+ } catch (error) {
5615
+ showAgentStatus(error.message, "error");
5616
+ }
5617
+ }
5618
+
5619
+ /**
5620
+ * One agent API request. A refusal throws the Korean/English line with the gateway's `code`, `field` and `status` on
5621
+ * the error (saveErrorLine turns them into save-specific sentences, todo 13); a request that never got an answer
5622
+ * throws with `network: true`.
5623
+ */
5624
+ async function agentApi(request) {
5625
+ let res;
5626
+ try {
5627
+ res = await fetch(request.path, { method: request.method, headers: request.headers, body: request.body });
5628
+ } catch (error) {
5629
+ throw Object.assign(new Error(error instanceof Error ? error.message : String(error)), { code: null, field: null, status: 0, network: true });
5630
+ }
5631
+ const body = await res.json().catch(() => null);
5632
+ if (!res.ok) throw Object.assign(new Error(errorLine(body, res.status)), { code: typeof body?.error === "string" ? body.error : null, field: typeof body?.field === "string" ? body.field : null, status: res.status, network: false });
5633
+ return body;
5634
+ }
5635
+
5636
+ /** The only writer of the agent list: every write moves its stamp on (T21W-B2). */
5637
+ function writeAgents(list) {
5638
+ agentState.writes += 1;
5639
+ agentState.list = list;
5640
+ }
5641
+
5642
+ /**
5643
+ * Reads the gateway's agent list. The answer lands only when nothing was written to the list after the request went
5644
+ * out (freshListAnswer); an overtaken answer is dropped and asked once more, and that answer follows the same rule.
5645
+ */
5646
+ async function loadAgents(again = true) {
5647
+ const sent = agentState.writes;
5648
+ try {
5649
+ const list = await agentApi({ method: "GET", path: AGENTS_PATH });
5650
+ if (!Array.isArray(list)) return;
5651
+ if (!freshListAnswer(sent, agentState.writes)) {
5652
+ if (again) await loadAgents(false);
5653
+ return;
5654
+ }
5655
+ writeAgents(list);
5656
+ agentState.loaded = true;
5657
+ renderAgentGroup();
5658
+ renderAgentHeader();
5659
+ renderApplyNow();
5660
+ // The list may arrive after the transcript was painted (first load) or changed while this page was offline
5661
+ // (reconnect): the open conversation's rows, reference labels and profile follow it (bug 284).
5662
+ repaintSpeaker();
5663
+ if (agentView.open && agentView.view === "list") renderAgentList();
5664
+ refreshTargetRows();
5665
+ } catch (error) {
5666
+ console.warn("agents load:", error);
5667
+ }
5668
+ }
5669
+
5670
+ function agentSidebarView() {
5671
+ return { current: state.current, running: state.running, busy: (id) => isBusy(activity, id), pending: state.pendingByConv };
5672
+ }
5673
+
5674
+ function currentConversation() {
5675
+ return state.conversations.find((c) => c.id === state.current) ?? null;
5676
+ }
5677
+
5678
+ function peSpan(className, text) {
5679
+ const el = document.createElement("span");
5680
+ el.className = className;
5681
+ el.textContent = text;
5682
+ return el;
5683
+ }
5684
+
5685
+ function peButton(text, onClick, id, className = "text-btn", act) {
5686
+ const button = document.createElement("button");
4573
5687
  button.type = "button";
4574
5688
  button.className = className;
4575
5689
  button.textContent = text;
@@ -4618,33 +5732,131 @@ function renderAgentGroup() {
4618
5732
  }
4619
5733
  }
4620
5734
 
5735
+ /** The header's agent block and the new-session button: while the definition changed, the status says so and the button is the way to apply it (bug 283). */
4621
5736
  function renderAgentHeader() {
4622
5737
  const view = headerView(currentConversation(), agentState.list);
5738
+ const notice = staleNotice(view);
5739
+ agentEls.rotate.classList.toggle("attention", notice.attention);
5740
+ agentEls.rotate.textContent = t(notice.labelKey);
5741
+ agentEls.rotate.setAttribute("aria-label", t(notice.labelKey));
4623
5742
  agentEls.head.hidden = !view.agent;
4624
5743
  if (!view.agent) return;
4625
5744
  agentEls.headIcon.textContent = view.icon;
4626
5745
  agentEls.headName.textContent = view.name;
4627
5746
  agentEls.headName.title = view.name;
4628
- agentEls.stale.hidden = !view.stale;
5747
+ agentEls.stale.hidden = !notice.shown;
4629
5748
  agentEls.stale.title = STALE_TITLE;
5749
+ if (notice.shown) agentEls.staleText.textContent = t(notice.textKey);
5750
+ }
5751
+
5752
+ /** agent.applied of a conversation: the list follows the new session's sha (a write, so an older list answer cannot undo it). */
5753
+ function receiveAgentApplied(conv, data) {
5754
+ writeAgents(applyAgentApplied(agentState.list, conv, data));
5755
+ renderAgentHeader();
5756
+ renderAgentGroup();
5757
+ renderApplyNow();
4630
5758
  }
4631
5759
 
4632
5760
  function receiveAgentUpdate(data) {
4633
5761
  const result = applyAgentUpdate(agentState.list, state.conversations, data);
4634
- agentState.list = result.agents;
5762
+ writeAgents(result.agents);
4635
5763
  state.conversations = result.conversations;
4636
5764
  if (result.removedConv && result.removedConv === state.current) selectConversation("main");
4637
5765
  else if (result.restoredConv) selectConversation(result.restoredConv);
4638
5766
  else {
4639
5767
  renderConversations();
4640
5768
  renderAgentHeader();
5769
+ repaintSpeaker(data?.id);
4641
5770
  }
4642
5771
  if (agentView.open && agentView.view === "list") renderAgentList();
5772
+ refreshTargetRows();
5773
+ // The open editor's own save answers through saveAgentEditor; only a change made elsewhere is followed here.
5774
+ switch (agentView.open && !editorSaving ? editorUpdateAction(agentView.editor, data) : null) {
5775
+ case "reload": void reloadOpenEditor(data.id); break;
5776
+ case "changed": showAgentStatus(t("agents.changedElsewhere"), "error"); break;
5777
+ }
4643
5778
  }
4644
5779
 
4645
- function showAgentStatus(text, kind = "") {
5780
+ /** An untouched editor of an agent whose saved definition changed elsewhere shows the gateway's current definition. */
5781
+ async function reloadOpenEditor(id) {
5782
+ const load = agentView.load;
5783
+ try {
5784
+ const detail = await agentApi({ method: "GET", path: `${AGENTS_PATH}/${encodeURIComponent(id)}` });
5785
+ const editor = agentView.editor;
5786
+ if (load !== agentView.load || editorSaving || editor?.kind !== "agent" || editor.id !== id || editor.dirty === true) return;
5787
+ agentView.editor = editorState(detail, "agent");
5788
+ capabilityPreview.discard();
5789
+ resetPreview(detail.fields);
5790
+ renderAgentEditor();
5791
+ showAgentStatus(t("agents.changedElsewhere.reloaded"), "ok");
5792
+ } catch (error) {
5793
+ if (load === agentView.load) showAgentStatus(t("agents.changedElsewhere"), "error");
5794
+ }
5795
+ }
5796
+
5797
+ /**
5798
+ * After agent metadata changed (bug 284): the open agent conversation's rows and reference labels follow its name and
5799
+ * icon, and the sidebar profile is redrawn. [agentId] limits the row repaint to one agent's update (a realtime
5800
+ * `agent.updated`); without it (a list refresh) the open agent conversation is repainted whatever changed.
5801
+ */
5802
+ function repaintSpeaker(agentId = null) {
5803
+ const view = speaker();
5804
+ if (view.agent && (agentId === null || view.agentId === agentId)) {
5805
+ nameAgentRows(document.querySelectorAll(".msg:not(.mine)"), view.name);
5806
+ iconAgentRows(document.querySelectorAll(".msg:not(.mine)"), null, view.icon);
5807
+ for (const label of document.querySelectorAll('[data-role="assistant"]')) label.textContent = view.name;
5808
+ }
5809
+ refreshCollaborationCards();
5810
+ renderAgentProfile();
5811
+ }
5812
+
5813
+ /** The dialog's status line; [apply] is the "지금 적용" target a changed save offers beside it (todo 21), else no button. */
5814
+ function showAgentStatus(text, kind = "", apply = null) {
4646
5815
  agentEls.status.textContent = text;
4647
5816
  agentEls.status.className = `agents-status${kind ? ` ${kind}` : ""}`;
5817
+ agentView.applyNow = apply;
5818
+ renderApplyNow();
5819
+ }
5820
+
5821
+ function renderApplyNow() {
5822
+ agentEls.applyNow.hidden = !applyNowShown(agentView.applyNow, agentState.list);
5823
+ }
5824
+
5825
+ /**
5826
+ * Sends the rotation that opens [target]'s next session at once, subscribed to its conversation's events before the
5827
+ * request goes out; resolves with applyWatch's outcome, or "unconfirmed" when nothing decides it within APPLY_WAIT_MS.
5828
+ */
5829
+ function watchApply(target) {
5830
+ return new Promise((resolve) => {
5831
+ const follow = applyWatch(target);
5832
+ let timer = null;
5833
+ const finish = (result) => {
5834
+ applyWaits.delete(listen);
5835
+ clearTimeout(timer);
5836
+ resolve(result);
5837
+ };
5838
+ const listen = (event) => {
5839
+ const result = follow(event);
5840
+ if (result !== null) finish(result);
5841
+ };
5842
+ timer = setTimeout(() => finish("unconfirmed"), APPLY_WAIT_MS);
5843
+ applyWaits.add(listen);
5844
+ if (!send({ type: "conversation.rotate", conv: target.convId, open: true })) finish("unconfirmed");
5845
+ });
5846
+ }
5847
+
5848
+ /** "지금 적용": the same question as the header button, then the rotation; the outcome is said in the status line. */
5849
+ async function applyAgentNow() {
5850
+ const target = agentView.applyNow;
5851
+ if (!target || agentEls.applyNow.disabled) return;
5852
+ if (!confirm(t("head.newSession.confirm.apply"))) return;
5853
+ agentEls.applyNow.disabled = true;
5854
+ const result = await watchApply(target);
5855
+ agentEls.applyNow.disabled = false;
5856
+ // Whatever the outcome (a timeout included), the header and the button follow the gateway's agent state (T21W-B1).
5857
+ void loadAgents();
5858
+ // Only a turn that held the rotation leaves the button to press again once the answer ends.
5859
+ showAgentStatus(applyLine(result), result === "applied" ? "ok" : "error", result === "turn_running" ? target : null);
4648
5860
  }
4649
5861
 
4650
5862
  function openAgents(view = "list", target = null) {
@@ -4661,9 +5873,13 @@ function openAgents(view = "list", target = null) {
4661
5873
 
4662
5874
  function closeAgents() {
4663
5875
  if (!agentView.open) return;
5876
+ // A change made less than the quiet period ago is saved now; the request outlives the dialog. The preview is discarded.
5877
+ draftAutosave.flush();
5878
+ capabilityPreview.discard();
4664
5879
  agentView.open = false;
4665
5880
  agentView.load += 1;
4666
5881
  agentView.editor = null;
5882
+ agentView.skills = { status: "idle", commands: null };
4667
5883
  agentEls.el.hidden = true;
4668
5884
  agentEls.body.replaceChildren();
4669
5885
  for (const button of [agentEls.toggle, agentEls.manage]) button.setAttribute("aria-expanded", "false");
@@ -4672,13 +5888,16 @@ function closeAgents() {
4672
5888
  (back instanceof HTMLElement && back !== document.body && document.contains(back) ? back : agentEls.toggle).focus();
4673
5889
  }
4674
5890
 
4675
- async function showAgentView(view, target = null) {
5891
+ async function showAgentView(view, target = null, draft = null) {
5892
+ draftAutosave.flush();
5893
+ capabilityPreview.discard();
4676
5894
  agentView.view = view;
4677
5895
  for (const [name, button] of Object.entries(agentEls.nav)) button.setAttribute("aria-current", String(name === view));
4678
5896
  const load = ++agentView.load;
4679
5897
  agentEls.body.replaceChildren();
4680
5898
  if (view === "list") {
4681
5899
  renderAgentList();
5900
+ void refreshDrafts(load);
4682
5901
  return;
4683
5902
  }
4684
5903
  if (view === "trash") {
@@ -4694,14 +5913,31 @@ async function showAgentView(view, target = null) {
4694
5913
  // The field registry rides on the defaults answer; a new agent has no detail to take it from yet.
4695
5914
  const defaults = await agentApi({ method: "GET", path: DEFAULTS_PATH });
4696
5915
  if (load !== agentView.load) return;
4697
- agentView.editor = editorState({ registry: defaults.registry }, "new");
5916
+ // A reopened draft brings its text and id (todo 11); a fresh create screen makes a new id. defaults.md rides along as the
5917
+ // gateway parsed it (`fields`), the shared layer under the agent's own values that the can/cannot box and the rows read (todo 12 fix 2).
5918
+ agentView.editor = { ...editorState({ registry: defaults.registry, source: draft?.source, draftId: draft?.id ?? newDraftId(), defaultsFields: defaults.fields }, "new"), draftFiles: Array.isArray(draft?.files) ? draft.files : [], pendingUploads: [] };
4698
5919
  agentView.files = [];
5920
+ agentView.lastDaily = null;
5921
+ agentView.fileTrash = [];
5922
+ agentView.fileConfirm = null;
5923
+ // The box asks the gateway at once for the fresh or reopened text (todo 12).
5924
+ resetPreview();
5925
+ capabilityPreview.touch(agentView.editor);
5926
+ capabilityPreview.flush();
4699
5927
  } else {
4700
5928
  const detail = await agentApi({ method: "GET", path: `${AGENTS_PATH}/${encodeURIComponent(target)}` });
4701
- const files = await agentApi({ method: "GET", path: filesPath(target) }).catch(() => []);
5929
+ const [files, trash] = await Promise.all([agentApi({ method: "GET", path: filesPath(target) }).catch(() => []), agentApi({ method: "GET", path: fileTrashPath(target) }).catch(() => [])]);
4702
5930
  if (load !== agentView.load) return;
5931
+ // The detail carries the parsed shared layer (`defaultsFields`) beside the merged fields, which are the gateway's evaluation of this untouched text (todo 12).
4703
5932
  agentView.editor = editorState(detail, "agent");
4704
5933
  agentView.files = fileRows(files);
5934
+ // The agent's own queue stays (a run started in an earlier visit goes on, failed rows keep their 다시 시도); only the rows
5935
+ // done by now are dropped - their files are in the list.
5936
+ agentView.uploads.set(target, uploadQueue(target).filter((row) => row.status !== "done"));
5937
+ agentView.lastDaily = typeof detail.lastDaily === "string" ? detail.lastDaily : null;
5938
+ agentView.fileTrash = fileTrashRows(trash);
5939
+ agentView.fileConfirm = null;
5940
+ resetPreview(detail.fields);
4705
5941
  }
4706
5942
  } catch (error) {
4707
5943
  if (load === agentView.load) showAgentStatus(error.message, "error");
@@ -4716,6 +5952,19 @@ function renderAgentList() {
4716
5952
  const actions = document.createElement("div");
4717
5953
  actions.className = "pe-actions";
4718
5954
  actions.append(peButton(t("side.newAgent"), () => openAgents("editor", null), "pe-new", "text-btn primary"));
5955
+ // Drafts of the create screen (todo 11): a button beside + 에이전트 opens their list.
5956
+ const drafts = agentView.drafts;
5957
+ const draftList = renderDraftList(drafts);
5958
+ draftList.hidden = true;
5959
+ if (drafts.length > 0) {
5960
+ const open = peButton(t("agents.draft.open", { count: drafts.length }), () => {
5961
+ draftList.hidden = !draftList.hidden;
5962
+ open.setAttribute("aria-expanded", String(!draftList.hidden));
5963
+ }, "pe-drafts-open");
5964
+ open.setAttribute("aria-expanded", "false");
5965
+ open.setAttribute("aria-controls", "pe-draft-list");
5966
+ actions.append(open);
5967
+ }
4719
5968
  const list = document.createElement("ul");
4720
5969
  list.className = "pe-list";
4721
5970
  list.id = "pe-agent-list";
@@ -4752,10 +6001,53 @@ function renderAgentList() {
4752
6001
  li.append(peSpan("agent-icon", item.icon), main, rowActions);
4753
6002
  list.append(li);
4754
6003
  }
4755
- section.append(actions, list);
6004
+ section.append(actions, draftList, list);
4756
6005
  agentEls.body.replaceChildren(section);
4757
6006
  }
4758
6007
 
6008
+ /** The create screen's drafts (todo 11): name or 이름 없음, when it was saved, reopen, and discard with an in-row confirmation. */
6009
+ function renderDraftList(drafts) {
6010
+ const list = document.createElement("ul");
6011
+ list.className = "pe-list pe-draft-list";
6012
+ list.id = "pe-draft-list";
6013
+ for (const draft of drafts) {
6014
+ const li = document.createElement("li");
6015
+ li.className = "pe-list-row";
6016
+ li.dataset.draft = draft.id;
6017
+ const main = document.createElement("div");
6018
+ main.className = "pe-list-main";
6019
+ const name = document.createElement("div");
6020
+ name.className = "pe-list-name";
6021
+ name.textContent = draft.name ?? t("agents.draft.unnamed");
6022
+ const sub = document.createElement("div");
6023
+ sub.className = "pe-list-sub";
6024
+ sub.textContent = draft.updatedAt ? t("agents.draft.savedAt", { time: new Date(draft.updatedAt).toLocaleString(uiLocale()) }) : "";
6025
+ main.append(name, sub);
6026
+ const confirmBox = document.createElement("div");
6027
+ confirmBox.className = "inbox-dismiss mode-cancel pe-draft-discard";
6028
+ confirmBox.hidden = true;
6029
+ const text = document.createElement("p");
6030
+ text.className = "inbox-dismiss-text";
6031
+ text.textContent = t("agents.draft.discard.confirm");
6032
+ const buttons = document.createElement("div");
6033
+ buttons.className = "pe-actions";
6034
+ buttons.append(
6035
+ peButton(t("agents.draft.discard"), () => void discardDraft(draft.id), undefined, "text-btn danger", "discard-confirm"),
6036
+ peButton(t("common.cancel"), () => { confirmBox.hidden = true; }, undefined, "text-btn", "discard-cancel"),
6037
+ );
6038
+ confirmBox.append(text, buttons);
6039
+ const rowActions = document.createElement("div");
6040
+ rowActions.className = "pe-list-actions";
6041
+ rowActions.append(
6042
+ peButton(t("agents.draft.load"), () => void openDraft(draft.id), undefined, "text-btn primary", "load"),
6043
+ peButton(t("agents.draft.discard"), () => { confirmBox.hidden = !confirmBox.hidden; }, undefined, "text-btn", "discard"),
6044
+ );
6045
+ li.append(peSpan("agent-icon", "✎"), main, rowActions, confirmBox);
6046
+ list.append(li);
6047
+ }
6048
+ return list;
6049
+ }
6050
+
4759
6051
  function renderEditorRow(row) {
4760
6052
  const wrap = document.createElement("div");
4761
6053
  wrap.className = "pe-row";
@@ -4788,18 +6080,21 @@ function renderEditorRow(row) {
4788
6080
  }
4789
6081
  error.textContent = "";
4790
6082
  input.removeAttribute("aria-invalid");
4791
- agentView.editor = setField(agentView.editor, row.name, result.value);
6083
+ commitEditor(setField(agentView.editor, row.name, result.value));
4792
6084
  refreshEditorRow(wrap, row.name);
4793
6085
  };
4794
6086
  if (row.widget === "toggle") {
4795
- const toggle = document.createElement("label");
6087
+ // The settings switch (role=switch, aria-checked), not a checkbox; the label's `for` names it.
6088
+ const toggle = document.createElement("div");
4796
6089
  toggle.className = "pe-toggle";
4797
- input = document.createElement("input");
4798
- input.type = "checkbox";
6090
+ input = document.createElement("button");
6091
+ input.type = "button";
6092
+ input.className = "switch";
4799
6093
  input.id = id;
4800
- input.checked = row.value === true;
6094
+ input.setAttribute("role", "switch");
6095
+ renderSwitch(input, row.value === true);
4801
6096
  toggle.append(input, peSpan("", t(row.value === true ? "common.onShort" : "common.offShort")));
4802
- input.addEventListener("change", () => apply(input.checked));
6097
+ input.addEventListener("click", () => apply(input.getAttribute("aria-checked") !== "true"));
4803
6098
  line.append(toggle);
4804
6099
  } else if (row.widget === "select") {
4805
6100
  input = document.createElement("select");
@@ -4810,13 +6105,27 @@ function renderEditorRow(row) {
4810
6105
  input.value = typeof row.value === "string" ? row.value : "";
4811
6106
  input.addEventListener("change", () => apply(input.value));
4812
6107
  line.append(input);
4813
- } else if (row.widget === "textarea" || row.widget === "skills") {
6108
+ } else if (row.widget === "skills") {
6109
+ // The check list is a group named by the row label, not a labelable control (todo 10).
6110
+ label.removeAttribute("for");
6111
+ label.id = `${id}-label`;
6112
+ input = renderSkillsControl(row, apply, id);
6113
+ input.setAttribute("aria-labelledby", label.id);
6114
+ line.append(input);
6115
+ } else if (row.widget === "targets") {
6116
+ label.removeAttribute("for");
6117
+ label.id = `${id}-label`;
6118
+ input = renderTargetsControl(row, apply, id);
6119
+ input.setAttribute("aria-labelledby", label.id);
6120
+ line.append(input);
6121
+ } else if (row.widget === "textarea") {
4814
6122
  input = document.createElement("textarea");
4815
6123
  input.className = "pe-textarea";
4816
6124
  input.id = id;
4817
6125
  input.rows = 2;
4818
6126
  input.value = Array.isArray(row.value) ? row.value.join(", ") : typeof row.value === "string" ? row.value : "";
4819
- input.addEventListener("change", () => apply(input.value));
6127
+ // Text is committed as it is typed so the draft autosave (todo 11) sees it; a number waits for change (its validation).
6128
+ input.addEventListener("input", () => apply(input.value));
4820
6129
  line.append(input);
4821
6130
  } else {
4822
6131
  input = document.createElement("input");
@@ -4826,7 +6135,7 @@ function renderEditorRow(row) {
4826
6135
  if (row.min !== undefined) input.min = String(row.min);
4827
6136
  if (row.max !== undefined) input.max = String(row.max);
4828
6137
  input.value = row.value === null || row.value === undefined ? "" : String(row.value);
4829
- input.addEventListener("change", () => apply(input.value));
6138
+ input.addEventListener(row.widget === "number" ? "change" : "input", () => apply(input.value));
4830
6139
  line.append(input);
4831
6140
  }
4832
6141
  if (row.required) input.required = true;
@@ -4844,9 +6153,218 @@ function renderEditorRow(row) {
4844
6153
  return wrap;
4845
6154
  }
4846
6155
 
6156
+ /** The installed skill commands for the check list (todo 10), fetched once per open dialog; a failure keeps the saved values. */
6157
+ function loadSkillCommands() {
6158
+ if (agentView.skills.status === "loading") return;
6159
+ agentView.skills = { status: "loading", commands: null };
6160
+ agentApi({ method: "GET", path: "/api/commands?conv=main" })
6161
+ .then((list) => { agentView.skills = { status: "ready", commands: Array.isArray(list) ? list : [] }; })
6162
+ .catch(() => { agentView.skills = { status: "failed", commands: null }; })
6163
+ .finally(() => refreshSkillRows());
6164
+ }
6165
+
6166
+ /** The open editor's skills row drawn again from the loaded (or failed) list; the editor state is untouched. */
6167
+ function refreshSkillRows() {
6168
+ if (!agentView.open || !agentView.editor) return;
6169
+ for (const wrap of document.querySelectorAll('#pe-form .pe-row[data-field="skills"]')) {
6170
+ const row = editorRows(agentView.editor).find((r) => r.kind === "field" && r.name === "skills");
6171
+ if (row) wrap.replaceWith(renderEditorRow(row));
6172
+ }
6173
+ }
6174
+
6175
+ /**
6176
+ * The directional targets check list (agent-collaboration-292 C-10): every other agent the gateway lists, one checkbox
6177
+ * each, never this agent itself; a saved id the list no longer carries (a deleted agent) stays checked with a 없음 tag so
6178
+ * the owner can uncheck it. The gateway refuses new requests to it either way.
6179
+ */
6180
+ function renderTargetsControl(row, apply, id) {
6181
+ const box = document.createElement("div");
6182
+ box.className = "pe-skills pe-targets";
6183
+ box.id = id;
6184
+ box.setAttribute("role", "group");
6185
+ const selfId = agentView.editor?.kind === "agent" ? agentView.editor.id : null;
6186
+ const { loaded, options } = targetOptions(agentState.loaded ? agentState.list : null, selfId, row.value);
6187
+ if (!loaded) {
6188
+ const line = document.createElement("p");
6189
+ line.className = "pe-skills-status";
6190
+ line.setAttribute("role", "status");
6191
+ line.textContent = t("agents.targets.loading");
6192
+ box.append(line);
6193
+ }
6194
+ const list = document.createElement("div");
6195
+ list.className = "pe-skill-list";
6196
+ if (loaded && options.length === 0) {
6197
+ const empty = document.createElement("p");
6198
+ empty.className = "pe-skills-empty";
6199
+ empty.textContent = t("agents.targets.none");
6200
+ list.append(empty);
6201
+ }
6202
+ const checked = new Set(options.filter((option) => option.checked).map((option) => option.id));
6203
+ for (const option of options) {
6204
+ const item = document.createElement("label");
6205
+ item.className = `pe-skill pe-target${option.missing ? " missing" : ""}`;
6206
+ item.dataset.agentId = option.id;
6207
+ const input = document.createElement("input");
6208
+ input.type = "checkbox";
6209
+ input.value = option.id;
6210
+ input.checked = option.checked;
6211
+ input.disabled = !loaded;
6212
+ input.addEventListener("change", () => {
6213
+ if (input.checked) checked.add(option.id);
6214
+ else checked.delete(option.id);
6215
+ apply(options.filter((candidate) => checked.has(candidate.id)).map((candidate) => candidate.id));
6216
+ });
6217
+ item.append(input, peSpan("pe-target-name", option.name));
6218
+ if (option.missing) {
6219
+ const tag = peSpan("pe-skill-missing", t("agents.targets.missing"));
6220
+ // Not a kept-tip spot (web/tooltip.js, bug 161): the explanation becomes the tag's aria-description.
6221
+ tag.title = t("agents.targets.missing.tip");
6222
+ item.append(tag);
6223
+ } else item.append(peSpan("pe-skill-desc", option.id));
6224
+ list.append(item);
6225
+ }
6226
+ box.append(list);
6227
+ return box;
6228
+ }
6229
+
6230
+ /** The open editor's targets row drawn again after the agent list changed (a load, a rename, a delete); the editor state is untouched. */
6231
+ function refreshTargetRows() {
6232
+ if (!agentView.open || !agentView.editor) return;
6233
+ for (const wrap of document.querySelectorAll('#pe-form .pe-row[data-field="collaboration.targets"]')) {
6234
+ const row = editorRows(agentView.editor).find((r) => r.kind === "field" && r.name === "collaboration.targets");
6235
+ if (row) wrap.replaceWith(renderEditorRow(row));
6236
+ }
6237
+ }
6238
+
6239
+ /**
6240
+ * The allowed-skills check list (agent-ux todo 10): the installed skills from GET /api/commands?conv=main, one checkbox
6241
+ * each (its description as the tooltip); a saved name that is not installed stays checked at the top with a tag; a
6242
+ * search box appears over ten rows. While the list loads or could not be loaded, the saved names are shown read-only
6243
+ * with a retry button, and saving keeps the value either way.
6244
+ */
6245
+ function renderSkillsControl(row, apply, id) {
6246
+ const box = document.createElement("div");
6247
+ box.className = "pe-skills";
6248
+ box.id = id;
6249
+ box.setAttribute("role", "group");
6250
+ if (agentView.skills.status === "idle") loadSkillCommands();
6251
+ const { status } = agentView.skills;
6252
+ const { loaded, options } = skillOptions(agentView.skills.commands, row.value);
6253
+ if (!loaded) {
6254
+ const line = document.createElement("p");
6255
+ line.className = "pe-skills-status";
6256
+ line.setAttribute("role", "status");
6257
+ line.append(peSpan("", t(status === "failed" ? "agents.skills.loadFailed" : "agents.skills.loading")));
6258
+ if (status === "failed") line.append(peButton(t("agents.skills.retry"), () => loadSkillCommands(), `${id}-retry`), peSpan("", t("agents.skills.keep")));
6259
+ box.append(line);
6260
+ }
6261
+ const list = document.createElement("div");
6262
+ list.className = "pe-skill-list";
6263
+ const noMatch = document.createElement("p");
6264
+ noMatch.className = "pe-skills-empty";
6265
+ noMatch.textContent = t(loaded && options.length === 0 ? "agents.skills.none" : "agents.skills.noMatch");
6266
+ noMatch.hidden = !(loaded && options.length === 0);
6267
+ if (loaded && options.length > 10) {
6268
+ const search = document.createElement("input");
6269
+ search.type = "search";
6270
+ search.className = "pe-input pe-skill-search";
6271
+ search.id = `${id}-search`;
6272
+ search.placeholder = t("agents.skills.search");
6273
+ search.setAttribute("aria-label", t("agents.skills.search"));
6274
+ search.addEventListener("input", () => {
6275
+ const query = search.value.trim().toLowerCase();
6276
+ let shownRows = 0;
6277
+ for (const item of list.querySelectorAll(".pe-skill")) {
6278
+ const hit = !query || item.dataset.name.includes(query);
6279
+ item.hidden = !hit;
6280
+ if (hit) shownRows += 1;
6281
+ }
6282
+ noMatch.hidden = shownRows > 0;
6283
+ });
6284
+ box.append(search);
6285
+ }
6286
+ const checked = new Set(options.filter((option) => option.checked).map((option) => option.name));
6287
+ for (const option of options) {
6288
+ const item = document.createElement("label");
6289
+ item.className = `pe-skill${option.installed === false ? " missing" : ""}`;
6290
+ item.dataset.name = option.name.toLowerCase();
6291
+ const input = document.createElement("input");
6292
+ input.type = "checkbox";
6293
+ input.value = option.name;
6294
+ input.checked = option.checked;
6295
+ input.disabled = !loaded;
6296
+ input.addEventListener("change", () => {
6297
+ if (input.checked) checked.add(option.name);
6298
+ else checked.delete(option.name);
6299
+ apply(options.filter((candidate) => checked.has(candidate.name)).map((candidate) => candidate.name));
6300
+ });
6301
+ item.append(input, peSpan("pe-skill-name", option.name));
6302
+ if (option.installed === false) {
6303
+ const tag = peSpan("pe-skill-missing", t("agents.skills.notInstalled"));
6304
+ tag.setAttribute("data-tip-keep", "");
6305
+ tag.setAttribute("data-tip", t("agents.skills.notInstalled.tip"));
6306
+ item.append(tag);
6307
+ }
6308
+ if (option.description) {
6309
+ // The full description is the row's page tooltip (web/tooltip.js); the line shows it clipped.
6310
+ item.setAttribute("data-tip-keep", "");
6311
+ item.setAttribute("data-tip", option.description);
6312
+ item.append(peSpan("pe-skill-desc", option.description));
6313
+ }
6314
+ list.append(item);
6315
+ }
6316
+ list.append(noMatch);
6317
+ box.append(list);
6318
+ return box;
6319
+ }
6320
+
6321
+ /**
6322
+ * A prohibited field (agent-ux todo 8): its label, a switch that stays off and cannot be turned on, and the reason as
6323
+ * visible text the switch is described by. Prohibited rows that share one reason share one paragraph: [helpId] names
6324
+ * it and [drawHelp] says whether this row draws it (the last row of the run does). Nothing here touches the editor state,
6325
+ * so the saved source never carries a value for the field.
6326
+ */
6327
+ function renderForbiddenRow(row, helpId, drawHelp) {
6328
+ const wrap = document.createElement("div");
6329
+ wrap.className = "pe-row pe-forbidden";
6330
+ wrap.dataset.field = row.name;
6331
+ const id = `pe-field-${row.name.replaceAll(".", "-")}`;
6332
+ const label = document.createElement("label");
6333
+ label.className = "pe-label";
6334
+ label.htmlFor = id;
6335
+ label.textContent = row.label;
6336
+ const control = document.createElement("div");
6337
+ control.className = "pe-control";
6338
+ const line = document.createElement("div");
6339
+ line.className = "pe-control-line";
6340
+ const holder = document.createElement("div");
6341
+ holder.className = "pe-toggle";
6342
+ const toggle = document.createElement("button");
6343
+ toggle.type = "button";
6344
+ toggle.className = "switch";
6345
+ toggle.id = id;
6346
+ toggle.setAttribute("role", "switch");
6347
+ toggle.setAttribute("aria-checked", "false");
6348
+ toggle.setAttribute("aria-disabled", "true");
6349
+ toggle.disabled = true;
6350
+ toggle.setAttribute("aria-describedby", helpId);
6351
+ holder.append(toggle, peSpan("", t("common.offShort")));
6352
+ line.append(holder);
6353
+ control.append(line);
6354
+ if (drawHelp) {
6355
+ const help = document.createElement("p");
6356
+ help.className = "pe-help";
6357
+ help.id = helpId;
6358
+ help.textContent = row.help;
6359
+ control.append(help);
6360
+ }
6361
+ wrap.append(label, control);
6362
+ return wrap;
6363
+ }
6364
+
4847
6365
  function peClearButton(wrap, name, id) {
4848
6366
  const clear = peButton(t("agents.clearOverride"), () => {
4849
- agentView.editor = clearField(agentView.editor, name);
6367
+ commitEditor(clearField(agentView.editor, name));
4850
6368
  const row = editorRows(agentView.editor).find((r) => r.kind === "field" && r.name === name);
4851
6369
  if (row) wrap.replaceWith(renderEditorRow(row));
4852
6370
  }, `${id}-clear`, "pe-clear");
@@ -4867,42 +6385,750 @@ function refreshEditorRow(wrap, name) {
4867
6385
  const clear = wrap.querySelector(".pe-clear");
4868
6386
  if (row.clearable && !clear && origin) origin.after(peClearButton(wrap, name, `pe-field-${name.replaceAll(".", "-")}`));
4869
6387
  else if (!row.clearable && clear) clear.remove();
6388
+ const toggleSwitch = wrap.querySelector(".pe-toggle .switch");
6389
+ if (toggleSwitch) renderSwitch(toggleSwitch, row.value === true);
4870
6390
  const toggleText = wrap.querySelector(".pe-toggle span");
4871
6391
  if (toggleText) toggleText.textContent = t(row.value === true ? "common.onShort" : "common.offShort");
4872
6392
  const select = wrap.querySelector("select.pe-select");
4873
6393
  if (select?.options[0]) select.options[0].textContent = t(row.own ? "agents.clearOption" : "agents.inheritOption");
4874
6394
  }
4875
6395
 
6396
+ /**
6397
+ * The how-to-write box above the role instructions (agent-ux todo 9): a native disclosure with the four headings the body
6398
+ * should cover (web/agents.js roleGuideItems), open while creating an agent and folded in an existing agent's editor.
6399
+ */
6400
+ function renderRoleGuide(open) {
6401
+ const guide = document.createElement("details");
6402
+ guide.className = "pe-guide";
6403
+ guide.id = "pe-role-guide";
6404
+ guide.open = open;
6405
+ const summary = document.createElement("summary");
6406
+ summary.textContent = t("agents.roleGuide.title");
6407
+ const list = document.createElement("ul");
6408
+ for (const item of roleGuideItems()) {
6409
+ const li = document.createElement("li");
6410
+ li.dataset.item = item.key;
6411
+ const label = document.createElement("b");
6412
+ label.textContent = item.label;
6413
+ li.append(label, `: ${item.text}`);
6414
+ list.append(li);
6415
+ }
6416
+ guide.append(summary, list);
6417
+ return guide;
6418
+ }
6419
+
6420
+ /**
6421
+ * The knowledge file section (agent-ux todo 15): three blocks, each headed by its name and its knowledge/ path - 올린 자료
6422
+ * (the upload zone and the source/ files), 결과물 (outputs/) and 기록 (MEMORY.md and notes/, read-only, with the newest daily
6423
+ * note date). Uploads apply at once; the note says so.
6424
+ */
4876
6425
  function renderAgentFiles(id) {
4877
6426
  const section = peSection(t("agents.files"), t("agents.files.note"));
6427
+ const areas = fileAreas(agentView.files);
6428
+ const blocks = document.createElement("div");
6429
+ blocks.className = "pe-areas";
6430
+ blocks.id = "pe-files";
6431
+ const source = renderFileArea("source", "knowledge/source/", areas.source, id);
6432
+ source.insertBefore(renderUploadZone({ id: "pe-source-upload", label: t("agents.sourceFile.label"), rows: uploadQueue(id), onFiles: (files) => void uploadAgentSources(id, files), onRetry: (key) => retryAgentUpload(id, key) }), source.lastElementChild);
6433
+ const notes = renderFileArea("notes", "notes/, MEMORY.md", areas.notes, id);
6434
+ const daily = document.createElement("p");
6435
+ daily.className = "pe-last-daily";
6436
+ daily.id = "pe-last-daily";
6437
+ daily.append(peSpan("", lastDailyLine(agentView.lastDaily)), infoTipButton(t("agents.lastDaily.tip")));
6438
+ notes.append(daily);
6439
+ blocks.append(source, renderFileArea("outputs", "knowledge/outputs/", areas.outputs, id), notes);
6440
+ section.append(blocks, renderFileTrash(id, agentView.fileTrash));
6441
+ return section;
6442
+ }
6443
+
6444
+ /**
6445
+ * The create screen's 올린 자료 block (agent-ux todo 18): the same zone, but the picked files wait in the browser as queued
6446
+ * rows (만들기 뒤 올립니다) until the create request answered; only their names and sizes go to the draft.
6447
+ */
6448
+ function renderNewAgentFiles() {
6449
+ const section = peSection(t("agents.area.source"), t("agents.newFiles.note"));
6450
+ section.append(renderUploadZone({ id: "pe-new-upload", label: t("agents.sourceFile.label"), rows: agentView.editor?.pendingUploads ?? [], onFiles: queueNewAgentFiles }));
6451
+ return section;
6452
+ }
6453
+
6454
+ /** The picked files join the create screen's own queue, on its editor state, and the draft autosave gets that state (todo 18 fix). */
6455
+ function queueNewAgentFiles(files) {
6456
+ const editor = agentView.editor;
6457
+ if (editor?.kind !== "new") return;
6458
+ agentView.editor = { ...editor, pendingUploads: [...(editor.pendingUploads ?? []), ...queuedUploadRows(files)] };
6459
+ redrawUploadRows("pe-new-upload", agentView.editor.pendingUploads, null);
6460
+ if (agentView.editor.draftId) draftAutosave.touch(agentView.editor);
6461
+ }
6462
+
6463
+ /** The file section drawn again in place after a file action (the rest of the form, and what is typed in it, stays). */
6464
+ function redrawAgentFiles(id) {
6465
+ document.getElementById("pe-files")?.closest(".pe-section")?.replaceWith(renderAgentFiles(id));
6466
+ }
6467
+
6468
+ /** The file list and the deleted files read again after an action (todo 16); a failure keeps the lists as they were. */
6469
+ async function refreshKnowledge(id) {
6470
+ const [files, trash] = await Promise.all([agentApi({ method: "GET", path: filesPath(id) }), agentApi({ method: "GET", path: fileTrashPath(id) })]);
6471
+ if (agentView.editor?.id !== id) return;
6472
+ agentView.files = fileRows(files);
6473
+ agentView.fileTrash = fileTrashRows(trash);
6474
+ }
6475
+
6476
+ /** After that agent's turn ended (todo 17): the lists and the last daily note date are read again and the section redrawn; a failure leaves it as it was. */
6477
+ async function refreshKnowledgeAfterTurn(id) {
6478
+ try {
6479
+ const detail = await agentApi({ method: "GET", path: `${AGENTS_PATH}/${encodeURIComponent(id)}` });
6480
+ await refreshKnowledge(id);
6481
+ if (agentView.editor?.id !== id) return;
6482
+ agentView.lastDaily = typeof detail.lastDaily === "string" ? detail.lastDaily : null;
6483
+ redrawAgentFiles(id);
6484
+ } catch (error) {
6485
+ console.warn("agent files refresh:", error);
6486
+ }
6487
+ }
6488
+
6489
+ /**
6490
+ * 내려받기 (todo 17): the file is fetched first so a refusal (a file deleted meanwhile, 404) becomes a sentence and a fresh
6491
+ * list instead of a saved error body; a good answer is saved through a blob link with the file's own name.
6492
+ */
6493
+ async function downloadKnowledgeFile(id, file) {
6494
+ let res;
6495
+ try {
6496
+ res = await fetch(downloadPath(id, file.path));
6497
+ } catch (error) {
6498
+ showAgentStatus(t("agents.file.download.failed", { name: file.name, reason: error instanceof Error ? error.message : String(error) }), "error");
6499
+ return;
6500
+ }
6501
+ if (!res.ok) {
6502
+ const body = await res.json().catch(() => null);
6503
+ showAgentStatus(t("agents.file.download.failed", { name: file.name, reason: errorLine(body, res.status) }), "error");
6504
+ await refreshKnowledge(id).catch(() => {});
6505
+ if (agentView.editor?.id === id) redrawAgentFiles(id);
6506
+ return;
6507
+ }
6508
+ const url = URL.createObjectURL(await res.blob());
6509
+ const link = document.createElement("a");
6510
+ link.href = url;
6511
+ link.download = file.name;
6512
+ document.body.append(link);
6513
+ link.click();
6514
+ link.remove();
6515
+ URL.revokeObjectURL(url);
6516
+ }
6517
+
6518
+ function trashIcon() {
6519
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
6520
+ svg.setAttribute("viewBox", "0 0 16 16");
6521
+ svg.setAttribute("width", "13");
6522
+ svg.setAttribute("height", "13");
6523
+ svg.setAttribute("aria-hidden", "true");
6524
+ const glyph = document.createElementNS("http://www.w3.org/2000/svg", "path");
6525
+ glyph.setAttribute("fill", "currentColor");
6526
+ glyph.setAttribute("d", "M6 1h4l1 1.5h3V5H2V2.5h3L6 1zM3 6h10l-1 9H4L3 6z");
6527
+ svg.append(glyph);
6528
+ return svg;
6529
+ }
6530
+
6531
+ /**
6532
+ * One block of the file section: the area's name, its path as a code tag, the info tooltip with the detail, and its rows. A
6533
+ * source/ or outputs/ file carries a 삭제 button (todo 16); pressing it opens the confirmation under that row.
6534
+ */
6535
+ function renderFileArea(area, path, rows, id) {
6536
+ const block = document.createElement("div");
6537
+ block.className = `pe-area pe-area-${area}`;
6538
+ block.id = `pe-area-${area}`;
6539
+ const head = document.createElement("div");
6540
+ head.className = "pe-area-head";
6541
+ const code = document.createElement("code");
6542
+ code.className = "pe-area-path";
6543
+ code.textContent = path;
6544
+ head.append(peSpan("pe-area-title", t(`agents.area.${area}`)), code, infoTipButton(t(`agents.area.${area}.tip`)));
4878
6545
  const list = document.createElement("ul");
4879
6546
  list.className = "pe-files";
4880
- list.id = "pe-files";
4881
- if (agentView.files.length === 0) {
6547
+ list.id = `pe-files-${area}`;
6548
+ if (rows.length === 0) {
4882
6549
  const empty = document.createElement("li");
4883
6550
  empty.className = "pe-empty";
4884
- empty.textContent = EMPTY_FILES_LINE;
6551
+ // An empty 결과물 block says how results get there (todo 17).
6552
+ empty.textContent = area === "outputs" ? t("agents.outputs.empty") : t("agents.files.none");
4885
6553
  list.append(empty);
4886
6554
  }
4887
- for (const file of agentView.files) {
6555
+ for (const file of rows) {
4888
6556
  const li = document.createElement("li");
4889
6557
  li.className = "pe-file";
6558
+ li.dataset.path = file.path;
4890
6559
  const name = peSpan(`pe-file-name ${file.type}`, `${"\u00a0\u00a0".repeat(file.depth)}${file.type === "directory" ? `${file.name}/` : file.name}`);
4891
6560
  name.title = file.path;
4892
- li.append(name, peSpan("pe-file-size", file.label));
6561
+ li.append(name);
6562
+ const originTag = fileOriginLabel(file);
6563
+ if (originTag) {
6564
+ const tag = peSpan(`pe-file-origin ${originTag.origin}`, originTag.text);
6565
+ // Not a kept-tip spot (web/tooltip.js, bug 161): the explanation becomes the tag's aria-description.
6566
+ tag.title = originTag.tip;
6567
+ li.append(tag);
6568
+ }
6569
+ const modified = modifiedLabel(file.modifiedAt, uiLocale());
6570
+ if (modified) li.append(peSpan("pe-file-time", modified));
6571
+ li.append(peSpan("pe-file-size", file.label));
6572
+ if (downloadable(file)) {
6573
+ const link = document.createElement("a");
6574
+ link.className = "text-btn pe-file-download";
6575
+ link.href = downloadPath(id, file.path);
6576
+ link.download = file.name;
6577
+ link.textContent = t("agents.file.download");
6578
+ link.dataset.act = "download-file";
6579
+ link.setAttribute("aria-label", t("agents.file.download.label", { name: file.name }));
6580
+ link.addEventListener("click", (ev) => {
6581
+ ev.preventDefault();
6582
+ void downloadKnowledgeFile(id, file);
6583
+ });
6584
+ li.append(link);
6585
+ }
6586
+ if (deletableFile(file)) {
6587
+ const remove = peButton(t("agents.file.delete"), () => {
6588
+ agentView.fileConfirm = agentView.fileConfirm === file.path ? null : file.path;
6589
+ redrawAgentFiles(id);
6590
+ document.querySelector(`.pe-file-confirm [data-act="${agentView.fileConfirm === null ? "delete-file" : "keep-file"}"]`)?.focus();
6591
+ }, null, "text-btn danger pe-file-delete", "delete-file-open");
6592
+ remove.prepend(trashIcon());
6593
+ remove.setAttribute("aria-label", t("agents.file.delete.label", { name: file.name }));
6594
+ remove.setAttribute("aria-expanded", String(agentView.fileConfirm === file.path));
6595
+ li.append(remove);
6596
+ }
6597
+ list.append(li);
6598
+ if (agentView.fileConfirm === file.path) list.append(renderFileConfirm(id, file));
6599
+ }
6600
+ block.append(head, list);
6601
+ return block;
6602
+ }
6603
+
6604
+ /** The in-row confirmation under a file (the inbox-dismiss box the agent delete uses): what the archive means, then 삭제 or 취소. */
6605
+ function renderFileConfirm(id, file) {
6606
+ const li = document.createElement("li");
6607
+ li.className = "pe-file-confirm-row";
6608
+ const box = document.createElement("div");
6609
+ box.className = "inbox-dismiss mode-cancel pe-file-confirm";
6610
+ box.dataset.path = file.path;
6611
+ const text = document.createElement("p");
6612
+ text.className = "inbox-dismiss-text";
6613
+ text.textContent = fileDeleteText(file);
6614
+ const buttons = document.createElement("div");
6615
+ buttons.className = "pe-actions";
6616
+ const confirm = peButton(t("agents.file.delete"), () => void deleteKnowledgeFile(id, file.path), null, "text-btn danger", "delete-file");
6617
+ confirm.setAttribute("aria-label", t("agents.file.delete.label", { name: file.name }));
6618
+ const keep = peButton(t("common.cancel"), () => {
6619
+ agentView.fileConfirm = null;
6620
+ redrawAgentFiles(id);
6621
+ document.querySelector(`.pe-file[data-path="${CSS.escape(file.path)}"] .pe-file-delete`)?.focus();
6622
+ }, null, "text-btn", "keep-file");
6623
+ buttons.append(confirm, keep);
6624
+ box.append(text, buttons);
6625
+ li.append(box);
6626
+ return li;
6627
+ }
6628
+
6629
+ /** The 삭제한 파일 disclosure under the blocks: the archived files of this agent with 되살리기, and what happens after 30 days. */
6630
+ function renderFileTrash(id, rows) {
6631
+ const details = document.createElement("details");
6632
+ details.className = "pe-guide pe-file-trash";
6633
+ details.id = "pe-file-trash";
6634
+ details.open = agentView.fileTrashOpen;
6635
+ details.addEventListener("toggle", () => { agentView.fileTrashOpen = details.open; });
6636
+ const summary = document.createElement("summary");
6637
+ summary.textContent = fileTrashLabel(rows.length);
6638
+ const note = document.createElement("p");
6639
+ note.className = "pe-note";
6640
+ note.textContent = t("agents.files.trash.note");
6641
+ const list = document.createElement("ul");
6642
+ list.className = "pe-files pe-file-trash-list";
6643
+ list.id = "pe-file-trash-list";
6644
+ if (rows.length === 0) {
6645
+ const empty = document.createElement("li");
6646
+ empty.className = "pe-empty";
6647
+ empty.textContent = t("agents.files.trash.empty");
6648
+ list.append(empty);
6649
+ }
6650
+ for (const row of rows) {
6651
+ const li = document.createElement("li");
6652
+ li.className = "pe-file pe-file-trash-row";
6653
+ li.dataset.entry = row.entry;
6654
+ const name = peSpan("pe-file-name", row.path);
6655
+ name.title = row.path;
6656
+ const when = peSpan("pe-file-size", row.deletedAt ? `${row.label}${t("agents.deletedAt", { time: new Date(row.deletedAt).toLocaleString(uiLocale()) })}` : row.label);
6657
+ const restore = peButton(t("agents.file.restore"), () => void restoreKnowledgeFile(id, row.entry), null, "text-btn", "restore-file");
6658
+ restore.setAttribute("aria-label", t("agents.file.restore.label", { name: row.name }));
6659
+ li.append(name, when, restore);
4893
6660
  list.append(li);
4894
6661
  }
4895
- const upload = document.createElement("div");
4896
- upload.className = "pe-upload";
6662
+ details.append(summary, note, list);
6663
+ return details;
6664
+ }
6665
+
6666
+ /** POST files/delete (todo 2): the file moves to this agent's archive; both lists are read again and the line says how to get it back. */
6667
+ async function deleteKnowledgeFile(id, path) {
6668
+ const name = path.slice(path.lastIndexOf("/") + 1);
6669
+ for (const button of document.querySelectorAll(".pe-file-confirm .text-btn")) button.disabled = true;
6670
+ try {
6671
+ await agentApi(fileDeleteRequest(id, path));
6672
+ agentView.fileConfirm = null;
6673
+ await refreshKnowledge(id);
6674
+ showAgentStatus(t("agents.file.deleted", { name }), "ok");
6675
+ } catch (error) {
6676
+ showAgentStatus(error.message, "error");
6677
+ }
6678
+ if (agentView.editor?.id === id) redrawAgentFiles(id);
6679
+ }
6680
+
6681
+ /** POST files/trash/<entry>/restore (todo 2): the file returns to its folder (under a new name when the old one is taken again). */
6682
+ async function restoreKnowledgeFile(id, entry) {
6683
+ for (const button of document.querySelectorAll(".pe-file-trash-row .text-btn")) button.disabled = true;
6684
+ try {
6685
+ const result = await agentApi(fileRestoreRequest(id, entry));
6686
+ await refreshKnowledge(id);
6687
+ showAgentStatus(t("agents.file.restored", { path: typeof result?.path === "string" ? result.path : entry }), "ok");
6688
+ } catch (error) {
6689
+ showAgentStatus(error.message, "error");
6690
+ }
6691
+ if (agentView.editor?.id === id) redrawAgentFiles(id);
6692
+ }
6693
+
6694
+ /**
6695
+ * The upload component (agent-ux todo 14): a dashed drop zone with the styled 파일 고르기 button over a hidden `multiple`
6696
+ * file input (the browser's own control is never shown), a drag highlight, and one progress row per queued file (name,
6697
+ * size, bar, state word with its icon, 다시 시도 on a refused upload). The caller keeps the queue (web/agents.js uploadRows)
6698
+ * and gets the picked FileList in onFiles and a failed row's key in onRetry.
6699
+ */
6700
+ function renderUploadZone({ id, label, onFiles, onRetry = null, rows = [], multiple = true }) {
6701
+ const zone = document.createElement("div");
6702
+ zone.className = "pe-upload-zone";
6703
+ zone.id = id;
4897
6704
  const input = document.createElement("input");
4898
6705
  input.type = "file";
4899
- input.id = "pe-source-file";
4900
- input.setAttribute("aria-label", t("agents.sourceFile.label"));
4901
- upload.append(input, peButton(t("agents.sourceUpload"), () => void uploadAgentSource(id, input), "pe-source-upload"));
4902
- section.append(list, upload);
6706
+ input.multiple = multiple;
6707
+ input.hidden = true;
6708
+ input.id = `${id}-input`;
6709
+ input.setAttribute("aria-label", label);
6710
+ input.addEventListener("change", () => {
6711
+ if (input.files?.length) onFiles(input.files);
6712
+ input.value = "";
6713
+ });
6714
+ const prompt = document.createElement("div");
6715
+ prompt.className = "pe-upload-prompt";
6716
+ const pick = peButton(t("agents.upload.pick"), () => input.click(), `${id}-pick`, "text-btn pe-upload-pick");
6717
+ pick.setAttribute("data-tip-keep", "");
6718
+ pick.setAttribute("data-tip", t("agents.upload.pick.tip", { max: formatBytes(UPLOAD_MAX_BYTES) }));
6719
+ prompt.append(peSpan("pe-upload-hint", t("agents.upload.drop")), pick);
6720
+ zone.append(input, prompt);
6721
+ // Files dragged over the zone: the chat and the memo accept a drop the same way (dragover preventDefault, then drop);
6722
+ // the zone also shows it. The depth counter keeps the highlight while the pointer crosses the zone's children.
6723
+ const draggingFiles = (ev) => [...(ev.dataTransfer?.types ?? [])].includes("Files");
6724
+ let depth = 0;
6725
+ zone.addEventListener("dragenter", (ev) => {
6726
+ if (!draggingFiles(ev)) return;
6727
+ ev.preventDefault();
6728
+ depth += 1;
6729
+ zone.classList.toggle("dragging", true);
6730
+ });
6731
+ zone.addEventListener("dragover", (ev) => {
6732
+ if (!draggingFiles(ev)) return;
6733
+ ev.preventDefault();
6734
+ zone.classList.toggle("dragging", true);
6735
+ });
6736
+ zone.addEventListener("dragleave", () => {
6737
+ depth = Math.max(0, depth - 1);
6738
+ if (depth === 0) zone.classList.toggle("dragging", false);
6739
+ });
6740
+ zone.addEventListener("drop", (ev) => {
6741
+ if (!ev.dataTransfer?.files?.length) return;
6742
+ ev.preventDefault();
6743
+ depth = 0;
6744
+ zone.classList.toggle("dragging", false);
6745
+ onFiles(ev.dataTransfer.files);
6746
+ });
6747
+ zone.append(renderUploadRows(id, rows, onRetry));
6748
+ return zone;
6749
+ }
6750
+
6751
+ /** The queue rows of one zone: drawn by renderUploadZone and replaced in place while an upload runs (the form is not rebuilt). */
6752
+ function renderUploadRows(zoneId, rows, onRetry) {
6753
+ const list = document.createElement("ul");
6754
+ list.className = "pe-upload-rows";
6755
+ list.id = `${zoneId}-rows`;
6756
+ list.setAttribute("aria-label", t("agents.upload.rows.label"));
6757
+ list.hidden = rows.length === 0;
6758
+ for (const row of rows) {
6759
+ const li = document.createElement("li");
6760
+ li.className = `pe-upload-row ${row.status}`;
6761
+ li.dataset.key = row.key;
6762
+ const head = document.createElement("div");
6763
+ head.className = "pe-upload-head";
6764
+ const name = peSpan("pe-file-name", row.name);
6765
+ name.title = row.name;
6766
+ const state = peSpan(`pe-upload-state ${row.status}`, uploadStateLine(row));
6767
+ state.setAttribute("role", "status");
6768
+ head.append(name, peSpan("pe-file-size", formatBytes(row.size)), state);
6769
+ // 다시 시도 only where a new request could succeed: a file the web itself refused stays failed (retryUpload says so).
6770
+ if (onRetry && row.status === "failed" && retryUpload([row], row.key)[0].status === "waiting") {
6771
+ const retry = peButton(t("agents.upload.retry"), () => onRetry(row.key), null, "text-btn pe-upload-retry", "retry");
6772
+ retry.setAttribute("aria-label", t("agents.upload.retry.label", { name: row.name }));
6773
+ head.append(retry);
6774
+ }
6775
+ const bar = document.createElement("div");
6776
+ bar.className = "pe-upload-bar";
6777
+ bar.setAttribute("role", "progressbar");
6778
+ bar.setAttribute("aria-label", t("agents.upload.progress.label", { name: row.name }));
6779
+ bar.setAttribute("aria-valuemin", "0");
6780
+ bar.setAttribute("aria-valuemax", "100");
6781
+ bar.setAttribute("aria-valuenow", String(Math.round(row.progress * 100)));
6782
+ const fill = document.createElement("div");
6783
+ fill.className = "pe-upload-fill";
6784
+ fill.style.width = `${Math.round(row.progress * 100)}%`;
6785
+ bar.append(fill);
6786
+ li.append(head, bar);
6787
+ list.append(li);
6788
+ }
6789
+ return list;
6790
+ }
6791
+
6792
+ /** One zone's rows redrawn from the queue; nothing happens when the zone is not on the page any more. */
6793
+ function redrawUploadRows(zoneId, rows, onRetry) {
6794
+ document.getElementById(`${zoneId}-rows`)?.replaceWith(renderUploadRows(zoneId, rows, onRetry));
6795
+ }
6796
+
6797
+ /**
6798
+ * POST one file as multipart through XMLHttpRequest (fetch cannot report upload progress). Resolves the gateway's JSON
6799
+ * answer; a refusal rejects like agentApi (the UI line as the message, `code` and `status` on the error), a request that
6800
+ * never got an answer with `network: true`.
6801
+ */
6802
+ function uploadWithProgress(path, file, onProgress) {
6803
+ return new Promise((resolve, reject) => {
6804
+ const xhr = new XMLHttpRequest();
6805
+ xhr.open("POST", path);
6806
+ xhr.responseType = "json";
6807
+ xhr.upload.onprogress = (ev) => {
6808
+ if (ev.lengthComputable && ev.total > 0) onProgress(ev.loaded / ev.total);
6809
+ };
6810
+ xhr.onload = () => {
6811
+ const body = xhr.response;
6812
+ if (xhr.status >= 200 && xhr.status < 300) resolve(body);
6813
+ else reject(Object.assign(new Error(errorLine(body, xhr.status)), { code: typeof body?.error === "string" ? body.error : null, field: null, status: xhr.status, network: false }));
6814
+ };
6815
+ xhr.onerror = () => reject(Object.assign(new Error(t("agents.upload.network")), { code: null, field: null, status: 0, network: true }));
6816
+ xhr.onabort = xhr.onerror;
6817
+ const form = new FormData();
6818
+ form.append("file", file, file.name);
6819
+ xhr.send(form);
6820
+ });
6821
+ }
6822
+
6823
+ /**
6824
+ * The 도구 권한 section of an agent's editor (294 C8/C10): one agent at a time, its loaded page
6825
+ * (GET /api/agents/:id/tool-permissions), the grants busy with a request, the open scope drafts and the revoke-all
6826
+ * confirmation. Drafts survive a reload; a conflict shows the current content beside the draft.
6827
+ */
6828
+ const agentToolView = { agentId: null, status: "idle", page: null, note: null, busy: new Set(), editing: new Map(), confirmAll: false, seq: 0 };
6829
+
6830
+ function refreshToolPermissions() {
6831
+ if (agentToolView.agentId) document.getElementById("pe-tools")?.replaceWith(renderToolPermissions(agentToolView.agentId));
6832
+ }
6833
+
6834
+ function renderToolPermissions(agentId) {
6835
+ const view = agentToolView;
6836
+ if (view.agentId !== agentId) {
6837
+ Object.assign(view, { agentId, status: "loading", page: null, note: null, busy: new Set(), editing: new Map(), confirmAll: false });
6838
+ void loadToolPermissions(agentId, { force: false });
6839
+ }
6840
+ const section = peSection(t("agents.tool.title"), t("agents.tool.note"));
6841
+ section.id = "pe-tools";
6842
+ section.classList.add("pe-tools");
6843
+ section.setAttribute("aria-busy", String(view.status === "loading"));
6844
+ const status = make("p", `pe-tool-status${view.note?.error ? " error" : ""}`,
6845
+ view.status === "loading" ? t("agents.tool.loading") : view.status === "unsupported" ? t("agents.tool.unsupported") : view.note?.text ?? "");
6846
+ status.setAttribute("role", "status");
6847
+ section.append(status);
6848
+ if (view.status === "failed") section.append(peButton(t("agents.tool.retry"), () => void loadToolPermissions(agentId, { force: true }), null, "text-btn pe-tool-retry"));
6849
+ const page = view.page;
6850
+ if (!page) return section;
6851
+ section.append(make("p", "pe-note pe-tool-summary", accessSummary(page.policy)));
6852
+ const pending = Array.isArray(page.pending) ? page.pending.length : 0;
6853
+ if (pending > 0) {
6854
+ const line = make("p", "pe-note pe-tool-pending", t("agents.tool.pending", { count: pending }));
6855
+ line.append(peButton(t("agents.tool.openInbox"), () => openInbox(), null, "link-btn"));
6856
+ section.append(line);
6857
+ }
6858
+ const rows = toolGrantRows(page, { toolName, operationLabel, scopeText });
6859
+ if (rows.length === 0) section.append(make("p", "pe-empty", t("agents.tool.empty")));
6860
+ else {
6861
+ const list = make("ul", "pe-list pe-tool-list");
6862
+ for (const row of rows) list.append(renderToolGrantRow(agentId, row, page));
6863
+ section.append(list);
6864
+ }
6865
+ const all = make("div", "pe-tool-all");
6866
+ const revokeAll = peButton(t("agents.tool.revokeAll"), () => {
6867
+ view.confirmAll = true;
6868
+ refreshToolPermissions();
6869
+ document.getElementById("pe-tools")?.querySelector(".pe-tool-all-confirm .text-btn")?.focus();
6870
+ }, "pe-tool-revoke-all", "text-btn danger");
6871
+ revokeAll.disabled = page.policy?.accessState === "blocked" || view.busy.has("all") || rows.every((row) => row.state === "revoked");
6872
+ all.append(revokeAll);
6873
+ if (view.confirmAll && !revokeAll.disabled) {
6874
+ const confirm = make("div", "inbox-dismiss mode-cancel pe-tool-all-confirm");
6875
+ confirm.append(make("p", "inbox-dismiss-text", t("agents.tool.all.confirm")));
6876
+ const actions = make("div", "pe-actions");
6877
+ actions.append(
6878
+ peButton(t("agents.tool.all.keep"), () => {
6879
+ view.confirmAll = false;
6880
+ refreshToolPermissions();
6881
+ }, null, "text-btn"),
6882
+ peButton(t("agents.tool.all.go"), () => void revokeAllGrants(agentId), null, "text-btn danger"),
6883
+ );
6884
+ confirm.append(actions);
6885
+ all.append(confirm);
6886
+ }
6887
+ section.append(all);
4903
6888
  return section;
4904
6889
  }
4905
6890
 
6891
+ /** One grant: tool, state chip, operations and target, the original confirmation, the role=switch (off = revoke at once) and the row actions. */
6892
+ function renderToolGrantRow(agentId, row, page) {
6893
+ const view = agentToolView;
6894
+ const grant = page.items.find((item) => item.id === row.id);
6895
+ const busy = view.busy.has(row.id);
6896
+ const off = row.state === "revoked" || row.state === "blocked";
6897
+ const li = make("li", "pe-list-row pe-tool-row");
6898
+ li.dataset.grant = row.id;
6899
+ li.setAttribute("aria-busy", String(busy));
6900
+ const main = make("div", "pe-list-main pe-tool-main");
6901
+ const name = make("div", "pe-list-name pe-tool-name", row.tool);
6902
+ name.append(make("span", `inbox-chip chip-${row.state}`, row.stateLabel));
6903
+ main.append(name, make("div", "pe-list-sub pe-tool-sub", [row.operation, row.target].filter(Boolean).join(" · ")));
6904
+ main.append(make("div", "pe-tool-meta", t("agents.tool.meta", { decision: row.origin, revision: row.revision })));
6905
+ li.append(main);
6906
+ const holder = make("div", "pe-tool-switch");
6907
+ const toggle = make("button", "switch");
6908
+ toggle.type = "button";
6909
+ toggle.id = `pe-tool-switch-${row.id}`;
6910
+ toggle.setAttribute("role", "switch");
6911
+ toggle.setAttribute("aria-label", t("agents.tool.switch.label", { tool: row.tool }));
6912
+ renderSwitch(toggle, !off, busy || off);
6913
+ holder.append(toggle, make("span", null, t(off ? "common.offShort" : "common.onShort")));
6914
+ li.append(holder);
6915
+ if (off) {
6916
+ // Back on is not a switch flip: the agent's next request is allowed in the inbox (C8).
6917
+ const why = make("p", "pe-note pe-tool-reallow", t("agents.tool.reallow"));
6918
+ why.id = `pe-tool-reallow-${row.id}`;
6919
+ toggle.setAttribute("aria-describedby", why.id);
6920
+ li.append(why);
6921
+ return li;
6922
+ }
6923
+ toggle.onclick = () => void revokeGrant(agentId, grant);
6924
+ const actions = make("div", "pe-list-actions pe-tool-actions");
6925
+ const edit = peButton(t("agents.tool.edit"), () => {
6926
+ if (view.editing.has(row.id)) view.editing.delete(row.id);
6927
+ else view.editing.set(row.id, scopeDraft(grant));
6928
+ refreshToolPermissions();
6929
+ }, null, "text-btn");
6930
+ edit.disabled = busy;
6931
+ edit.setAttribute("aria-expanded", String(view.editing.has(row.id)));
6932
+ const revoke = peButton(t("agents.tool.revoke"), () => void revokeGrant(agentId, grant), null, "text-btn");
6933
+ revoke.disabled = busy;
6934
+ actions.append(edit, revoke);
6935
+ li.append(actions);
6936
+ if (view.editing.has(row.id)) li.append(renderScopeEditor(agentId, grant, row));
6937
+ return li;
6938
+ }
6939
+
6940
+ /** The edit line: the kind of change (C8) and the exact operations and values it adds or removes. */
6941
+ function diffLine(grant, edit) {
6942
+ const parts = [editLine(edit.kind)];
6943
+ const operations = (list) => list.map((operation) => operationLabel(grant.toolKey, operation)).join(", ");
6944
+ const values = (scope) => Object.entries(scope).map(([key, list]) => `${key} ${list.join(", ")}`).join("; ");
6945
+ if (edit.added.operations.length) parts.push(t("agents.tool.edit.addedOps", { list: operations(edit.added.operations) }));
6946
+ if (Object.keys(edit.added.scope).length) parts.push(t("agents.tool.edit.addedScope", { list: values(edit.added.scope) }));
6947
+ if (edit.removed.operations.length) parts.push(t("agents.tool.edit.removedOps", { list: operations(edit.removed.operations) }));
6948
+ if (Object.keys(edit.removed.scope).length) parts.push(t("agents.tool.edit.removedScope", { list: values(edit.removed.scope) }));
6949
+ return parts.join(" ");
6950
+ }
6951
+
6952
+ /**
6953
+ * The inline scope editor of one grant: the granted operations as role=switch rows (off narrows) and every list value
6954
+ * of the scope as text (fewer values narrow, more widen). The diff line and the save button update in place, so typing
6955
+ * never redraws the field under the caret.
6956
+ */
6957
+ function renderScopeEditor(agentId, grant, row) {
6958
+ const view = agentToolView;
6959
+ const draft = view.editing.get(row.id);
6960
+ const box = make("div", "pe-tool-edit");
6961
+ box.setAttribute("role", "group");
6962
+ box.setAttribute("aria-label", t("agents.tool.edit"));
6963
+ const diff = make("p", "pe-note pe-tool-diff");
6964
+ const save = peButton(t("common.save"), () => void submitScopeEdit(agentId, grant), null, "text-btn primary");
6965
+ const update = () => {
6966
+ const edit = classifyEdit(grant, draft);
6967
+ diff.textContent = diffLine(grant, edit);
6968
+ diff.classList.toggle("widen", edit.kind === "widen");
6969
+ save.disabled = edit.kind === "same" || view.busy.has(row.id);
6970
+ };
6971
+ const ops = make("div", "pe-tool-ops");
6972
+ ops.append(make("div", "pe-tool-edit-label", t("agents.tool.edit.operations")));
6973
+ for (const operation of row.operations) {
6974
+ const line = make("div", "switch-row pe-tool-op");
6975
+ const toggle = make("button", "switch");
6976
+ toggle.type = "button";
6977
+ toggle.id = `pe-tool-op-${row.id}-${operation}`;
6978
+ toggle.setAttribute("role", "switch");
6979
+ toggle.dataset.operation = operation;
6980
+ const on = () => draft.operations.includes(operation);
6981
+ renderSwitch(toggle, on());
6982
+ toggle.onclick = () => {
6983
+ draft.operations = on() ? draft.operations.filter((entry) => entry !== operation) : row.operations.filter((entry) => entry === operation || draft.operations.includes(entry));
6984
+ renderSwitch(toggle, on());
6985
+ update();
6986
+ };
6987
+ const label = make("label", "switch-label", operationLabel(grant.toolKey, operation));
6988
+ label.htmlFor = toggle.id;
6989
+ line.append(toggle, label);
6990
+ ops.append(line);
6991
+ }
6992
+ box.append(ops);
6993
+ for (const [key, text] of Object.entries(draft.text)) {
6994
+ const field = make("label", "pe-tool-scope-field");
6995
+ field.append(make("span", "pe-tool-edit-label", t("agents.tool.edit.scope", { key })));
6996
+ const area = make("textarea", "pe-textarea pe-tool-scope");
6997
+ area.rows = 2;
6998
+ area.value = text;
6999
+ area.setAttribute("aria-label", key);
7000
+ area.addEventListener("input", () => {
7001
+ draft.text[key] = area.value;
7002
+ draft.scope[key] = parseScopeText(area.value);
7003
+ update();
7004
+ });
7005
+ field.append(area);
7006
+ box.append(field);
7007
+ }
7008
+ const actions = make("div", "pe-actions pe-tool-edit-actions");
7009
+ actions.append(save, peButton(t("common.cancel"), () => {
7010
+ view.editing.delete(row.id);
7011
+ refreshToolPermissions();
7012
+ }, null, "text-btn"));
7013
+ box.append(diff, actions);
7014
+ update();
7015
+ return box;
7016
+ }
7017
+
7018
+ async function loadToolPermissions(agentId, { force = false } = {}) {
7019
+ const view = agentToolView;
7020
+ if (view.agentId !== agentId) return;
7021
+ const seq = ++view.seq;
7022
+ view.status = "loading";
7023
+ if (force) view.note = null;
7024
+ refreshToolPermissions();
7025
+ const request = toolPermissionsRequest(agentId);
7026
+ let answer = await agentToolFetch(request.path, { method: "GET" });
7027
+ // A read may take the renewed session once; a POST never repeats by itself.
7028
+ if (answer.client && answer.status === 403 && clientExpired(answer.body?.error)) answer = await agentToolFetch(request.path, { method: "GET" });
7029
+ if (view.agentId !== agentId || seq !== view.seq) return;
7030
+ if (!answer.client) {
7031
+ view.status = "failed";
7032
+ view.note = { text: t(agentToolClientRefused ? "agents.tool.error.forbidden" : "agents.tool.error.network"), error: true };
7033
+ } else {
7034
+ const result = interpretPermissionResponse(answer.status, answer.body);
7035
+ if (result.kind === "ok") {
7036
+ view.status = "ready";
7037
+ view.page = answer.body;
7038
+ } else if (result.kind === "unsupported") {
7039
+ view.status = "unsupported";
7040
+ view.page = null;
7041
+ } else {
7042
+ view.status = "failed";
7043
+ view.note = { text: toolPermissionErrorLine(result.error ?? null, answer.status), error: true };
7044
+ }
7045
+ }
7046
+ refreshToolPermissions();
7047
+ }
7048
+
7049
+ function toolPermissionFailure(answer) {
7050
+ if (!answer.client) return t(agentToolClientRefused ? "agents.tool.error.forbidden" : "agents.tool.error.network");
7051
+ const result = interpretPermissionResponse(answer.status, answer.body);
7052
+ return toolPermissionErrorLine(result.error ?? null, answer.status);
7053
+ }
7054
+
7055
+ const RELOAD_AFTER = new Set(["conflict", "stale", "gone"]);
7056
+
7057
+ /** OFF on the switch or 권한 회수: revoked at once, no second confirmation (C8); the gateway's answer names any running work. */
7058
+ async function revokeGrant(agentId, grant) {
7059
+ const view = agentToolView;
7060
+ if (!grant || view.agentId !== agentId || view.busy.has(grant.id)) return;
7061
+ view.busy.add(grant.id);
7062
+ view.note = null;
7063
+ refreshToolPermissions();
7064
+ const request = revokeRequest(agentId, grant, crypto.randomUUID());
7065
+ const answer = await agentToolFetch(request.path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(request.body) });
7066
+ view.busy.delete(grant.id);
7067
+ if (view.agentId !== agentId) return;
7068
+ const result = answer.client ? interpretPermissionResponse(answer.status, answer.body) : { kind: "network" };
7069
+ if (result.kind === "ok") {
7070
+ view.page = applyRevoke(view.page, grant.id, answer.body);
7071
+ view.editing.delete(grant.id);
7072
+ view.note = { text: revokeLine(answer.body), error: false };
7073
+ refreshToolPermissions();
7074
+ return;
7075
+ }
7076
+ view.note = { text: toolPermissionFailure(answer), error: true };
7077
+ if (RELOAD_AFTER.has(result.kind)) void loadToolPermissions(agentId);
7078
+ else refreshToolPermissions();
7079
+ }
7080
+
7081
+ /** C10 revoke-all after the inline confirmation: every grant revoked, new requests blocked until the inbox allows one. */
7082
+ async function revokeAllGrants(agentId) {
7083
+ const view = agentToolView;
7084
+ if (view.agentId !== agentId || !view.page || view.busy.has("all")) return;
7085
+ view.busy.add("all");
7086
+ view.confirmAll = false;
7087
+ view.note = null;
7088
+ refreshToolPermissions();
7089
+ const request = revokeAllRequest(agentId, view.page.policy, crypto.randomUUID());
7090
+ const answer = await agentToolFetch(request.path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(request.body) });
7091
+ view.busy.delete("all");
7092
+ if (view.agentId !== agentId) return;
7093
+ const result = answer.client ? interpretPermissionResponse(answer.status, answer.body) : { kind: "network" };
7094
+ view.note = result.kind === "ok" ? { text: t("agents.tool.revokedAll"), error: false } : { text: toolPermissionFailure(answer), error: true };
7095
+ if (result.kind === "ok" || RELOAD_AFTER.has(result.kind)) void loadToolPermissions(agentId);
7096
+ else refreshToolPermissions();
7097
+ }
7098
+
7099
+ /** POST .../edit: 200 narrowed and applied, 202 widened and sent to the inbox, 409 keeps the draft beside the reloaded content (C8). */
7100
+ async function submitScopeEdit(agentId, grant) {
7101
+ const view = agentToolView;
7102
+ const draft = view.editing.get(grant.id);
7103
+ if (!draft || view.agentId !== agentId || view.busy.has(grant.id) || classifyEdit(grant, draft).kind === "same") return;
7104
+ view.busy.add(grant.id);
7105
+ view.note = null;
7106
+ refreshToolPermissions();
7107
+ const request = editRequest(agentId, grant, draft, crypto.randomUUID());
7108
+ const answer = await agentToolFetch(request.path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(request.body) });
7109
+ view.busy.delete(grant.id);
7110
+ if (view.agentId !== agentId) return;
7111
+ const result = answer.client ? interpretPermissionResponse(answer.status, answer.body) : { kind: "network" };
7112
+ if (result.kind === "ok" || result.kind === "confirm") {
7113
+ const updated = result.kind === "ok" && answer.body && typeof answer.body === "object" ? answer.body.grant : null;
7114
+ if (updated && typeof updated === "object" && view.page) {
7115
+ const policyRevision = typeof answer.body.policyRevision === "number" ? answer.body.policyRevision : view.page.policy.policyRevision;
7116
+ view.page = { ...view.page, items: view.page.items.map((item) => (item.id === grant.id ? updated : item)), policy: { ...view.page.policy, policyRevision } };
7117
+ }
7118
+ view.editing.delete(grant.id);
7119
+ view.note = { text: t(result.kind === "ok" ? "agents.tool.edit.narrowed" : "agents.tool.edit.sent"), error: false };
7120
+ refreshToolPermissions();
7121
+ return;
7122
+ }
7123
+ view.note = { text: toolPermissionFailure(answer), error: true };
7124
+ if (RELOAD_AFTER.has(result.kind)) void loadToolPermissions(agentId);
7125
+ else refreshToolPermissions();
7126
+ }
7127
+
7128
+ function receiveToolPermissionsEvent(data) {
7129
+ if (agentToolView.agentId && policyEventStale(agentToolView.page, data)) void loadToolPermissions(agentToolView.agentId);
7130
+ }
7131
+
4906
7132
  function renderAgentEditor() {
4907
7133
  const editor = agentView.editor;
4908
7134
  if (!editor) return;
@@ -4918,7 +7144,30 @@ function renderAgentEditor() {
4918
7144
  const isDefaults = editor.kind === "defaults";
4919
7145
  const fields = peSection(isDefaults ? t("agents.defaultsSection") : editor.kind === "new" ? t("agents.new") : t("agents.settingsFor", { name: editor.name ?? "" }),
4920
7146
  t(isDefaults ? "agents.defaults.note" : "agents.editor.note"));
4921
- for (const row of editorRows(editor)) {
7147
+ if (editor.kind === "new") {
7148
+ // The draft line (todo 11): what the autosave did last; a reopened draft lists the files it had picked.
7149
+ const note = document.createElement("p");
7150
+ note.className = "pe-note";
7151
+ note.textContent = t("agents.draft.note");
7152
+ const status = document.createElement("p");
7153
+ status.className = "pe-draft-status";
7154
+ status.id = "pe-draft-status";
7155
+ status.setAttribute("role", "status");
7156
+ // A redraw keeps the last line; a reopened draft (it has text already) says so; a fresh screen says nothing yet.
7157
+ status.textContent = document.getElementById("pe-draft-status")?.textContent ?? (Object.keys(editor.own).length > 0 || editor.body ? t("agents.draft.loaded") : "");
7158
+ fields.append(note, status);
7159
+ if (editor.draftFiles?.length) {
7160
+ const files = document.createElement("p");
7161
+ files.className = "pe-note pe-draft-files";
7162
+ files.id = "pe-draft-files";
7163
+ files.textContent = t("agents.draft.files", { names: editor.draftFiles.map((file) => file.name).join(", ") });
7164
+ fields.append(files);
7165
+ }
7166
+ }
7167
+ if (!isDefaults) fields.append(renderCapabilities());
7168
+ const rows = editorRows(editor);
7169
+ for (let index = 0; index < rows.length; index += 1) {
7170
+ const row = rows[index];
4922
7171
  if (row.kind === "group") {
4923
7172
  const group = document.createElement("div");
4924
7173
  group.className = "section-label pe-group";
@@ -4927,18 +7176,25 @@ function renderAgentEditor() {
4927
7176
  fields.append(group);
4928
7177
  continue;
4929
7178
  }
7179
+ if (row.kind === "forbidden") {
7180
+ // Consecutive prohibited rows with one reason share one paragraph, drawn under the last of them.
7181
+ let last = index;
7182
+ while (rows[last + 1]?.kind === "forbidden" && rows[last + 1].help === row.help) last += 1;
7183
+ fields.append(renderForbiddenRow(row, `pe-field-${rows[last].name.replaceAll(".", "-")}-help`, last === index));
7184
+ continue;
7185
+ }
4930
7186
  fields.append(renderEditorRow(row));
4931
7187
  }
4932
7188
  const instructionsKey = isDefaults ? "agents.sharedInstructions" : "agents.roleInstructions";
4933
7189
  const bodySection = peSection(t(instructionsKey), t(`${instructionsKey}.note`));
7190
+ if (!isDefaults) bodySection.append(renderRoleGuide(editor.kind === "new"));
4934
7191
  const bodyArea = document.createElement("textarea");
4935
7192
  bodyArea.className = "pe-textarea pe-body";
4936
7193
  bodyArea.id = "pe-body";
4937
7194
  bodyArea.value = editor.body;
7195
+ if (!isDefaults) bodyArea.placeholder = t("agents.roleInstructions.placeholder");
4938
7196
  bodyArea.setAttribute("aria-label", t(instructionsKey));
4939
- bodyArea.addEventListener("change", () => {
4940
- agentView.editor = setBody(agentView.editor, bodyArea.value);
4941
- });
7197
+ bodyArea.addEventListener("input", () => commitEditor(setBody(agentView.editor, bodyArea.value)));
4942
7198
  bodySection.append(bodyArea);
4943
7199
  const rawSection = peSection(t("agents.rawSection"), t("agents.raw.note"));
4944
7200
  const rawArea = document.createElement("textarea");
@@ -4947,13 +7203,13 @@ function renderAgentEditor() {
4947
7203
  rawArea.value = editor.unknownRaw;
4948
7204
  rawArea.spellcheck = false;
4949
7205
  rawArea.setAttribute("aria-label", t("agents.raw.label"));
4950
- rawArea.addEventListener("change", () => {
4951
- agentView.editor = setUnknownRaw(agentView.editor, rawArea.value);
4952
- });
7206
+ rawArea.addEventListener("input", () => commitEditor(setUnknownRaw(agentView.editor, rawArea.value)));
4953
7207
  rawSection.append(rawArea);
4954
7208
  form.append(fields, bodySection, rawSection);
4955
7209
  const existing = editor.kind === "agent" && editor.id;
7210
+ if (existing) form.append(renderToolPermissions(editor.id));
4956
7211
  if (existing) form.append(renderAgentFiles(editor.id));
7212
+ if (editor.kind === "new") form.append(renderNewAgentFiles());
4957
7213
  let deleteBox = null;
4958
7214
  let deleteInput = null;
4959
7215
  const error = document.createElement("p");
@@ -5011,44 +7267,6 @@ function renderAgentEditor() {
5011
7267
  if (focused) document.getElementById(focused)?.focus();
5012
7268
  }
5013
7269
 
5014
- async function saveAgentEditor() {
5015
- const editor = agentView.editor;
5016
- if (!editor) return;
5017
- const errors = validateEditor(editor);
5018
- const errorEl = document.getElementById("pe-form-error");
5019
- if (errors.length) {
5020
- if (errorEl) errorEl.textContent = errors.map((e) => e.message).join(" · ");
5021
- showAgentStatus(t("agents.notSaved"), "error");
5022
- return;
5023
- }
5024
- if (errorEl) errorEl.textContent = "";
5025
- const save = document.getElementById("pe-save");
5026
- if (save) save.disabled = true;
5027
- try {
5028
- const body = await agentApi(saveRequest(editor));
5029
- if (editor.kind === "defaults") {
5030
- agentView.editor = editorState(body, "defaults");
5031
- showAgentStatus(t("agents.defaultsSaved"), "ok");
5032
- renderAgentEditor();
5033
- return;
5034
- }
5035
- receiveAgentUpdate(body);
5036
- if (editor.kind === "new") {
5037
- closeAgents();
5038
- systemLine(t("agents.created.line", { name: body.name }));
5039
- return;
5040
- }
5041
- agentView.editor = editorState(body, "agent");
5042
- showAgentStatus(t("agents.saved"), "ok");
5043
- renderAgentEditor();
5044
- } catch (error) {
5045
- showAgentStatus(error.message, "error");
5046
- } finally {
5047
- const button = document.getElementById("pe-save");
5048
- if (button) button.disabled = false;
5049
- }
5050
- }
5051
-
5052
7270
  async function cloneAgent(id) {
5053
7271
  try {
5054
7272
  const body = await agentApi(cloneRequest(id));
@@ -5072,22 +7290,70 @@ async function deleteAgent(id, typed) {
5072
7290
  }
5073
7291
  }
5074
7292
 
5075
- async function uploadAgentSource(id, input) {
5076
- const file = input.files?.[0];
5077
- if (!file) {
5078
- showAgentStatus(t("agents.pickFile"), "error");
5079
- return;
7293
+ /**
7294
+ * The files picked or dropped for an agent's source/ folder (agent-ux todo 14): they join that agent's queue under the zone
7295
+ * (a file over the gateway limit fails there at once and is never sent) and go out one by one, each row showing its
7296
+ * progress. The file list is read again after the run and the status line says what was uploaded.
7297
+ */
7298
+ async function uploadAgentSources(id, files) {
7299
+ setUploadQueue(id, [...uploadQueue(id), ...uploadRows(files)]);
7300
+ await runAgentUploads(id);
7301
+ }
7302
+
7303
+ /** One agent's upload queue; empty when nothing was picked for it. */
7304
+ function uploadQueue(id) {
7305
+ return agentView.uploads.get(id) ?? [];
7306
+ }
7307
+
7308
+ /** Replaces one agent's queue and redraws its zone when that agent's editor is the open one; another editor's zone is left alone. */
7309
+ function setUploadQueue(id, rows) {
7310
+ agentView.uploads.set(id, rows);
7311
+ if (agentView.editor?.id === id) redrawUploadRows("pe-source-upload", rows, (key) => retryAgentUpload(id, key));
7312
+ }
7313
+
7314
+ function retryAgentUpload(id, key) {
7315
+ setUploadQueue(id, retryUpload(uploadQueue(id), key));
7316
+ void runAgentUploads(id);
7317
+ }
7318
+
7319
+ function setAgentUpload(id, key, patch) {
7320
+ setUploadQueue(id, advanceUpload(uploadQueue(id), key, patch));
7321
+ }
7322
+
7323
+ /**
7324
+ * Sends one agent's waiting rows in order, one request at a time: one runner per agent (a second call while it runs only
7325
+ * leaves its rows in the queue) and the run goes on when another editor opens (review fix B1). The list and the status
7326
+ * line are refreshed only while that agent's editor is the open one - after the run, and again after the list answered.
7327
+ */
7328
+ async function runAgentUploads(id) {
7329
+ if (agentView.uploading.has(id)) return;
7330
+ agentView.uploading.add(id);
7331
+ const saved = [];
7332
+ try {
7333
+ for (let row = nextUpload(uploadQueue(id)); row; row = nextUpload(uploadQueue(id))) {
7334
+ setAgentUpload(id, row.key, { status: "uploading", progress: 0 });
7335
+ try {
7336
+ const result = await uploadWithProgress(sourceUploadPath(id), row.file, (progress) => setAgentUpload(id, row.key, { status: "uploading", progress }));
7337
+ setAgentUpload(id, row.key, { status: "done", savedName: result.name });
7338
+ saved.push({ name: row.name, savedName: result.name, size: result.size });
7339
+ } catch (error) {
7340
+ setAgentUpload(id, row.key, { status: "failed", error: error.code ?? (error.network ? "network" : "rejected"), errorLine: error.message });
7341
+ }
7342
+ }
7343
+ } finally {
7344
+ agentView.uploading.delete(id);
5080
7345
  }
5081
- const form = new FormData();
5082
- form.append("file", file, file.name);
7346
+ if (agentView.editor?.id !== id) return;
5083
7347
  try {
5084
- const result = await agentApi({ method: "POST", path: sourceUploadPath(id), body: form });
5085
- agentView.files = fileRows(await agentApi({ method: "GET", path: filesPath(id) }));
5086
- showAgentStatus(t("agents.uploaded", { name: result.name, size: formatBytes(result.size) }), "ok");
5087
- renderAgentEditor();
7348
+ const files = await agentApi({ method: "GET", path: filesPath(id) });
7349
+ if (agentView.editor?.id !== id) return;
7350
+ agentView.files = fileRows(files);
5088
7351
  } catch (error) {
7352
+ if (agentView.editor?.id !== id) return;
5089
7353
  showAgentStatus(error.message, "error");
5090
7354
  }
7355
+ if (saved.length) showAgentStatus(uploadedLine(saved), "ok");
7356
+ renderAgentEditor();
5091
7357
  }
5092
7358
 
5093
7359
  async function renderAgentTrash(load) {
@@ -5124,7 +7390,9 @@ async function renderAgentTrash(load) {
5124
7390
  main.append(name, sub);
5125
7391
  const rowActions = document.createElement("div");
5126
7392
  rowActions.className = "pe-list-actions";
5127
- rowActions.append(peButton(t("agents.restore"), () => void restoreAgent(row.entry), undefined, "text-btn primary", "restore"));
7393
+ const restore = peButton(t("agents.restore"), () => restoreAgent(row.entry), undefined, "text-btn primary", "restore");
7394
+ restore.disabled = restoreInFlight.has(row.entry);
7395
+ rowActions.append(restore);
5128
7396
  li.append(peSpan("agent-icon", row.icon), main, rowActions);
5129
7397
  list.append(li);
5130
7398
  }
@@ -5132,21 +7400,98 @@ async function renderAgentTrash(load) {
5132
7400
  agentEls.body.replaceChildren(section);
5133
7401
  }
5134
7402
 
5135
- async function restoreAgent(entry) {
7403
+ /**
7404
+ * Archive entries with a restore in flight: entry -> the token of the request that owns it (gate review T22-B1). One
7405
+ * request per entry at a time, plain or under a new id: the row's 되살리기 and the notice's retry are locked while it
7406
+ * runs (a row or notice drawn meanwhile comes up locked too), and a reply whose request no longer owns the entry is
7407
+ * dropped, so a late refusal can never overwrite a completed restore.
7408
+ */
7409
+ const restoreInFlight = new Map();
7410
+
7411
+ /**
7412
+ * A restore from the archive (plan todo 22). The gateway's 409 `exists` means the entry's id is live again (the agent
7413
+ * was deleted twice and one copy restored): a notice under the row explains it and offers the same restore with
7414
+ * `{ newId: true }`, which keeps both. There is no overwrite choice.
7415
+ */
7416
+ async function restoreAgent(entry, newId = false) {
7417
+ if (restoreInFlight.has(entry)) return;
7418
+ const token = {};
7419
+ restoreInFlight.set(entry, token);
7420
+ lockRestore(entry, true);
7421
+ const request = restoreRequest(entry, { newId });
7422
+ let outcome;
5136
7423
  try {
5137
- const body = await agentApi(restoreRequest(entry));
5138
- receiveAgentUpdate(body);
5139
- showAgentStatus(t("agents.restored", { name: body.name }), "ok");
5140
- await showAgentView("list");
7424
+ const res = await fetch(request.path, { method: request.method, headers: request.headers, body: request.body });
7425
+ outcome = restoreOutcome(res.status, await res.json().catch(() => null));
5141
7426
  } catch (error) {
5142
- showAgentStatus(error.message, "error");
7427
+ outcome = { kind: "error", message: error.message };
7428
+ }
7429
+ if (restoreInFlight.get(entry) !== token) return;
7430
+ restoreInFlight.delete(entry);
7431
+ lockRestore(entry, false);
7432
+ if (outcome.kind === "restored") {
7433
+ receiveAgentUpdate(outcome.detail);
7434
+ showAgentStatus(t(newId ? "agents.restored.newId" : "agents.restored", { name: outcome.detail.name }), "ok");
7435
+ await showAgentView("list");
7436
+ } else if (outcome.kind === "conflict") showRestoreConflict(entry, outcome.id);
7437
+ else if (newId) showRestoreConflictError(entry, outcome.message);
7438
+ else showAgentStatus(outcome.message, "error");
7439
+ }
7440
+
7441
+ /** The row's 되살리기 and the notice's retry of one entry, as drawn now, locked while its request runs. */
7442
+ function lockRestore(entry, locked) {
7443
+ for (const button of [archiveRow(entry)?.querySelector('[data-act="restore"]'), restoreConflictBox(entry)?.querySelector('[data-act="restore-new-id"]')]) {
7444
+ if (button) button.disabled = locked;
5143
7445
  }
5144
7446
  }
5145
7447
 
7448
+ function archiveRow(entry) {
7449
+ return [...document.querySelectorAll("#pe-trash-list li[data-entry]")].find((li) => li.dataset.entry === entry) ?? null;
7450
+ }
7451
+
7452
+ /** The notice under an archive row after a 409 `exists`: the cause, [새 ID로 되살리기] and [취소]. A second 409 replaces it. */
7453
+ function showRestoreConflict(entry, id) {
7454
+ const row = archiveRow(entry);
7455
+ if (!row) {
7456
+ showAgentStatus(t("agents.restore.conflict", { id }), "error");
7457
+ return;
7458
+ }
7459
+ restoreConflictBox(entry)?.remove();
7460
+ const box = document.createElement("li");
7461
+ box.className = "inbox-dismiss pe-restore-conflict";
7462
+ box.dataset.conflict = entry;
7463
+ box.tabIndex = -1;
7464
+ const text = document.createElement("p");
7465
+ text.className = "inbox-dismiss-text";
7466
+ text.textContent = t("agents.restore.conflict", { id });
7467
+ const error = document.createElement("p");
7468
+ error.className = "pe-error";
7469
+ const buttons = document.createElement("div");
7470
+ buttons.className = "pe-actions";
7471
+ const retry = peButton(t("agents.restore.newId"), () => restoreAgent(entry, true), undefined, "text-btn primary", "restore-new-id");
7472
+ retry.disabled = restoreInFlight.has(entry);
7473
+ buttons.append(retry, peButton(t("common.cancel"), () => box.remove(), undefined, "text-btn", "restore-cancel"));
7474
+ box.append(text, error, buttons);
7475
+ row.after(box);
7476
+ box.focus();
7477
+ }
7478
+
7479
+ /** A failed retry under a new id: the reason stays in the notice, next to the button; without the notice it is the status line. */
7480
+ function showRestoreConflictError(entry, message) {
7481
+ const line = t("agents.restore.newId.failed", { reason: message });
7482
+ const error = restoreConflictBox(entry)?.querySelector(".pe-error");
7483
+ if (error) error.textContent = line;
7484
+ else showAgentStatus(line, "error");
7485
+ }
7486
+
7487
+ function restoreConflictBox(entry) {
7488
+ return [...document.querySelectorAll("#pe-trash-list li[data-conflict]")].find((li) => li.dataset.conflict === entry) ?? null;
7489
+ }
7490
+
5146
7491
  agentEls.manage.onclick = () => (agentView.open ? closeAgents() : openAgents("list"));
5147
7492
  agentEls.toggle.onclick = () => (agentView.open ? closeAgents() : openAgents("list"));
5148
- agentEls.stale.onclick = () => send({ type: "conversation.rotate", conv: state.current });
5149
7493
  agentEls.close.onclick = closeAgents;
7494
+ agentEls.applyNow.onclick = () => void applyAgentNow();
5150
7495
  for (const [view, button] of Object.entries(agentEls.nav)) button.onclick = () => openAgents(view);
5151
7496
  agentEls.el.addEventListener("click", (ev) => {
5152
7497
  if (ev.target === agentEls.el) closeAgents();
@@ -5173,6 +7518,76 @@ agentEls.el.addEventListener("keydown", (ev) => {
5173
7518
  });
5174
7519
  void loadAgents();
5175
7520
 
7521
+ /**
7522
+ * Save the open editor (todo 13, bug 285). An unchanged text is not sent. After a save, the merged sha the gateway
7523
+ * answered decides the line: unchanged, or saved while an open session keeps the previous definition (the gateway
7524
+ * accepts a save while the agent is answering; the stale state then shows in the conversation header), or saved for
7525
+ * the next session. A refusal becomes a sentence and the editor keeps its state.
7526
+ */
7527
+ async function saveAgentEditor() {
7528
+ const editor = agentView.editor;
7529
+ if (!editor) return;
7530
+ const errors = validateEditor(editor);
7531
+ const errorEl = document.getElementById("pe-form-error");
7532
+ if (errors.length) {
7533
+ if (errorEl) errorEl.textContent = errors.map((e) => e.message).join(" \u00b7 ");
7534
+ showAgentStatus(t("agents.notSaved"), "error");
7535
+ return;
7536
+ }
7537
+ if (errorEl) errorEl.textContent = "";
7538
+ if (unchangedSource(editor)) {
7539
+ showAgentStatus(t("agents.saved.unchanged"), "ok");
7540
+ return;
7541
+ }
7542
+ const save = document.getElementById("pe-save");
7543
+ if (save) save.disabled = true;
7544
+ try {
7545
+ // A new agent: what waits for this draft is saved and its wire awaited first, so no draft request can follow the create
7546
+ // request that archives the draft; the gateway refuses a late one from another tab (draft_closed).
7547
+ if (editor.kind === "new") await draftAutosave.settle(editor.draftId);
7548
+ editorSaving = true;
7549
+ const body = await agentApi(saveRequest(editor)).finally(() => { editorSaving = false; });
7550
+ if (editor.kind === "defaults") {
7551
+ agentView.editor = editorState(body, "defaults");
7552
+ showAgentStatus(t("agents.defaultsSaved"), "ok");
7553
+ renderAgentEditor();
7554
+ return;
7555
+ }
7556
+ receiveAgentUpdate(body);
7557
+ if (editor.kind === "new") {
7558
+ agentView.drafts = agentView.drafts.filter((draft) => draft.id !== editor.draftId);
7559
+ // The files picked on the create screen that sent this request (captured with its editor state, todo 18) go out only now,
7560
+ // after the definition exists, into the created agent's own queue - never another screen's files (T18-B1). While that screen
7561
+ // is still the open one, the dialog moves to the new agent's editor, whose zone shows the progress, or closes when nothing
7562
+ // was picked; a screen that was closed or replaced meanwhile is left alone and the uploads run behind it (review fix B1).
7563
+ const queued = uploadsAfterCreate(body, editor.pendingUploads);
7564
+ const stillOpen = agentView.open && agentView.editor?.kind === "new" && agentView.editor.draftId === editor.draftId;
7565
+ systemLine(t("agents.created.line", { name: body.name }));
7566
+ if (!queued) {
7567
+ if (stillOpen) closeAgents();
7568
+ return;
7569
+ }
7570
+ if (stillOpen) await showAgentView("editor", queued.id);
7571
+ setUploadQueue(queued.id, queued.rows);
7572
+ if (stillOpen) showAgentStatus(t("agents.created.uploading", { name: body.name, count: queued.rows.filter((row) => row.status === "waiting").length }), "ok");
7573
+ void runAgentUploads(queued.id);
7574
+ return;
7575
+ }
7576
+ const outcome = saveOutcome(editor, body);
7577
+ agentView.editor = editorState(body, "agent");
7578
+ // The save answer's merged fields are the gateway's evaluation of the saved text; a preview still on the wire is superseded.
7579
+ capabilityPreview.discard();
7580
+ resetPreview(body.fields);
7581
+ showAgentStatus(t(!outcome.changed ? "agents.saved.unchanged" : outcome.stale ? "agents.saved" : "agents.saved.next"), "ok", applyNowTarget(outcome, body));
7582
+ renderAgentEditor();
7583
+ } catch (error) {
7584
+ showAgentStatus(saveErrorLine(error, editor.registry), "error");
7585
+ } finally {
7586
+ const button = document.getElementById("pe-save");
7587
+ if (button) button.disabled = false;
7588
+ }
7589
+ }
7590
+
5176
7591
  /** The open agent dialog redrawn from its state: the list, the archive (fetched again) or the editor (unsaved edits kept). */
5177
7592
  function rerenderAgentDialog() {
5178
7593
  if (!agentView.open) return;
@@ -5206,6 +7621,7 @@ function rerenderLanguage() {
5206
7621
  renderWorkStatusAll();
5207
7622
  renderTray();
5208
7623
  renderReferences();
7624
+ documents.render();
5209
7625
  renderDeviceRequests();
5210
7626
  if (!slash.el.hidden) renderSlashMenu();
5211
7627
  renderInboxBadge();
@@ -5232,4 +7648,12 @@ function rerenderLanguage() {
5232
7648
 
5233
7649
  onLanguageChange(rerenderLanguage);
5234
7650
 
7651
+ // Plan agent-ux todo 23: the first-run tour starts once per browser (localStorage zgw.tour.done); the settings button replays it.
7652
+ const tour = installTour(document, window, localStorage);
7653
+ $("tour-replay").onclick = () => {
7654
+ closeSettings();
7655
+ tour.start();
7656
+ };
7657
+ if (autoStartWanted(localStorage)) tour.start();
7658
+
5235
7659
  connect();