agello 0.3.0 → 0.4.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.
@@ -12,9 +12,6 @@
12
12
  // no-history do not persist chat in localStorage (default: persisted per server + pane)
13
13
  // screen show live terminal-browser screen panel (side by side, stacked when narrow)
14
14
  // with a toggle that lets the viewer control the browser (mouse, wheel, keyboard)
15
- // and an annotate toggle: hovering tints the element under the pointer, clicking
16
- // outlines it and opens an input; the request goes to the agent with the element's
17
- // CSS selector (action=annotate). The wheel still scrolls the page.
18
15
  //
19
16
  // Theming (CSS custom properties on the element or an ancestor)
20
17
  // --ab-bg --ab-panel --ab-text --ab-muted --ab-line --ab-accent --ab-user --ab-font
@@ -27,6 +24,12 @@
27
24
  // (tool calls are also kept in the conversation as one-line rows, like the terminal)
28
25
  // agent-screen detail: { connected, browser?, url?, title?, agentControlled? }
29
26
  // agent-present detail: { on, rect?: {x,y,w,h}, since? }
27
+ // agent-pane detail: { pane, server } another pane was picked in the drawer (server attribute changed)
28
+ //
29
+ // Pane picker
30
+ // Clicking the status in the header opens a drawer with herdr's workspace -> tab -> pane tree.
31
+ // Picking a pane switches to that pane's own server (started on demand). Workspaces, tabs and
32
+ // panes can be added there; nothing is closed from the page (closing kills the pane's processes).
30
33
  //
31
34
  // Presentation mode (started by the agent: `agello present [x,y,w,h]`)
32
35
  // The screen panel expands to fill the window and shows only the given
@@ -39,12 +42,9 @@
39
42
  // closing it without asking sends action=hand-lower so it can go on.
40
43
  // The viewer's questions show as bubbles too, with the same 10s lifetime.
41
44
  // An end button above it stops the presentation for every viewer.
42
- // Script lines (`agello script` + `present resume`) arrive as agent messages
43
- // with `script: "step.line"`. While the script is not playing and the agent
44
- // is working (e.g. answering a question), a small loader is shown.
45
45
  //
46
46
  // Methods
47
- // el.send(text, action = 'message', extra) -> Promise<{ok, error?}> (extra: e.g. {target} for annotate)
47
+ // el.send(text, action = 'message', images = [{type, data (base64)}]) -> Promise<{ok, error?}>
48
48
  // el.clearHistory() clear saved chat for the current pane
49
49
  // el.stopPresent() end presentation mode, tell the agent -> Promise<{ok, error?, notified?}>
50
50
 
@@ -75,10 +75,61 @@ const SCREEN_REASONS = {
75
75
  };
76
76
  const ACTIONS = {
77
77
  message: "메시지", approve: "승인", reject: "거절",
78
- "present-stop": "발표 종료", "hand-raise": "손들기", "hand-lower": "손 내림", annotate: "주석",
78
+ "present-stop": "발표 종료", "hand-raise": "손들기", "hand-lower": "손 내림",
79
79
  };
80
80
 
81
81
  const CSS = `
82
+ .pane-btn { display: inline-flex; align-items: center; gap: 7px; padding: 3px 10px; border-radius: 999px; font-size: 12px;
83
+ color: var(--_muted); max-width: 46cqi; }
84
+ .pane-btn .state { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
85
+ .pane-btn .dot { flex: none; }
86
+ .drawer-backdrop { display: none; position: absolute; inset: 0; z-index: 20; background: rgb(0 0 0 / .25); }
87
+ .drawer { position: absolute; top: 0; right: 0; bottom: 0; z-index: 21; width: min(360px, 92%); display: flex;
88
+ flex-direction: column; background: var(--_panel); border-left: 1px solid var(--_line);
89
+ box-shadow: -8px 0 24px rgb(0 0 0 / .18); transform: translateX(100%); visibility: hidden;
90
+ transition: transform .18s ease, visibility 0s .18s; }
91
+ :host([panes-open]) .drawer { transform: none; visibility: visible; transition: transform .18s ease; }
92
+ :host([panes-open]) .drawer-backdrop { display: block; }
93
+ .drawer-head { display: flex; gap: 6px; align-items: center; padding: 10px 12px; border-bottom: 1px solid var(--_line); }
94
+ .drawer-head strong { font-size: 13px; margin-right: auto; }
95
+ .icon-btn { padding: 1px 8px; border-radius: 6px; font-size: 14px; line-height: 1.5; }
96
+ .pane-filter { width: 100%; padding: 5px 9px; font: inherit; font-size: 12px; border: 1px solid var(--_line);
97
+ border-radius: 7px; background: var(--_bg); color: var(--_text); }
98
+ .drawer-search { padding: 8px 12px; border-bottom: 1px solid var(--_line); }
99
+ .add-form { display: grid; gap: 6px; padding: 10px 12px; border-bottom: 1px solid var(--_line); font-size: 12px; }
100
+ .add-form[hidden] { display: none; }
101
+ .add-form .add-title { font-weight: 600; }
102
+ .add-form input, .add-form select { padding: 4px 8px; font: inherit; border: 1px solid var(--_line); border-radius: 6px;
103
+ background: var(--_bg); color: var(--_text); }
104
+ .add-form .add-row { display: flex; gap: 6px; justify-content: flex-end; }
105
+ .tree { flex: 1; overflow: auto; padding: 6px 0; font-size: 13px; }
106
+ .tree-row { display: flex; align-items: center; gap: 6px; padding: 4px 10px; cursor: pointer; min-width: 0; }
107
+ .tree-row:hover { background: var(--_user); }
108
+ .tree-row .name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
109
+ .tree-row .meta { font-size: 11px; color: var(--_muted); flex: none; }
110
+ .tree-row .caret { width: 12px; flex: none; color: var(--_muted); font-size: 10px; }
111
+ .tree-row .row-add { visibility: hidden; padding: 0 6px; font-size: 12px; border-radius: 5px; flex: none; }
112
+ .tree-row:hover .row-add, .tree-row:focus-within .row-add { visibility: visible; }
113
+ .tree-row.ws .name { font-weight: 600; }
114
+ .tree-row.tab { padding-left: 26px; color: var(--_muted); }
115
+ .tree-row.pane { padding-left: 30px; }
116
+ .tree-row.pane.nested { padding-left: 46px; }
117
+ .tree-row.pane.current { background: color-mix(in srgb, var(--_accent) 16%, transparent); }
118
+ .tree-row .badge { font-size: 10px; padding: 0 6px; border-radius: 999px; border: 1px solid var(--_line); color: var(--_muted); flex: none; }
119
+ .tree-empty, .drawer-hint { padding: 8px 12px; font-size: 12px; color: var(--_muted); }
120
+ .drawer-hint:empty { display: none; }
121
+ .term-btn { display: inline-flex; align-items: center; justify-content: center; padding: 3px 7px; border-radius: 999px; }
122
+ .term-btn svg { width: 14px; height: 14px; }
123
+ .term-btn[aria-pressed="true"] { background: var(--_accent); border-color: var(--_accent); color: white; }
124
+ .terminal-panel { display: none; flex: 1; min-height: 0; flex-direction: column; background: #101114; }
125
+ :host([terminal-open]) .chat > .stage, :host([terminal-open]) .chat > footer { display: none; }
126
+ :host([terminal-open]) .terminal-panel { display: flex; }
127
+ .terminal-bar { display: flex; gap: 8px; align-items: center; padding: 6px 12px; border-bottom: 1px solid #26272b; }
128
+ .terminal-status { flex: 1; font-size: 12px; color: #a1a1aa; overflow-wrap: anywhere; }
129
+ .terminal-retry { font-size: 11px; padding: 2px 9px; border-radius: 999px; }
130
+ .terminal-host { flex: 1; min-height: 0; padding: 8px 12px; overflow: hidden; }
131
+ .terminal-host textarea { min-height: 0; border: 0; padding: 0; resize: none; }
132
+
82
133
  :host {
83
134
  --_bg: var(--ab-bg, #f6f6f4); --_panel: var(--ab-panel, #fff); --_text: var(--ab-text, #1f1f1f);
84
135
  --_muted: var(--ab-muted, #6b6b6b); --_line: var(--ab-line, #e4e4e0); --_accent: var(--ab-accent, #2f6fed);
@@ -103,7 +154,6 @@ header { display: flex; align-items: center; gap: 10px; padding: 10px 16px; bord
103
154
  background: var(--_panel); }
104
155
  :host([no-header]) header { display: none; }
105
156
  h1 { font-size: 14px; margin: 0; font-weight: 600; }
106
- .clear-btn { font-size: 11px; padding: 2px 9px; border-radius: 999px; }
107
157
  .session { margin-left: auto; display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--_muted); }
108
158
  .dot { width: 9px; height: 9px; border-radius: 50%; background: var(--_muted); }
109
159
  .dot.idle, .dot.done { background: var(--_ok); } .dot.working { background: var(--_busy); }
@@ -178,30 +228,6 @@ h1 { font-size: 14px; margin: 0; font-weight: 600; }
178
228
  @media (prefers-reduced-motion: reduce) { .control-btn .track, .control-btn .knob { transition: none; } }
179
229
  .placeholder { font-size: 13px; color: var(--_muted); text-align: center; }
180
230
 
181
- /* annotate: hover tint, selected outline, request input next to the element */
182
- .annot-btn { flex: none; font-size: 12px; padding: 2px 10px; border-radius: 999px; }
183
- .annot-btn[aria-pressed="true"] { background: var(--_accent); border-color: var(--_accent); color: #fff; }
184
- .viewport.annot img { cursor: crosshair; }
185
- .hl { position: absolute; z-index: 3; pointer-events: none; display: none; border-radius: 2px; }
186
- .hl.on { display: block; }
187
- .hl.hover { background: color-mix(in srgb, var(--_accent) 22%, transparent);
188
- box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--_accent) 55%, transparent); }
189
- .hl.sel { box-shadow: 0 0 0 2px var(--_accent), 0 0 0 5px color-mix(in srgb, var(--_accent) 25%, transparent); }
190
- .hl-label { position: absolute; z-index: 4; pointer-events: none; display: none; max-width: 60%;
191
- padding: 2px 7px; border-radius: 5px; font: 11px/1.5 ui-monospace, Menlo, monospace; color: #fff;
192
- background: rgba(20, 20, 22, .85); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
193
- .hl-label.on { display: block; }
194
- .note { position: absolute; z-index: 5; width: min(340px, calc(100% - 16px)); padding: 10px; border-radius: 12px;
195
- background: var(--_panel); border: 1px solid var(--_line); box-shadow: 0 8px 28px rgba(0,0,0,.22);
196
- animation: capIn .18s ease; }
197
- .note[hidden] { display: none; }
198
- .note .note-target { font: 11px/1.4 ui-monospace, Menlo, monospace; color: var(--_muted); margin-bottom: 6px;
199
- overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
200
- .note textarea { min-height: 52px; font-size: 14px; }
201
- .note .note-row { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
202
- .note .note-hint { flex: 1; font-size: 12px; color: var(--_muted); }
203
- @media (prefers-reduced-motion: reduce) { .note { animation: none; } }
204
-
205
231
  /* presentation mode: the screen panel covers the window, chat stays hidden underneath.
206
232
  container-type would make the host the containing block of the fixed layer, so drop it. */
207
233
  :host([presenting]) { container-type: normal; }
@@ -229,14 +255,6 @@ h1 { font-size: 14px; margin: 0; font-weight: 600; }
229
255
  .caption a { color: inherit; }
230
256
  @keyframes capIn { from { opacity: 0; translate: 0 10px; } }
231
257
 
232
- /* agent busy while presenting (answering, fixing the script): minimal loader */
233
- .busy { display: none; }
234
- .body.present .busy.on { position: absolute; left: 22px; bottom: 28px; z-index: 5; display: flex; align-items: center;
235
- gap: 9px; padding: 9px 14px; border-radius: 999px; font-size: 13px; color: #fff; background: rgba(20, 20, 22, .72);
236
- -webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px); box-shadow: 0 8px 30px rgba(0,0,0,.35);
237
- animation: capIn .25s ease; }
238
- .busy .spinner { border-color: rgba(255,255,255,.25); border-top-color: #fff; }
239
-
240
258
  /* raise hand: ask the agent mid-presentation */
241
259
  .hand-wrap { display: none; }
242
260
  .body.present .hand-wrap { position: absolute; right: 22px; bottom: 28px; z-index: 5; display: flex;
@@ -341,18 +359,38 @@ button.primary { background: var(--_accent); border-color: var(--_accent); color
341
359
  button:disabled { cursor: not-allowed; opacity: .45; }
342
360
  :host([no-actions]) .secondary { display: none; }
343
361
  .hint { font-size: 12px; color: var(--_muted); }
362
+ .attachments { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
363
+ .attachments:empty { display: none; }
364
+ .attachment { position: relative; width: 64px; height: 64px; border: 1px solid var(--_line); border-radius: 8px; overflow: hidden; }
365
+ .attachment img { width: 100%; height: 100%; object-fit: cover; display: block; }
366
+ .attachment button { position: absolute; top: 2px; right: 2px; padding: 0 6px; font-size: 12px; line-height: 1.5;
367
+ border-radius: 999px; background: rgb(0 0 0 / .6); border: 0; color: #fff; }
368
+ .composer.dragover textarea { outline: 2px dashed var(--_accent); outline-offset: -1px; }
344
369
  `;
345
370
 
346
371
  const TEMPLATE = `
347
- <header><h1 part="heading"></h1><div class="session"><button class="clear-btn" title="이 pane의 저장된 대화 삭제">기록 지우기</button><span class="dot"></span><span class="state">연결 중</span></div></header>
372
+ <header><h1 part="heading"></h1><div class="session"><button class="term-btn" aria-pressed="false" title="터미널에서 실행 중인 세션 보기 (다시 누르면 채팅)" aria-label="터미널"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="1.5" y="2.5" width="13" height="11" rx="2"/><path d="M4.5 6l2 2-2 2M8.5 10.5h3"/></svg></button><button class="pane-btn" aria-haspopup="dialog" aria-expanded="false" title="pane 선택"><span class="dot"></span><span class="state">연결 중</span><span aria-hidden="true">▾</span></button></div></header>
373
+ <div class="drawer-backdrop"></div>
374
+ <aside class="drawer" role="dialog" aria-label="pane 선택">
375
+ <div class="drawer-head"><strong>pane 선택</strong><button class="icon-btn ws-add" title="workspace 추가" aria-label="workspace 추가">+</button><button class="icon-btn panes-refresh" title="새로고침" aria-label="새로고침">↻</button><button class="icon-btn drawer-close" title="닫기 (Esc)" aria-label="닫기">×</button></div>
376
+ <div class="drawer-search"><input class="pane-filter" type="search" placeholder="workspace, 제목, 폴더 검색" aria-label="pane 검색"></div>
377
+ <form class="add-form" hidden><span class="add-title"></span>
378
+ <input name="label" placeholder="이름 (선택)" maxlength="80">
379
+ <input name="cwd" placeholder="폴더 경로 (선택, 절대 경로)">
380
+ <select name="direction" aria-label="분할 방향"><option value="right">오른쪽으로 분할</option><option value="down">아래로 분할</option></select>
381
+ <div class="add-row"><button type="button" class="add-cancel">취소</button><button type="submit" class="primary">추가</button></div></form>
382
+ <div class="tree" role="tree"></div><div class="drawer-hint" role="status"></div>
383
+ </aside>
348
384
  <div class="body"><section class="chat">
385
+ <div class="terminal-panel" aria-label="터미널"><div class="terminal-bar"><span class="terminal-status" role="status"></span><button class="terminal-retry">다시 연결</button></div><div class="terminal-host"></div></div>
349
386
  <div class="stage">
350
387
  <div class="log" part="log"><div class="inner"><div class="empty">이 pane의 대화가 브라우저에 저장돼요.</div></div><div class="inner queue" part="queue"></div></div>
351
388
  <div class="activity" part="activity"></div>
352
389
  </div>
353
390
  <footer part="composer"><div class="composer">
354
391
  <div class="grip" role="separator" aria-orientation="horizontal" aria-label="입력창 높이 조절 (위아래 방향키)" tabindex="0" title="끌어서 입력창 높이 조절"></div>
355
- <textarea placeholder="에이전트에게 보낼 메시지 (Enter 전송, Shift+Enter 줄바꿈)"></textarea>
392
+ <textarea placeholder="에이전트에게 보낼 메시지 (Enter 전송, Shift+Enter 줄바꿈, 이미지 붙여넣기·끌어놓기)"></textarea>
393
+ <div class="attachments" aria-label="첨부 이미지"></div>
356
394
  <div class="row">
357
395
  <button class="secondary" data-action="approve">승인</button>
358
396
  <button class="secondary" data-action="reject">거절</button>
@@ -363,13 +401,9 @@ const TEMPLATE = `
363
401
  </section>
364
402
  <aside class="screen" part="screen">
365
403
  <div class="screen-bar"><span class="live" hidden>에이전트 조작 중</span><span class="url"></span>
366
- <button class="annot-btn" type="button" aria-pressed="false" title="화면의 요소를 골라 에이전트에게 요청 (선택자와 함께 전달)">주석</button>
367
404
  <button class="control-btn" role="switch" aria-checked="false" title="켜면 마우스와 키보드 입력이 브라우저로 전달돼요 (끄기: Shift+Esc)">
368
405
  <span>내 조작</span><span class="track"><span class="knob"></span></span><span class="state-label">OFF</span></button></div>
369
- <div class="viewport"><div class="ring"></div><div class="glow"></div><div class="agent-badge">에이전트 조작 중</div><img alt="에이전트 브라우저 화면" draggable="false"><textarea class="kbd" aria-label="브라우저 키보드 입력"></textarea><div class="placeholder">열린 terminal-browser 없음</div><div class="hl hover"></div><div class="hl sel"></div><div class="hl-label"></div>
370
- <form class="note" part="note" hidden><div class="note-target"></div><textarea aria-label="선택한 요소에 대한 요청" placeholder="이 요소에 대한 요청 (Enter 전송, Esc 취소)"></textarea>
371
- <div class="note-row"><span class="note-hint"></span><button type="submit" class="primary">보내기</button></div></form><div class="captions" part="captions" aria-live="polite"></div>
372
- <div class="busy" part="busy" role="status"><span class="spinner"></span><span>답변 준비 중</span></div>
406
+ <div class="viewport"><div class="ring"></div><div class="glow"></div><div class="agent-badge">에이전트 조작 중</div><img alt="에이전트 브라우저 화면" draggable="false"><textarea class="kbd" aria-label="브라우저 키보드 입력"></textarea><div class="placeholder">열린 terminal-browser 없음</div><div class="captions" part="captions" aria-live="polite"></div>
373
407
  <div class="hand-wrap">
374
408
  <form class="ask" part="ask" hidden><textarea aria-label="에이전트에게 질문" placeholder="에이전트에게 질문 (Enter 전송, Esc 닫기)"></textarea>
375
409
  <div class="ask-row"><span class="ask-hint"></span><button type="submit" class="primary">보내기</button></div></form>
@@ -388,14 +422,16 @@ class AgentBridge extends HTMLElement {
388
422
  #root = this.attachShadow({ mode: "open" });
389
423
  #es = null;
390
424
  #screenEs = null;
391
- #ws = null; // /input: control input and annotate inspect queries
392
- #control = false;
393
- #annot = false;
394
- #hover = null; // inspected element under the pointer (annotate)
395
- #hoverPt = null;
396
- #sel = null; // selected element (annotate)
397
- #inspectId = 0;
398
- #inspectWait = new Map();
425
+ #controlWs = null;
426
+ #terminalCleanup = null;
427
+ #terminalVersion = 0;
428
+ #terminalStyles = null;
429
+ #panes = null; // {current, workspaces}
430
+ #expanded = new Set();
431
+ #adding = null; // {kind, workspace?, pane?}
432
+ #images = []; // chat attachments: {type, data (base64), url (preview)}
433
+ static IMAGE_MAX = 5;
434
+ static IMAGE_BYTES = 10 * 1024 * 1024;
399
435
  #frame = { w: 0, h: 0 };
400
436
  #moveQueued = null;
401
437
  #status = null;
@@ -417,11 +453,9 @@ class AgentBridge extends HTMLElement {
417
453
  activity: q(".activity"), box: q("textarea"), hint: q(".hint"),
418
454
  buttons: this.#root.querySelectorAll("button[data-action]"),
419
455
  live: q(".live"), url: q(".screen-bar .url"), viewport: q(".viewport"), img: q(".viewport img"),
420
- controlBtn: q(".control-btn"), kbd: q(".kbd"), clearBtn: q(".clear-btn"),
456
+ controlBtn: q(".control-btn"), kbd: q(".kbd"),
421
457
  body: q(".body"), screen: q(".screen"), captions: q(".captions"),
422
- busy: q(".busy"), hand: q(".hand"), end: q(".end"), ask: q(".ask"), askBox: q(".ask textarea"), askHint: q(".ask-hint"),
423
- annotBtn: q(".annot-btn"), hlHover: q(".hl.hover"), hlSel: q(".hl.sel"), hlLabel: q(".hl-label"),
424
- note: q(".note"), noteBox: q(".note textarea"), noteHint: q(".note-hint"), noteTarget: q(".note-target"),
458
+ hand: q(".hand"), end: q(".end"), ask: q(".ask"), askBox: q(".ask textarea"), askHint: q(".ask-hint"),
425
459
  };
426
460
  this.$.buttons.forEach((b) => b.addEventListener("click", () => this.#submit(b.dataset.action)));
427
461
  this.#bindGrip();
@@ -433,9 +467,11 @@ class AgentBridge extends HTMLElement {
433
467
  });
434
468
  loadMarkdown().then((md) => (this.#md = md));
435
469
  this.#bindControl();
436
- this.#bindAnnotate();
437
470
  this.#bindHand();
438
- this.$.clearBtn.addEventListener("click", () => this.clearHistory());
471
+ q(".term-btn").addEventListener("click", () => this.#showTerminal(!this.hasAttribute("terminal-open")));
472
+ q(".terminal-retry").addEventListener("click", () => this.#showTerminal(true));
473
+ this.#bindPanes();
474
+ this.#bindImages();
439
475
  }
440
476
 
441
477
  get server() {
@@ -448,25 +484,300 @@ class AgentBridge extends HTMLElement {
448
484
  this.#syncScreen();
449
485
  }
450
486
  disconnectedCallback() {
487
+ this.#showTerminal(false);
451
488
  this.#es?.close();
452
489
  this.#es = null;
453
490
  this.#screenEs?.close();
454
491
  this.#screenEs = null;
455
492
  this.#setControl(false);
456
- this.#setAnnotate(false);
457
493
  }
458
494
  attributeChangedCallback(name) {
459
495
  if (name === "heading") this.$.heading.textContent = this.getAttribute("heading") || "agello";
460
- if (name === "server" && this.isConnected) { this.#connect(); this.#screenEs?.close(); this.#screenEs = null; this.#syncScreen(); }
496
+ if (name === "server" && this.isConnected) { this.#showTerminal(false); this.#openPanes(false); this.#connect(); this.#screenEs?.close(); this.#screenEs = null; this.#syncScreen(); }
461
497
  if (name === "screen" && this.isConnected) this.#syncScreen();
462
498
  }
463
499
 
464
- async send(text, action = "message", extra = {}) {
500
+ async #showTerminal(on) {
501
+ const version = ++this.#terminalVersion;
502
+ this.#terminalCleanup?.();
503
+ this.#terminalCleanup = null;
504
+ this.toggleAttribute("terminal-open", on);
505
+ const q = (s) => this.#root.querySelector(s);
506
+ q(".term-btn").setAttribute("aria-pressed", String(on));
507
+ if (!on) return;
508
+ this.#setControl(false);
509
+ q(".terminal-status").textContent = "터미널 로딩 중…";
510
+ try {
511
+ if (!this.#terminalStyles) {
512
+ const link = document.createElement("link");
513
+ link.rel = "stylesheet";
514
+ link.href = `${SCRIPT_ORIGIN}/terminal.css`;
515
+ link.dataset.terminalCss = "";
516
+ this.#terminalStyles = new Promise((resolve, reject) => {
517
+ link.onload = resolve;
518
+ link.onerror = () => { this.#terminalStyles = null; link.remove(); reject(new Error("Stylesheet failed")); };
519
+ });
520
+ this.#root.append(link);
521
+ }
522
+ await this.#terminalStyles;
523
+ const { mountTerminal } = await import(`${SCRIPT_ORIGIN}/terminal.js`);
524
+ if (version !== this.#terminalVersion || !this.isConnected) return;
525
+ this.#terminalCleanup = mountTerminal(q(".terminal-host"), q(".terminal-status"), this.server);
526
+ } catch {
527
+ if (version === this.#terminalVersion) q(".terminal-status").textContent = "터미널을 불러오지 못했습니다. 다시 연결해 주세요.";
528
+ }
529
+ }
530
+
531
+ // ---------- pane picker (herdr workspace -> tab -> pane) ----------
532
+
533
+ #bindPanes() {
534
+ const q = (s) => this.#root.querySelector(s);
535
+ q(".pane-btn").addEventListener("click", () => this.#openPanes(!this.hasAttribute("panes-open")));
536
+ q(".drawer-close").addEventListener("click", () => this.#openPanes(false));
537
+ q(".drawer-backdrop").addEventListener("click", () => this.#openPanes(false));
538
+ q(".panes-refresh").addEventListener("click", () => this.#loadPanes());
539
+ q(".ws-add").addEventListener("click", () => this.#startAdd({ kind: "workspace" }));
540
+ q(".pane-filter").addEventListener("input", () => this.#renderPanes());
541
+ q(".add-cancel").addEventListener("click", () => this.#startAdd(null));
542
+ q(".add-form").addEventListener("submit", (e) => { e.preventDefault(); this.#submitAdd(); });
543
+ q(".drawer").addEventListener("keydown", (e) => {
544
+ if (e.key === "Escape") { e.stopPropagation(); this.#adding ? this.#startAdd(null) : this.#openPanes(false); }
545
+ });
546
+ q(".tree").addEventListener("click", (e) => {
547
+ const add = e.target.closest(".row-add");
548
+ const row = e.target.closest(".tree-row");
549
+ if (!row) return;
550
+ const { kind, id, ws } = row.dataset;
551
+ if (add) return this.#startAdd(kind === "ws" ? { kind: "tab", workspace: id } : { kind: "pane", pane: id, workspace: ws });
552
+ if (kind === "pane") return this.#selectPane(id);
553
+ this.#expanded.has(id) ? this.#expanded.delete(id) : this.#expanded.add(id);
554
+ this.#renderPanes();
555
+ });
556
+ q(".tree").addEventListener("keydown", (e) => {
557
+ if ((e.key === "Enter" || e.key === " ") && e.target.classList.contains("tree-row")) { e.preventDefault(); e.target.click(); }
558
+ });
559
+ }
560
+
561
+ #openPanes(on) {
562
+ if (on === this.hasAttribute("panes-open")) return;
563
+ this.toggleAttribute("panes-open", on);
564
+ const q = (s) => this.#root.querySelector(s);
565
+ q(".pane-btn").setAttribute("aria-expanded", String(on));
566
+ if (!on) { this.#startAdd(null); q(".pane-btn").focus(); return; }
567
+ q(".pane-filter").value = "";
568
+ this.#loadPanes(true);
569
+ q(".pane-filter").focus();
570
+ }
571
+
572
+ async #loadPanes(expandCurrent = false) {
573
+ const hint = this.#root.querySelector(".drawer-hint");
574
+ hint.textContent = "불러오는 중…";
575
+ try {
576
+ const res = await fetch(`${this.server}/panes`);
577
+ const d = await res.json();
578
+ if (!d.ok) throw new Error(d.error);
579
+ this.#panes = d;
580
+ hint.textContent = "";
581
+ if (expandCurrent) {
582
+ const ws = d.workspaces.find((w) => w.tabs.some((t) => t.panes.some((p) => p.id === d.current)));
583
+ const tab = ws?.tabs.find((t) => t.panes.some((p) => p.id === d.current));
584
+ if (ws) this.#expanded.add(ws.id);
585
+ if (tab) this.#expanded.add(tab.id);
586
+ }
587
+ } catch {
588
+ hint.textContent = "pane 목록을 불러오지 못했습니다.";
589
+ }
590
+ this.#renderPanes();
591
+ }
592
+
593
+ #renderPanes() {
594
+ const tree = this.#root.querySelector(".tree");
595
+ const d = this.#panes;
596
+ if (!d) { tree.replaceChildren(); return; }
597
+ const filter = this.#root.querySelector(".pane-filter").value.trim().toLowerCase();
598
+ const hit = (...xs) => !filter || xs.some((x) => String(x ?? "").toLowerCase().includes(filter));
599
+ const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; };
600
+ const row = (kind, id, cls, level, expanded) => {
601
+ const r = el("div", `tree-row ${cls}`);
602
+ Object.assign(r.dataset, { kind, id });
603
+ r.tabIndex = 0;
604
+ r.setAttribute("role", "treeitem");
605
+ r.setAttribute("aria-level", String(level));
606
+ if (expanded !== undefined) r.setAttribute("aria-expanded", String(expanded));
607
+ return r;
608
+ };
609
+ const addBtn = (label) => { const b = el("button", "row-add", "+"); b.type = "button"; b.title = label; b.setAttribute("aria-label", label); return b; };
610
+ const base = (p) => p.cwd?.split("/").filter(Boolean).pop();
611
+ const rows = [];
612
+ for (const ws of d.workspaces) {
613
+ const tabs = ws.tabs.map((t) => ({ ...t, panes: t.panes.filter((p) => hit(ws.label, t.label, p.id, p.agent, p.title, p.cwd)) }))
614
+ .filter((t) => t.panes.length);
615
+ if (filter && !tabs.length) continue;
616
+ const open = !!filter || this.#expanded.has(ws.id);
617
+ const r = row("ws", ws.id, "ws", 1, open);
618
+ const count = ws.tabs.reduce((n, t) => n + t.panes.length, 0);
619
+ r.append(el("span", "caret", open ? "▾" : "▸"), el("span", "name", ws.label || ws.id), el("span", "meta", String(count)), addBtn("tab 추가"));
620
+ rows.push(r);
621
+ if (!open) continue;
622
+ const single = ws.tabs.length === 1; // D5: a lone tab adds no information
623
+ for (const t of tabs) {
624
+ const tabOpen = single || !!filter || this.#expanded.has(t.id);
625
+ if (!single) {
626
+ const tr = row("tab", t.id, "tab", 2, tabOpen);
627
+ tr.append(el("span", "caret", tabOpen ? "▾" : "▸"), el("span", "name", `tab ${t.label}`), el("span", "meta", String(t.panes.length)));
628
+ rows.push(tr);
629
+ }
630
+ if (!tabOpen) continue;
631
+ for (const p of t.panes) {
632
+ const pr = row("pane", p.id, `pane${single ? "" : " nested"}${p.id === d.current ? " current" : ""}`, single ? 2 : 3);
633
+ pr.dataset.ws = ws.id;
634
+ if (p.id === d.current) pr.setAttribute("aria-current", "true");
635
+ const dot = el("span", `dot ${p.status}`);
636
+ dot.title = STATES[p.status] || p.status;
637
+ const name = el("span", "name", p.title || base(p) || p.id);
638
+ name.title = [p.title, p.cwd, p.id].filter(Boolean).join("\n");
639
+ const badge = el("span", "badge", p.agent || "셸");
640
+ if (p.agent !== "claude") badge.title = "채팅 미지원: 터미널로 연결";
641
+ pr.append(dot, name, badge, addBtn("pane 분할"));
642
+ rows.push(pr);
643
+ }
644
+ }
645
+ }
646
+ tree.replaceChildren(...rows);
647
+ if (!rows.length) tree.append(el("div", "tree-empty", filter ? "검색 결과 없음" : "pane 없음"));
648
+ }
649
+
650
+ #startAdd(target) {
651
+ this.#adding = target;
652
+ const form = this.#root.querySelector(".add-form");
653
+ form.hidden = !target;
654
+ if (!target) return;
655
+ form.reset();
656
+ const titles = { workspace: "새 workspace", tab: "새 tab", pane: "pane 분할" };
657
+ form.querySelector(".add-title").textContent = titles[target.kind];
658
+ form.elements.label.hidden = target.kind === "pane";
659
+ form.elements.direction.hidden = target.kind !== "pane";
660
+ (target.kind === "pane" ? form.elements.direction : form.elements.label).focus();
661
+ }
662
+
663
+ async #submitAdd() {
664
+ const target = this.#adding;
665
+ if (!target) return;
666
+ const f = this.#root.querySelector(".add-form").elements;
667
+ const hint = this.#root.querySelector(".drawer-hint");
668
+ const body = { ...target, cwd: f.cwd.value.trim() || undefined };
669
+ if (target.kind === "pane") body.direction = f.direction.value;
670
+ else body.label = f.label.value.trim() || undefined;
671
+ if (body.cwd && !body.cwd.startsWith("/")) { hint.textContent = "폴더 경로는 /로 시작하는 절대 경로여야 합니다."; return; }
672
+ hint.textContent = "만드는 중…";
673
+ try {
674
+ const res = await fetch(`${this.server}/panes/create`, {
675
+ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
676
+ });
677
+ const d = await res.json();
678
+ if (!d.ok) throw new Error(d.error);
679
+ this.#startAdd(null);
680
+ if (target.workspace) this.#expanded.add(target.workspace);
681
+ await this.#selectPane(d.pane, { terminal: true }); // a new pane is a plain shell: show it as a terminal
682
+ } catch (e) {
683
+ hint.textContent = `추가 실패: ${e.message || "server_unreachable"}`;
684
+ }
685
+ }
686
+
687
+ async #selectPane(pane, { terminal = false } = {}) {
688
+ const hint = this.#root.querySelector(".drawer-hint");
689
+ if (pane === this.#panes?.current && !terminal) { this.#openPanes(false); return; }
690
+ hint.textContent = "연결 중…";
691
+ try {
692
+ const res = await fetch(`${this.server}/panes/connect`, {
693
+ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ pane }),
694
+ });
695
+ const d = await res.json();
696
+ if (!d.ok) throw new Error(d.error);
697
+ const info = this.#panes?.workspaces.flatMap((w) => w.tabs.flatMap((t) => t.panes)).find((p) => p.id === pane);
698
+ hint.textContent = "";
699
+ this.#openPanes(false);
700
+ if (d.url !== this.server) this.setAttribute("server", d.url);
701
+ this.#emit("agent-pane", { pane, server: d.url });
702
+ if (terminal || (info && info.agent !== "claude")) this.#showTerminal(true); // D8: chat is Claude-only
703
+ } catch (e) {
704
+ hint.textContent = `연결 실패: ${e.message || "server_unreachable"}`;
705
+ }
706
+ }
707
+
708
+ // ---------- image attachments (paste / drop into the chat box) ----------
709
+
710
+ #bindImages() {
711
+ const composer = this.#root.querySelector(".composer");
712
+ const images = (list) => [...(list ?? [])].filter((f) => f.type?.startsWith("image/"));
713
+ this.$.box.addEventListener("paste", (e) => {
714
+ const files = images([...(e.clipboardData?.items ?? [])].filter((i) => i.kind === "file").map((i) => i.getAsFile()));
715
+ if (!files.length) return;
716
+ if (!e.clipboardData.getData("text/plain")) e.preventDefault();
717
+ this.#addImages(files);
718
+ });
719
+ composer.addEventListener("dragover", (e) => {
720
+ if (![...(e.dataTransfer?.items ?? [])].some((i) => i.kind === "file")) return;
721
+ e.preventDefault();
722
+ composer.classList.add("dragover");
723
+ });
724
+ composer.addEventListener("dragleave", (e) => { if (!composer.contains(e.relatedTarget)) composer.classList.remove("dragover"); });
725
+ composer.addEventListener("drop", (e) => {
726
+ composer.classList.remove("dragover");
727
+ const files = images(e.dataTransfer?.files);
728
+ if (!files.length) return;
729
+ e.preventDefault();
730
+ this.#addImages(files);
731
+ });
732
+ this.#root.querySelector(".attachments").addEventListener("click", (e) => {
733
+ const i = e.target.closest("[data-remove]")?.dataset.remove;
734
+ if (i === undefined) return;
735
+ URL.revokeObjectURL(this.#images[i].url);
736
+ this.#images.splice(Number(i), 1);
737
+ this.#renderImages();
738
+ });
739
+ }
740
+
741
+ async #addImages(files) {
742
+ const ok = ["image/png", "image/jpeg", "image/gif", "image/webp"];
743
+ for (const f of files) {
744
+ if (this.#images.length >= AgentBridge.IMAGE_MAX) { this.$.hint.textContent = `이미지는 최대 ${AgentBridge.IMAGE_MAX}개`; break; }
745
+ if (!ok.includes(f.type)) { this.$.hint.textContent = "PNG, JPEG, GIF, WebP만 첨부 가능"; continue; }
746
+ if (f.size > AgentBridge.IMAGE_BYTES) { this.$.hint.textContent = "이미지 1개당 최대 10MB"; continue; }
747
+ const data = await new Promise((resolve, reject) => {
748
+ const r = new FileReader();
749
+ r.onload = () => resolve(String(r.result).split(",")[1]);
750
+ r.onerror = reject;
751
+ r.readAsDataURL(f);
752
+ });
753
+ this.#images.push({ type: f.type, data, url: URL.createObjectURL(f) });
754
+ }
755
+ this.#renderImages();
756
+ }
757
+
758
+ #renderImages() {
759
+ this.#root.querySelector(".attachments").replaceChildren(...this.#images.map((img, i) => {
760
+ const wrap = document.createElement("div");
761
+ wrap.className = "attachment";
762
+ const pic = document.createElement("img");
763
+ pic.src = img.url;
764
+ pic.alt = `첨부 이미지 ${i + 1}`;
765
+ const rm = document.createElement("button");
766
+ rm.type = "button";
767
+ rm.textContent = "×";
768
+ rm.dataset.remove = String(i);
769
+ rm.setAttribute("aria-label", `첨부 이미지 ${i + 1} 삭제`);
770
+ wrap.append(pic, rm);
771
+ return wrap;
772
+ }));
773
+ }
774
+
775
+ async send(text, action = "message", images = []) {
465
776
  try {
466
777
  const res = await fetch(`${this.server}/send`, {
467
778
  method: "POST",
468
779
  headers: { "Content-Type": "application/json" },
469
- body: JSON.stringify({ ...extra, action, text }),
780
+ body: JSON.stringify({ action, text, images: images.length ? images : undefined }),
470
781
  });
471
782
  return await res.json();
472
783
  } catch {
@@ -505,8 +816,7 @@ class AgentBridge extends HTMLElement {
505
816
  on("message", (m) => {
506
817
  this.#addMessage(m);
507
818
  this.#saveMessage(m);
508
- // script lines carry their own on-screen time (>= 10s), others use CAPTION_MS
509
- if (this.#present.on && m.role === "assistant") this.#caption(m.text, "agent", m.hold * 1000 || undefined);
819
+ if (this.#present.on && m.role === "assistant") this.#caption(m.text);
510
820
  if (this.#present.on && m.role === "browser" && m.action === "message") this.#caption(m.text, "user");
511
821
  this.#emit("agent-message", m);
512
822
  });
@@ -532,7 +842,7 @@ class AgentBridge extends HTMLElement {
532
842
 
533
843
  #syncScreen() {
534
844
  const want = this.hasAttribute("screen") || this.#present.on;
535
- if (!want) { this.#screenEs?.close(); this.#screenEs = null; this.#setControl(false); this.#setAnnotate(false); return; }
845
+ if (!want) { this.#screenEs?.close(); this.#screenEs = null; this.#setControl(false); return; }
536
846
  if (this.#screenEs) return;
537
847
  const es = (this.#screenEs = new EventSource(`${this.server}/screen`));
538
848
  es.addEventListener("meta", (e) => this.#renderScreenMeta(JSON.parse(e.data)));
@@ -541,7 +851,6 @@ class AgentBridge extends HTMLElement {
541
851
  this.#frame = { w: f.w, h: f.h };
542
852
  this.$.img.src = `data:image/jpeg;base64,${f.data}`;
543
853
  this.$.viewport.classList.add("has");
544
- if (this.#annot) this.#annotFrame();
545
854
  });
546
855
  es.onerror = () => this.#renderScreenMeta({ connected: false });
547
856
  }
@@ -553,9 +862,8 @@ class AgentBridge extends HTMLElement {
553
862
  const was = this.#present.on;
554
863
  this.#present = p ?? { on: false };
555
864
  this.#emit("agent-present", this.#present);
556
- this.#renderBusy();
557
865
  if (on === was) return;
558
- if (on) { this.#setControl(false); this.#setAnnotate(false); }
866
+ if (on) { this.#showTerminal(false); this.#setControl(false); }
559
867
  else { this.$.captions.replaceChildren(); this.#openAsk(false); this.#handUp = false; }
560
868
  this.#syncScreen();
561
869
  // FLIP: grow from (or shrink back to) the side panel
@@ -571,12 +879,6 @@ class AgentBridge extends HTMLElement {
571
879
  { duration: on ? 420 : 360, easing: "cubic-bezier(.2, .7, .2, 1)" });
572
880
  }
573
881
 
574
- #renderBusy() {
575
- const s = this.#status;
576
- const busy = this.#present.on && s?.alive && s.status === "working" && this.#present.player?.state !== "playing";
577
- this.$.busy.classList.toggle("on", !!busy);
578
- }
579
-
580
882
  // Raise hand: open a small input over the presentation and send it as a message.
581
883
  #bindHand() {
582
884
  const { hand, ask, askBox, askHint } = this.$;
@@ -636,7 +938,7 @@ class AgentBridge extends HTMLElement {
636
938
 
637
939
  // Agent reply as a short-lived bubble over the presented screen.
638
940
  // kind "user": the viewer's own question (plain text, accent colour).
639
- #caption(text, kind = "agent", ms = AgentBridge.CAPTION_MS) {
941
+ #caption(text, kind = "agent") {
640
942
  if (!text?.trim()) return;
641
943
  const el = document.createElement("div");
642
944
  el.className = `caption ${kind}`;
@@ -651,7 +953,7 @@ class AgentBridge extends HTMLElement {
651
953
  el.classList.add("out");
652
954
  setTimeout(() => el.remove(), 500);
653
955
  };
654
- setTimeout(leave, ms);
956
+ setTimeout(leave, AgentBridge.CAPTION_MS);
655
957
  const live = [...box.children].filter((c) => !c.classList.contains("out"));
656
958
  live.slice(0, Math.max(0, live.length - AgentBridge.CAPTION_MAX)).forEach((c) => c.__leave?.());
657
959
  el.__leave = leave;
@@ -694,43 +996,24 @@ class AgentBridge extends HTMLElement {
694
996
 
695
997
  // ---------- user control ----------
696
998
 
697
- // One socket for both modes; open while control or annotate is on.
698
- #syncWs() {
699
- const want = this.#control || this.#annot;
700
- if (want && !this.#ws) {
999
+ #setControl(on) {
1000
+ if (on && !this.#controlWs) {
701
1001
  const ws = new WebSocket(`${this.server.replace(/^http/, "ws")}/input`);
702
- ws.onmessage = (e) => {
703
- let m;
704
- try { m = JSON.parse(e.data); } catch { return; }
705
- this.#inspectWait.get(m.id)?.(m.result ?? null);
706
- };
707
- ws.onclose = () => {
708
- if (this.#ws !== ws) return;
709
- this.#ws = null;
710
- this.#setControl(false);
711
- this.#setAnnotate(false);
712
- };
713
- this.#ws = ws;
714
- } else if (!want && this.#ws) {
715
- const ws = this.#ws;
716
- this.#ws = null;
1002
+ ws.onclose = () => { if (this.#controlWs === ws) this.#setControl(false); };
1003
+ this.#controlWs = ws;
1004
+ } else if (!on && this.#controlWs) {
1005
+ const ws = this.#controlWs;
1006
+ this.#controlWs = null;
717
1007
  ws.close();
718
1008
  }
719
- if (!this.#ws) { this.#inspectWait.forEach((r) => r(null)); this.#inspectWait.clear(); }
720
- }
721
-
722
- #setControl(on) {
723
- this.#control = !!on && !this.#present.on;
724
- if (this.#control) this.#setAnnotate(false);
725
- this.#syncWs();
726
- this.$.controlBtn.setAttribute("aria-checked", String(this.#control));
727
- this.$.controlBtn.querySelector(".state-label").textContent = this.#control ? "ON" : "OFF";
728
- this.$.viewport.classList.toggle("control", this.#control);
729
- if (this.#control) this.$.kbd.focus();
1009
+ this.$.controlBtn.setAttribute("aria-checked", String(!!this.#controlWs));
1010
+ this.$.controlBtn.querySelector(".state-label").textContent = this.#controlWs ? "ON" : "OFF";
1011
+ this.$.viewport.classList.toggle("control", !!this.#controlWs);
1012
+ if (this.#controlWs) this.$.kbd.focus();
730
1013
  }
731
1014
 
732
1015
  #cdp(method, params) {
733
- const ws = this.#ws;
1016
+ const ws = this.#controlWs;
734
1017
  if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ method, params }));
735
1018
  }
736
1019
 
@@ -744,11 +1027,11 @@ class AgentBridge extends HTMLElement {
744
1027
 
745
1028
  #bindControl() {
746
1029
  const { img, kbd, controlBtn } = this.$;
747
- const on = () => this.#control && this.#frame.w > 0;
1030
+ const on = () => !!this.#controlWs && this.#frame.w > 0;
748
1031
  const mods = (e) => (e.altKey ? 1 : 0) | (e.ctrlKey ? 2 : 0) | (e.metaKey ? 4 : 0) | (e.shiftKey ? 8 : 0);
749
1032
  const BUTTONS = ["left", "middle", "right"];
750
1033
 
751
- controlBtn.addEventListener("click", () => this.#setControl(!this.#control));
1034
+ controlBtn.addEventListener("click", () => { if (!this.#present.on) this.#setControl(!this.#controlWs); });
752
1035
 
753
1036
  img.addEventListener("mousedown", (e) => {
754
1037
  if (!on()) return;
@@ -776,7 +1059,7 @@ class AgentBridge extends HTMLElement {
776
1059
  });
777
1060
  });
778
1061
  img.addEventListener("wheel", (e) => {
779
- if (!on() && !(this.#annot && this.#frame.w > 0)) return; // annotate: scroll to reach the element
1062
+ if (!on()) return;
780
1063
  e.preventDefault();
781
1064
  this.#cdp("Input.dispatchMouseEvent", { type: "mouseWheel", ...this.#point(e), deltaX: e.deltaX, deltaY: e.deltaY,
782
1065
  modifiers: mods(e) });
@@ -814,163 +1097,6 @@ class AgentBridge extends HTMLElement {
814
1097
  });
815
1098
  }
816
1099
 
817
- // ---------- annotate ----------
818
-
819
- #setAnnotate(on) {
820
- on = !!on && !this.#present.on;
821
- if (on === this.#annot) return;
822
- this.#annot = on;
823
- if (on) this.#setControl(false);
824
- else { this.#hover = null; this.#hoverPt = null; this.#select(null); }
825
- this.#syncWs();
826
- this.$.annotBtn.setAttribute("aria-pressed", String(on));
827
- this.$.viewport.classList.toggle("annot", on);
828
- this.#drawHl();
829
- }
830
-
831
- // Element at a frame point ({x, y}) or by selector; null if unavailable.
832
- #inspect(q, full = false) {
833
- const ws = this.#ws;
834
- if (ws?.readyState !== WebSocket.OPEN) return Promise.resolve(null);
835
- const id = ++this.#inspectId;
836
- return new Promise((resolve) => {
837
- const done = (r) => { clearTimeout(t); this.#inspectWait.delete(id); resolve(r); };
838
- const t = setTimeout(() => done(null), 3000);
839
- this.#inspectWait.set(id, done);
840
- ws.send(JSON.stringify({ id, inspect: { ...q, full } }));
841
- });
842
- }
843
-
844
- // Latest pointer position wins; at most one hover query in flight.
845
- #probing = false;
846
- #probeAgain = false;
847
- async #probeHover() {
848
- if (this.#probing) { this.#probeAgain = true; return; }
849
- this.#probing = true;
850
- try {
851
- do {
852
- this.#probeAgain = false;
853
- const p = this.#hoverPt;
854
- if (!p || !this.#annot) break;
855
- const r = await this.#inspect(p);
856
- if (this.#hoverPt && this.#annot) { this.#hover = r; this.#drawHl(); }
857
- } while (this.#probeAgain);
858
- } finally {
859
- this.#probing = false;
860
- }
861
- }
862
-
863
- // The page changed (scroll, layout, HMR): follow the selected element and
864
- // re-check what is under the pointer.
865
- #tracking = false;
866
- async #annotFrame() {
867
- if (this.#hoverPt) this.#probeHover();
868
- const sel = this.#sel;
869
- if (!sel || this.#tracking) { this.#drawHl(); return; }
870
- this.#tracking = true;
871
- try {
872
- const r = await this.#inspect({ selector: sel.selector });
873
- if (r && this.#sel === sel) sel.rect = r.rect; // gone from the page: keep the last box
874
- } finally {
875
- this.#tracking = false;
876
- }
877
- this.#drawHl();
878
- }
879
-
880
- #select(t) {
881
- const { note, noteBox, noteHint, noteTarget } = this.$;
882
- this.#sel = t;
883
- note.hidden = !t;
884
- noteHint.textContent = "";
885
- if (t) {
886
- noteTarget.textContent = t.selector;
887
- noteTarget.title = t.selector;
888
- noteBox.focus();
889
- }
890
- this.#drawHl();
891
- }
892
-
893
- // Frame (CSS px of the page viewport) -> position over the displayed image.
894
- #drawHl() {
895
- const { viewport, img, hlHover, hlSel, hlLabel, note } = this.$;
896
- const show = this.#annot && viewport.classList.contains("has") && this.#frame.w > 0;
897
- const vr = viewport.getBoundingClientRect();
898
- const ir = img.getBoundingClientRect();
899
- const sx = ir.width / this.#frame.w, sy = ir.height / this.#frame.h;
900
- const box = (r) => {
901
- if (!show || !r) return null;
902
- const x0 = Math.max(r.x, 0), y0 = Math.max(r.y, 0);
903
- const x1 = Math.min(r.x + r.w, this.#frame.w), y1 = Math.min(r.y + r.h, this.#frame.h);
904
- if (x1 <= x0 || y1 <= y0) return null;
905
- return { left: ir.left - vr.left + x0 * sx, top: ir.top - vr.top + y0 * sy, width: (x1 - x0) * sx, height: (y1 - y0) * sy };
906
- };
907
- const place = (el, b) => {
908
- el.classList.toggle("on", !!b);
909
- if (b) Object.assign(el.style, { left: `${b.left}px`, top: `${b.top}px`, width: `${b.width}px`, height: `${b.height}px` });
910
- return b;
911
- };
912
- const hb = place(hlHover, box(this.#hover?.rect));
913
- const sb = place(hlSel, box(this.#sel?.rect));
914
-
915
- hlLabel.classList.toggle("on", !!hb);
916
- if (hb) {
917
- const r = this.#hover.rect;
918
- hlLabel.textContent = `${this.#hover.label} ${Math.round(r.w)}×${Math.round(r.h)}`;
919
- // above the box, else below it, else inside its top edge
920
- const top = hb.top >= 22 ? hb.top - 22 : hb.top + hb.height + 26 <= vr.height ? hb.top + hb.height + 4 : hb.top + 4;
921
- Object.assign(hlLabel.style, { left: `${hb.left + (top === hb.top + 4 ? 4 : 0)}px`, top: `${top}px` });
922
- }
923
-
924
- // input below the selected element, above it if there is no room
925
- if (!note.hidden) {
926
- const W = note.offsetWidth, H = note.offsetHeight, pad = 8;
927
- const b = sb ?? { left: pad, top: pad, width: 0, height: 0 };
928
- let top = b.top + b.height + pad;
929
- if (top + H > vr.height - pad) top = b.top - H - pad;
930
- top = Math.max(pad, Math.min(top, vr.height - H - pad));
931
- const left = Math.max(pad, Math.min(b.left, vr.width - W - pad));
932
- Object.assign(note.style, { left: `${left}px`, top: `${top}px` });
933
- }
934
- }
935
-
936
- #bindAnnotate() {
937
- const { img, annotBtn, note, noteBox, noteHint } = this.$;
938
- annotBtn.addEventListener("click", () => this.#setAnnotate(!this.#annot));
939
- img.addEventListener("mousemove", (e) => {
940
- if (!this.#annot || !this.#frame.w) return;
941
- this.#hoverPt = this.#point(e);
942
- this.#probeHover();
943
- });
944
- img.addEventListener("mouseleave", () => {
945
- if (!this.#annot) return;
946
- this.#hoverPt = null;
947
- this.#hover = null;
948
- this.#drawHl();
949
- });
950
- img.addEventListener("click", async (e) => {
951
- if (!this.#annot || !this.#frame.w) return;
952
- e.preventDefault();
953
- const t = await this.#inspect(this.#point(e), true);
954
- if (t && this.#annot) this.#select(t);
955
- });
956
- new ResizeObserver(() => this.#drawHl()).observe(img);
957
- note.addEventListener("submit", async (e) => {
958
- e.preventDefault();
959
- const text = noteBox.value.trim();
960
- const target = this.#sel;
961
- if (!text || !target) return;
962
- noteHint.textContent = "보내는 중…";
963
- const res = await this.send(text, "annotate", { target });
964
- if (!res.ok) { noteHint.textContent = `전송 실패: ${res.error}`; return; }
965
- noteBox.value = "";
966
- if (this.#sel === target) this.#select(null);
967
- });
968
- noteBox.addEventListener("keydown", (e) => {
969
- if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); note.requestSubmit(); }
970
- if (e.key === "Escape") { e.preventDefault(); this.#select(null); }
971
- });
972
- }
973
-
974
1100
  #renderScreenMeta(m) {
975
1101
  const { live, url, viewport } = this.$;
976
1102
  if (!m.connected) {
@@ -986,7 +1112,6 @@ class AgentBridge extends HTMLElement {
986
1112
  url.textContent = m.title ? `${m.title} · ${m.url}` : m.url || "";
987
1113
  url.title = m.url || "";
988
1114
  }
989
- this.#drawHl();
990
1115
  this.#emit("agent-screen", m);
991
1116
  }
992
1117
 
@@ -1123,7 +1248,7 @@ class AgentBridge extends HTMLElement {
1123
1248
  return;
1124
1249
  } else if (m.role === "assistant") {
1125
1250
  wrap.className = "msg assistant";
1126
- meta.textContent = `에이전트${m.script ? ` · 대본 ${m.script}` : ""} · ${time}`;
1251
+ meta.textContent = `에이전트 · ${time}`;
1127
1252
  if (this.#md) bubble.innerHTML = this.#md(m.text);
1128
1253
  else { bubble.textContent = m.text; bubble.classList.add("plain"); }
1129
1254
  } else if (m.role === "browser") {
@@ -1185,16 +1310,22 @@ class AgentBridge extends HTMLElement {
1185
1310
  this.$.buttons.forEach((b) => (b.disabled = !canSend));
1186
1311
  if (!s || !s.alive || s.status === "idle") this.#tools.clear();
1187
1312
  this.#renderActivity();
1188
- this.#renderBusy();
1189
1313
  this.#emit("agent-status", s);
1190
1314
  }
1191
1315
 
1192
1316
  async #submit(action) {
1193
1317
  const text = this.$.box.value.trim();
1194
- if (action === "message" && !text) return;
1318
+ const images = this.#images.map(({ type, data }) => ({ type, data }));
1319
+ if (action === "message" && !text && !images.length) return;
1195
1320
  this.$.buttons.forEach((b) => (b.disabled = true));
1196
- const res = await this.send(text, action);
1197
- if (res.ok) { this.$.box.value = ""; this.$.hint.textContent = ""; }
1321
+ const res = await this.send(text, action, images);
1322
+ if (res.ok) {
1323
+ this.$.box.value = "";
1324
+ this.$.hint.textContent = "";
1325
+ this.#images.forEach((img) => URL.revokeObjectURL(img.url));
1326
+ this.#images = [];
1327
+ this.#renderImages();
1328
+ }
1198
1329
  else this.$.hint.textContent = `전송 실패: ${res.error}`;
1199
1330
  this.#renderStatus(this.#status);
1200
1331
  this.$.box.focus();