agello 0.4.1 → 0.4.3

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,6 +12,9 @@
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.
15
18
  //
16
19
  // Theming (CSS custom properties on the element or an ancestor)
17
20
  // --ab-bg --ab-panel --ab-text --ab-muted --ab-line --ab-accent --ab-user --ab-font
@@ -39,12 +42,17 @@
39
42
  // A raise-hand button next to the bubbles opens a small input so the viewer
40
43
  // can ask the agent something mid-presentation (sent as action=message).
41
44
  // Opening it tells the agent right away (action=hand-raise) so it can pause;
42
- // closing it without asking sends action=hand-lower so it can go on.
45
+ // closing it with X (or Esc) before asking sends action=hand-lower so it can go on.
46
+ // The input stays open while the agent answers, for follow-up questions; X
47
+ // (or Esc) sends action=hand-done so the agent resumes the presentation.
43
48
  // The viewer's questions show as bubbles too, with the same 10s lifetime.
44
49
  // An end button above it stops the presentation for every viewer.
50
+ // Script lines (`agello script` + `present resume`) arrive as agent messages
51
+ // with `script: "step.line"`. While the script is not playing and the agent
52
+ // is working (e.g. answering a question), a small loader is shown.
45
53
  //
46
54
  // Methods
47
- // el.send(text, action = 'message', images = [{type, data (base64)}]) -> Promise<{ok, error?}>
55
+ // el.send(text, action = 'message', extra) -> Promise<{ok, error?}> (extra: {images: [{type, data (base64)}]}, {target} for annotate)
48
56
  // el.clearHistory() clear saved chat for the current pane
49
57
  // el.stopPresent() end presentation mode, tell the agent -> Promise<{ok, error?, notified?}>
50
58
 
@@ -75,7 +83,7 @@ const SCREEN_REASONS = {
75
83
  };
76
84
  const ACTIONS = {
77
85
  message: "메시지", approve: "승인", reject: "거절",
78
- "present-stop": "발표 종료", "hand-raise": "손들기", "hand-lower": "손 내림",
86
+ "present-stop": "발표 종료", "hand-raise": "손들기", "hand-lower": "손 내림", "hand-done": "질문 완료", annotate: "주석",
79
87
  };
80
88
 
81
89
  const CSS = `
@@ -228,6 +236,30 @@ h1 { font-size: 14px; margin: 0; font-weight: 600; }
228
236
  @media (prefers-reduced-motion: reduce) { .control-btn .track, .control-btn .knob { transition: none; } }
229
237
  .placeholder { font-size: 13px; color: var(--_muted); text-align: center; }
230
238
 
239
+ /* annotate: hover tint, selected outline, request input next to the element */
240
+ .annot-btn { flex: none; font-size: 12px; padding: 2px 10px; border-radius: 999px; }
241
+ .annot-btn[aria-pressed="true"] { background: var(--_accent); border-color: var(--_accent); color: #fff; }
242
+ .viewport.annot img { cursor: crosshair; }
243
+ .hl { position: absolute; z-index: 3; pointer-events: none; display: none; border-radius: 2px; }
244
+ .hl.on { display: block; }
245
+ .hl.hover { background: color-mix(in srgb, var(--_accent) 22%, transparent);
246
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--_accent) 55%, transparent); }
247
+ .hl.sel { box-shadow: 0 0 0 2px var(--_accent), 0 0 0 5px color-mix(in srgb, var(--_accent) 25%, transparent); }
248
+ .hl-label { position: absolute; z-index: 4; pointer-events: none; display: none; max-width: 60%;
249
+ padding: 2px 7px; border-radius: 5px; font: 11px/1.5 ui-monospace, Menlo, monospace; color: #fff;
250
+ background: rgba(20, 20, 22, .85); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
251
+ .hl-label.on { display: block; }
252
+ .note { position: absolute; z-index: 5; width: min(340px, calc(100% - 16px)); padding: 10px; border-radius: 12px;
253
+ background: var(--_panel); border: 1px solid var(--_line); box-shadow: 0 8px 28px rgba(0,0,0,.22);
254
+ animation: capIn .18s ease; }
255
+ .note[hidden] { display: none; }
256
+ .note .note-target { font: 11px/1.4 ui-monospace, Menlo, monospace; color: var(--_muted); margin-bottom: 6px;
257
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
258
+ .note textarea { min-height: 52px; font-size: 14px; }
259
+ .note .note-row { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
260
+ .note .note-hint { flex: 1; font-size: 12px; color: var(--_muted); }
261
+ @media (prefers-reduced-motion: reduce) { .note { animation: none; } }
262
+
231
263
  /* presentation mode: the screen panel covers the window, chat stays hidden underneath.
232
264
  container-type would make the host the containing block of the fixed layer, so drop it. */
233
265
  :host([presenting]) { container-type: normal; }
@@ -246,6 +278,9 @@ h1 { font-size: 14px; margin: 0; font-weight: 600; }
246
278
  box-shadow: 0 8px 30px rgba(0, 0, 0, .35); animation: capIn .3s ease; }
247
279
  .caption.plain { white-space: pre-wrap; }
248
280
  .caption.user { background: color-mix(in srgb, var(--_accent) 88%, transparent); }
281
+ /* system event (hand raised / lowered / done): small, light, not part of the conversation */
282
+ .caption.event { padding: 6px 14px; border-radius: 999px; font-size: 13px; color: #1d1d1f;
283
+ background: rgba(255, 255, 255, .9); box-shadow: 0 4px 16px rgba(0,0,0,.25); }
249
284
  .caption.out { opacity: 0; translate: 0 6px; transition: opacity .5s ease, translate .5s ease; }
250
285
  .caption > :first-child { margin-top: 0; } .caption > :last-child { margin-bottom: 0; }
251
286
  .caption p, .caption ul, .caption ol { margin: .35em 0; }
@@ -255,6 +290,14 @@ h1 { font-size: 14px; margin: 0; font-weight: 600; }
255
290
  .caption a { color: inherit; }
256
291
  @keyframes capIn { from { opacity: 0; translate: 0 10px; } }
257
292
 
293
+ /* agent busy while presenting (answering, fixing the script): minimal loader */
294
+ .busy { display: none; }
295
+ .body.present .busy.on { position: absolute; left: 22px; bottom: 28px; z-index: 5; display: flex; align-items: center;
296
+ gap: 9px; padding: 9px 14px; border-radius: 999px; font-size: 13px; color: #fff; background: rgba(20, 20, 22, .72);
297
+ -webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px); box-shadow: 0 8px 30px rgba(0,0,0,.35);
298
+ animation: capIn .25s ease; }
299
+ .busy .spinner { border-color: rgba(255,255,255,.25); border-top-color: #fff; }
300
+
258
301
  /* raise hand: ask the agent mid-presentation */
259
302
  .hand-wrap { display: none; }
260
303
  .body.present .hand-wrap { position: absolute; right: 22px; bottom: 28px; z-index: 5; display: flex;
@@ -279,6 +322,13 @@ h1 { font-size: 14px; margin: 0; font-weight: 600; }
279
322
  .ask textarea::placeholder { color: rgba(255,255,255,.5); }
280
323
  .ask .ask-row { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
281
324
  .ask .ask-hint { font-size: 12px; color: rgba(255,255,255,.6); flex: 1; }
325
+ .ask .ask-row .primary { margin-left: 0; }
326
+ .ask .ask-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; font-size: 13px; color: rgba(255,255,255,.75); }
327
+ .ask .ask-title { flex: 1; }
328
+ .ask .ask-done { width: 28px; height: 28px; padding: 0; border-radius: 50%; display: grid; place-items: center;
329
+ background: transparent; color: #fff; border-color: rgba(255,255,255,.22); }
330
+ .ask .ask-done:hover { background: rgba(255,255,255,.14); }
331
+ .ask .ask-done svg { width: 16px; height: 16px; }
282
332
  @media (prefers-reduced-motion: reduce) { .hand, .ask { transition: none; animation: none; } }
283
333
  @media (prefers-reduced-motion: reduce) { .caption { animation: none; } .caption.out { transition: none; } }
284
334
  @container (max-width: 820px) {
@@ -401,11 +451,18 @@ const TEMPLATE = `
401
451
  </section>
402
452
  <aside class="screen" part="screen">
403
453
  <div class="screen-bar"><span class="live" hidden>에이전트 조작 중</span><span class="url"></span>
454
+ <button class="annot-btn" type="button" aria-pressed="false" title="화면의 요소를 골라 에이전트에게 요청 (선택자와 함께 전달)">주석</button>
404
455
  <button class="control-btn" role="switch" aria-checked="false" title="켜면 마우스와 키보드 입력이 브라우저로 전달돼요 (끄기: Shift+Esc)">
405
456
  <span>내 조작</span><span class="track"><span class="knob"></span></span><span class="state-label">OFF</span></button></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>
457
+ <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>
458
+ <form class="note" part="note" hidden><div class="note-target"></div><textarea aria-label="선택한 요소에 대한 요청" placeholder="이 요소에 대한 요청 (Enter 전송, Esc 취소)"></textarea>
459
+ <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>
460
+ <div class="busy" part="busy" role="status"><span class="spinner"></span><span>답변 준비 중</span></div>
407
461
  <div class="hand-wrap">
408
- <form class="ask" part="ask" hidden><textarea aria-label="에이전트에게 질문" placeholder="에이전트에게 질문 (Enter 전송, Esc 닫기)"></textarea>
462
+ <form class="ask" part="ask" hidden>
463
+ <div class="ask-head"><span class="ask-title"></span><button type="button" class="ask-done">
464
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button></div>
465
+ <textarea aria-label="에이전트에게 질문"></textarea>
409
466
  <div class="ask-row"><span class="ask-hint"></span><button type="submit" class="primary">보내기</button></div></form>
410
467
  <button class="end" part="end" type="button" aria-label="발표 종료" title="발표 종료">
411
468
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
@@ -422,7 +479,14 @@ class AgentBridge extends HTMLElement {
422
479
  #root = this.attachShadow({ mode: "open" });
423
480
  #es = null;
424
481
  #screenEs = null;
425
- #controlWs = null;
482
+ #ws = null; // /input: control input and annotate inspect queries
483
+ #control = false;
484
+ #annot = false;
485
+ #hover = null; // inspected element under the pointer (annotate)
486
+ #hoverPt = null;
487
+ #sel = null; // selected element (annotate)
488
+ #inspectId = 0;
489
+ #inspectWait = new Map();
426
490
  #terminalCleanup = null;
427
491
  #terminalVersion = 0;
428
492
  #terminalStyles = null;
@@ -442,6 +506,7 @@ class AgentBridge extends HTMLElement {
442
506
  #present = { on: false };
443
507
  static HISTORY_LIMIT = 500;
444
508
  static CAPTION_MS = 10000; // presentation bubble lifetime
509
+ static EVENT_MS = 5000; // system event bubble lifetime
445
510
  static CAPTION_MAX = 3; // bubbles on screen at once (oldest leaves early)
446
511
 
447
512
  constructor() {
@@ -455,7 +520,10 @@ class AgentBridge extends HTMLElement {
455
520
  live: q(".live"), url: q(".screen-bar .url"), viewport: q(".viewport"), img: q(".viewport img"),
456
521
  controlBtn: q(".control-btn"), kbd: q(".kbd"),
457
522
  body: q(".body"), screen: q(".screen"), captions: q(".captions"),
458
- hand: q(".hand"), end: q(".end"), ask: q(".ask"), askBox: q(".ask textarea"), askHint: q(".ask-hint"),
523
+ busy: q(".busy"), hand: q(".hand"), end: q(".end"), ask: q(".ask"), askBox: q(".ask textarea"), askHint: q(".ask-hint"),
524
+ askTitle: q(".ask-title"), askDone: q(".ask-done"),
525
+ annotBtn: q(".annot-btn"), hlHover: q(".hl.hover"), hlSel: q(".hl.sel"), hlLabel: q(".hl-label"),
526
+ note: q(".note"), noteBox: q(".note textarea"), noteHint: q(".note-hint"), noteTarget: q(".note-target"),
459
527
  };
460
528
  this.$.buttons.forEach((b) => b.addEventListener("click", () => this.#submit(b.dataset.action)));
461
529
  this.#bindGrip();
@@ -467,6 +535,7 @@ class AgentBridge extends HTMLElement {
467
535
  });
468
536
  loadMarkdown().then((md) => (this.#md = md));
469
537
  this.#bindControl();
538
+ this.#bindAnnotate();
470
539
  this.#bindHand();
471
540
  q(".term-btn").addEventListener("click", () => this.#showTerminal(!this.hasAttribute("terminal-open")));
472
541
  q(".terminal-retry").addEventListener("click", () => this.#showTerminal(true));
@@ -490,6 +559,7 @@ class AgentBridge extends HTMLElement {
490
559
  this.#screenEs?.close();
491
560
  this.#screenEs = null;
492
561
  this.#setControl(false);
562
+ this.#setAnnotate(false);
493
563
  }
494
564
  attributeChangedCallback(name) {
495
565
  if (name === "heading") this.$.heading.textContent = this.getAttribute("heading") || "agello";
@@ -506,6 +576,7 @@ class AgentBridge extends HTMLElement {
506
576
  q(".term-btn").setAttribute("aria-pressed", String(on));
507
577
  if (!on) return;
508
578
  this.#setControl(false);
579
+ this.#setAnnotate(false);
509
580
  q(".terminal-status").textContent = "터미널 로딩 중…";
510
581
  try {
511
582
  if (!this.#terminalStyles) {
@@ -772,12 +843,12 @@ class AgentBridge extends HTMLElement {
772
843
  }));
773
844
  }
774
845
 
775
- async send(text, action = "message", images = []) {
846
+ async send(text, action = "message", extra = {}) {
776
847
  try {
777
848
  const res = await fetch(`${this.server}/send`, {
778
849
  method: "POST",
779
850
  headers: { "Content-Type": "application/json" },
780
- body: JSON.stringify({ action, text, images: images.length ? images : undefined }),
851
+ body: JSON.stringify({ ...extra, action, text }),
781
852
  });
782
853
  return await res.json();
783
854
  } catch {
@@ -816,8 +887,13 @@ class AgentBridge extends HTMLElement {
816
887
  on("message", (m) => {
817
888
  this.#addMessage(m);
818
889
  this.#saveMessage(m);
819
- if (this.#present.on && m.role === "assistant") this.#caption(m.text);
890
+ // script lines carry their own on-screen time (>= 10s), others use CAPTION_MS
891
+ if (this.#present.on && m.role === "assistant") this.#caption(m.text, "agent", m.hold * 1000 || undefined);
820
892
  if (this.#present.on && m.role === "browser" && m.action === "message") this.#caption(m.text, "user");
893
+ // a reply to the viewer's question (status polling may miss a short working phase)
894
+ // (the agent took the question once it is echoed in the transcript; earlier work was the hand-raise turn)
895
+ if (this.#qa === "asked" && m.role === "browser" && m.action === "message") this.#qaTaken = true;
896
+ if (this.#qa === "asked" && this.#qaTaken && m.role === "assistant" && !m.script) { this.#qaWorked = true; this.#checkAnswered(); }
821
897
  this.#emit("agent-message", m);
822
898
  });
823
899
  on("present", (p) => this.#setPresent(p));
@@ -842,7 +918,7 @@ class AgentBridge extends HTMLElement {
842
918
 
843
919
  #syncScreen() {
844
920
  const want = this.hasAttribute("screen") || this.#present.on;
845
- if (!want) { this.#screenEs?.close(); this.#screenEs = null; this.#setControl(false); return; }
921
+ if (!want) { this.#screenEs?.close(); this.#screenEs = null; this.#setControl(false); this.#setAnnotate(false); return; }
846
922
  if (this.#screenEs) return;
847
923
  const es = (this.#screenEs = new EventSource(`${this.server}/screen`));
848
924
  es.addEventListener("meta", (e) => this.#renderScreenMeta(JSON.parse(e.data)));
@@ -851,6 +927,7 @@ class AgentBridge extends HTMLElement {
851
927
  this.#frame = { w: f.w, h: f.h };
852
928
  this.$.img.src = `data:image/jpeg;base64,${f.data}`;
853
929
  this.$.viewport.classList.add("has");
930
+ if (this.#annot) this.#annotFrame();
854
931
  });
855
932
  es.onerror = () => this.#renderScreenMeta({ connected: false });
856
933
  }
@@ -862,9 +939,10 @@ class AgentBridge extends HTMLElement {
862
939
  const was = this.#present.on;
863
940
  this.#present = p ?? { on: false };
864
941
  this.#emit("agent-present", this.#present);
942
+ this.#renderBusy();
865
943
  if (on === was) return;
866
- if (on) { this.#showTerminal(false); this.#setControl(false); }
867
- else { this.$.captions.replaceChildren(); this.#openAsk(false); this.#handUp = false; }
944
+ if (on) { this.#showTerminal(false); this.#setControl(false); this.#setAnnotate(false); }
945
+ else { this.$.captions.replaceChildren(); this.#openAsk(false); this.#qa = null; }
868
946
  this.#syncScreen();
869
947
  // FLIP: grow from (or shrink back to) the side panel
870
948
  const { screen, body } = this.$;
@@ -879,18 +957,35 @@ class AgentBridge extends HTMLElement {
879
957
  { duration: on ? 420 : 360, easing: "cubic-bezier(.2, .7, .2, 1)" });
880
958
  }
881
959
 
882
- // Raise hand: open a small input over the presentation and send it as a message.
960
+ #renderBusy() {
961
+ const s = this.#status;
962
+ const busy = this.#present.on && s?.alive && s.status === "working" && this.#present.player?.state !== "playing";
963
+ this.$.busy.classList.toggle("on", !!busy);
964
+ this.#checkAnswered();
965
+ }
966
+
967
+ // Raise hand: a question session over the presentation.
968
+ // The input stays open for the whole session; X / Esc / the hand button close it.
969
+ // raised: nothing asked yet. close -> hand-lower (agent resumes)
970
+ // asked: question sent, agent answering (follow-ups can be typed already)
971
+ // follow: answered, waiting for a follow-up. close -> hand-done (agent resumes)
972
+ // The session ends when the script plays again or the presentation stops.
973
+ #qa = null; // null | "raised" | "asked" | "follow"
974
+ #qaTaken = false; // the last question reached the agent (echoed in the transcript)
975
+ #qaWorked = false; // agent seen working on it
976
+
883
977
  #bindHand() {
884
978
  const { hand, ask, askBox, askHint } = this.$;
885
979
  hand.addEventListener("click", () => {
886
- if (ask.hidden) this.#raiseHand();
887
- else this.#lowerHand();
980
+ if (!this.#qa) this.#raiseHand();
981
+ else this.#closeHand();
888
982
  });
983
+ this.$.askDone.addEventListener("click", () => { this.#closeHand(); hand.focus(); });
889
984
  this.$.end.addEventListener("click", async () => {
890
985
  this.$.end.disabled = true;
891
986
  const res = await this.stopPresent();
892
987
  this.$.end.disabled = false;
893
- if (!res.ok) { this.#openAsk(true); askHint.textContent = `발표 종료 실패: ${res.error}`; }
988
+ if (!res.ok) { this.#openAsk(this.#qa ?? "raised"); askHint.textContent = `발표 종료 실패: ${res.error}`; }
894
989
  });
895
990
  ask.addEventListener("submit", async (e) => {
896
991
  e.preventDefault();
@@ -900,50 +995,95 @@ class AgentBridge extends HTMLElement {
900
995
  const res = await this.send(text, "message");
901
996
  if (!res.ok) { askHint.textContent = `전송 실패: ${res.error}`; return; }
902
997
  askBox.value = "";
903
- this.#handUp = false; // the question itself ends the raised hand
904
- this.#openAsk(false);
998
+ this.#qa = "asked";
999
+ this.#qaTaken = this.#qaWorked = false;
1000
+ this.#openAsk("asked");
905
1001
  hand.classList.add("sent");
906
1002
  setTimeout(() => hand.classList.remove("sent"), 1500);
907
1003
  });
908
1004
  askBox.addEventListener("keydown", (e) => {
909
1005
  if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); ask.requestSubmit(); }
910
- if (e.key === "Escape") { e.preventDefault(); this.#lowerHand(); hand.focus(); }
1006
+ if (e.key === "Escape") { e.preventDefault(); this.#closeHand(); hand.focus(); }
911
1007
  });
912
1008
  }
913
1009
 
914
- // Raise: open the input and tell the agent to pause. Lower (closed without
915
- // asking): tell it to go on. Failures only show a hint; asking still works.
916
- #handUp = false;
1010
+ // Raise: open the input and tell the agent to pause. Failures only show a hint; asking still works.
917
1011
  async #raiseHand() {
918
- this.#openAsk(true);
919
- if (this.#handUp) return;
920
- this.#handUp = true;
1012
+ this.#event("손들기 확인 · 발표 일시정지");
1013
+ this.#qa = "raised";
1014
+ this.#openAsk("raised");
921
1015
  const res = await this.send("사용자가 손을 듦: 질문 입력 중", "hand-raise");
922
1016
  if (!res.ok && !this.$.ask.hidden) this.$.askHint.textContent = `손들기 알림 실패: ${res.error}`;
923
1017
  }
1018
+ // X / Esc / hand button: before asking = cancel (hand-lower), after = resolved (hand-done).
1019
+ #closeHand() {
1020
+ if (this.#qa === "raised") this.#lowerHand(); else this.#doneHand();
1021
+ }
1022
+ // Cancel before asking: tell the agent to go on.
924
1023
  async #lowerHand() {
925
1024
  this.#openAsk(false);
926
- if (!this.#handUp) return;
927
- this.#handUp = false;
928
- await this.send("사용자가 질문 없이 손을 내림", "hand-lower");
1025
+ if (this.#qa !== "raised") return;
1026
+ this.#qa = null;
1027
+ this.#event("손들기 취소 · 곧 발표 재개");
1028
+ await this.send("사용자가 질문 없이 손을 내림: 발표 계속", "hand-lower");
1029
+ }
1030
+ // X after an answer: the question is resolved, tell the agent to go on.
1031
+ async #doneHand() {
1032
+ this.#openAsk(false);
1033
+ if (!this.#qa || this.#qa === "raised") return;
1034
+ this.#qa = null;
1035
+ this.#event("질문 완료 · 곧 발표 재개");
1036
+ await this.send("사용자가 질문 완료 (추가 질문 없음): 발표 계속", "hand-done");
929
1037
  }
930
1038
 
931
- #openAsk(open) {
932
- const { hand, ask, askBox, askHint } = this.$;
933
- ask.hidden = !open;
934
- hand.setAttribute("aria-expanded", String(open));
1039
+ // Answer finished (agent was working, now is not): reopen the input for a follow-up.
1040
+ // If the script plays again (agent resumed on its own), the session is over.
1041
+ #checkAnswered() {
1042
+ if (!this.#qa) return;
1043
+ if (this.#present.player?.state === "playing") {
1044
+ if (this.#qa !== "raised") { this.#qa = null; this.#openAsk(false); }
1045
+ return;
1046
+ }
1047
+ if (this.#qa !== "asked") return;
1048
+ const s = this.#status;
1049
+ if (s?.alive && s.status === "working") { if (this.#qaTaken) this.#qaWorked = true; return; }
1050
+ if (this.#qaWorked && s?.alive) { this.#qa = "follow"; this.#openAsk("follow"); }
1051
+ }
1052
+
1053
+ // The input stays open for the whole session. mode: false (close) | "raised" | "asked" | "follow"
1054
+ static ASK_TEXT = {
1055
+ raised: ["질문 입력", "에이전트에게 질문 (Enter 전송, Esc·X 취소)", "손들기 취소: 발표 계속"],
1056
+ asked: ["답변 준비 중…", "추가 질문 (Enter 전송, Esc·X 질문 완료)", "질문 완료: 발표 계속"],
1057
+ follow: ["추가 질문이 있으면 입력", "추가 질문 (Enter 전송, Esc·X 질문 완료)", "질문 완료: 발표 계속"],
1058
+ };
1059
+ #openAsk(mode) {
1060
+ const { hand, ask, askBox, askHint, askTitle, askDone } = this.$;
1061
+ const wasOpen = !ask.hidden;
1062
+ ask.hidden = !mode;
1063
+ hand.setAttribute("aria-expanded", String(!!mode));
935
1064
  askHint.textContent = "";
936
- if (open) askBox.focus();
1065
+ if (!mode) return;
1066
+ const [title, placeholder, done] = AgentBridge.ASK_TEXT[mode];
1067
+ askTitle.textContent = title;
1068
+ askBox.placeholder = placeholder;
1069
+ askDone.setAttribute("aria-label", done);
1070
+ askDone.title = done;
1071
+ if (!wasOpen || mode !== "asked") askBox.focus();
1072
+ }
1073
+
1074
+ // Instant local feedback for a hand interaction (no agent round trip).
1075
+ #event(text) {
1076
+ this.#caption(text, "event", AgentBridge.EVENT_MS);
937
1077
  }
938
1078
 
939
1079
  // Agent reply as a short-lived bubble over the presented screen.
940
1080
  // kind "user": the viewer's own question (plain text, accent colour).
941
- #caption(text, kind = "agent") {
1081
+ #caption(text, kind = "agent", ms = AgentBridge.CAPTION_MS) {
942
1082
  if (!text?.trim()) return;
943
1083
  const el = document.createElement("div");
944
1084
  el.className = `caption ${kind}`;
945
1085
  el.part = "caption";
946
- if (kind === "user") { el.textContent = text; el.classList.add("plain"); }
1086
+ if (kind === "user" || kind === "event") { el.textContent = text; el.classList.add("plain"); }
947
1087
  else if (this.#md) el.innerHTML = this.#md(text);
948
1088
  else { el.textContent = text; el.classList.add("plain"); }
949
1089
  const box = this.$.captions;
@@ -953,7 +1093,7 @@ class AgentBridge extends HTMLElement {
953
1093
  el.classList.add("out");
954
1094
  setTimeout(() => el.remove(), 500);
955
1095
  };
956
- setTimeout(leave, AgentBridge.CAPTION_MS);
1096
+ setTimeout(leave, ms);
957
1097
  const live = [...box.children].filter((c) => !c.classList.contains("out"));
958
1098
  live.slice(0, Math.max(0, live.length - AgentBridge.CAPTION_MAX)).forEach((c) => c.__leave?.());
959
1099
  el.__leave = leave;
@@ -996,24 +1136,43 @@ class AgentBridge extends HTMLElement {
996
1136
 
997
1137
  // ---------- user control ----------
998
1138
 
999
- #setControl(on) {
1000
- if (on && !this.#controlWs) {
1139
+ // One socket for both modes; open while control or annotate is on.
1140
+ #syncWs() {
1141
+ const want = this.#control || this.#annot;
1142
+ if (want && !this.#ws) {
1001
1143
  const ws = new WebSocket(`${this.server.replace(/^http/, "ws")}/input`);
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;
1144
+ ws.onmessage = (e) => {
1145
+ let m;
1146
+ try { m = JSON.parse(e.data); } catch { return; }
1147
+ this.#inspectWait.get(m.id)?.(m.result ?? null);
1148
+ };
1149
+ ws.onclose = () => {
1150
+ if (this.#ws !== ws) return;
1151
+ this.#ws = null;
1152
+ this.#setControl(false);
1153
+ this.#setAnnotate(false);
1154
+ };
1155
+ this.#ws = ws;
1156
+ } else if (!want && this.#ws) {
1157
+ const ws = this.#ws;
1158
+ this.#ws = null;
1007
1159
  ws.close();
1008
1160
  }
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();
1161
+ if (!this.#ws) { this.#inspectWait.forEach((r) => r(null)); this.#inspectWait.clear(); }
1162
+ }
1163
+
1164
+ #setControl(on) {
1165
+ this.#control = !!on && !this.#present.on;
1166
+ if (this.#control) this.#setAnnotate(false);
1167
+ this.#syncWs();
1168
+ this.$.controlBtn.setAttribute("aria-checked", String(this.#control));
1169
+ this.$.controlBtn.querySelector(".state-label").textContent = this.#control ? "ON" : "OFF";
1170
+ this.$.viewport.classList.toggle("control", this.#control);
1171
+ if (this.#control) this.$.kbd.focus();
1013
1172
  }
1014
1173
 
1015
1174
  #cdp(method, params) {
1016
- const ws = this.#controlWs;
1175
+ const ws = this.#ws;
1017
1176
  if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ method, params }));
1018
1177
  }
1019
1178
 
@@ -1027,11 +1186,11 @@ class AgentBridge extends HTMLElement {
1027
1186
 
1028
1187
  #bindControl() {
1029
1188
  const { img, kbd, controlBtn } = this.$;
1030
- const on = () => !!this.#controlWs && this.#frame.w > 0;
1189
+ const on = () => this.#control && this.#frame.w > 0;
1031
1190
  const mods = (e) => (e.altKey ? 1 : 0) | (e.ctrlKey ? 2 : 0) | (e.metaKey ? 4 : 0) | (e.shiftKey ? 8 : 0);
1032
1191
  const BUTTONS = ["left", "middle", "right"];
1033
1192
 
1034
- controlBtn.addEventListener("click", () => { if (!this.#present.on) this.#setControl(!this.#controlWs); });
1193
+ controlBtn.addEventListener("click", () => this.#setControl(!this.#control));
1035
1194
 
1036
1195
  img.addEventListener("mousedown", (e) => {
1037
1196
  if (!on()) return;
@@ -1059,7 +1218,7 @@ class AgentBridge extends HTMLElement {
1059
1218
  });
1060
1219
  });
1061
1220
  img.addEventListener("wheel", (e) => {
1062
- if (!on()) return;
1221
+ if (!on() && !(this.#annot && this.#frame.w > 0)) return; // annotate: scroll to reach the element
1063
1222
  e.preventDefault();
1064
1223
  this.#cdp("Input.dispatchMouseEvent", { type: "mouseWheel", ...this.#point(e), deltaX: e.deltaX, deltaY: e.deltaY,
1065
1224
  modifiers: mods(e) });
@@ -1097,6 +1256,163 @@ class AgentBridge extends HTMLElement {
1097
1256
  });
1098
1257
  }
1099
1258
 
1259
+ // ---------- annotate ----------
1260
+
1261
+ #setAnnotate(on) {
1262
+ on = !!on && !this.#present.on;
1263
+ if (on === this.#annot) return;
1264
+ this.#annot = on;
1265
+ if (on) this.#setControl(false);
1266
+ else { this.#hover = null; this.#hoverPt = null; this.#select(null); }
1267
+ this.#syncWs();
1268
+ this.$.annotBtn.setAttribute("aria-pressed", String(on));
1269
+ this.$.viewport.classList.toggle("annot", on);
1270
+ this.#drawHl();
1271
+ }
1272
+
1273
+ // Element at a frame point ({x, y}) or by selector; null if unavailable.
1274
+ #inspect(q, full = false) {
1275
+ const ws = this.#ws;
1276
+ if (ws?.readyState !== WebSocket.OPEN) return Promise.resolve(null);
1277
+ const id = ++this.#inspectId;
1278
+ return new Promise((resolve) => {
1279
+ const done = (r) => { clearTimeout(t); this.#inspectWait.delete(id); resolve(r); };
1280
+ const t = setTimeout(() => done(null), 3000);
1281
+ this.#inspectWait.set(id, done);
1282
+ ws.send(JSON.stringify({ id, inspect: { ...q, full } }));
1283
+ });
1284
+ }
1285
+
1286
+ // Latest pointer position wins; at most one hover query in flight.
1287
+ #probing = false;
1288
+ #probeAgain = false;
1289
+ async #probeHover() {
1290
+ if (this.#probing) { this.#probeAgain = true; return; }
1291
+ this.#probing = true;
1292
+ try {
1293
+ do {
1294
+ this.#probeAgain = false;
1295
+ const p = this.#hoverPt;
1296
+ if (!p || !this.#annot) break;
1297
+ const r = await this.#inspect(p);
1298
+ if (this.#hoverPt && this.#annot) { this.#hover = r; this.#drawHl(); }
1299
+ } while (this.#probeAgain);
1300
+ } finally {
1301
+ this.#probing = false;
1302
+ }
1303
+ }
1304
+
1305
+ // The page changed (scroll, layout, HMR): follow the selected element and
1306
+ // re-check what is under the pointer.
1307
+ #tracking = false;
1308
+ async #annotFrame() {
1309
+ if (this.#hoverPt) this.#probeHover();
1310
+ const sel = this.#sel;
1311
+ if (!sel || this.#tracking) { this.#drawHl(); return; }
1312
+ this.#tracking = true;
1313
+ try {
1314
+ const r = await this.#inspect({ selector: sel.selector });
1315
+ if (r && this.#sel === sel) sel.rect = r.rect; // gone from the page: keep the last box
1316
+ } finally {
1317
+ this.#tracking = false;
1318
+ }
1319
+ this.#drawHl();
1320
+ }
1321
+
1322
+ #select(t) {
1323
+ const { note, noteBox, noteHint, noteTarget } = this.$;
1324
+ this.#sel = t;
1325
+ note.hidden = !t;
1326
+ noteHint.textContent = "";
1327
+ if (t) {
1328
+ noteTarget.textContent = t.selector;
1329
+ noteTarget.title = t.selector;
1330
+ noteBox.focus();
1331
+ }
1332
+ this.#drawHl();
1333
+ }
1334
+
1335
+ // Frame (CSS px of the page viewport) -> position over the displayed image.
1336
+ #drawHl() {
1337
+ const { viewport, img, hlHover, hlSel, hlLabel, note } = this.$;
1338
+ const show = this.#annot && viewport.classList.contains("has") && this.#frame.w > 0;
1339
+ const vr = viewport.getBoundingClientRect();
1340
+ const ir = img.getBoundingClientRect();
1341
+ const sx = ir.width / this.#frame.w, sy = ir.height / this.#frame.h;
1342
+ const box = (r) => {
1343
+ if (!show || !r) return null;
1344
+ const x0 = Math.max(r.x, 0), y0 = Math.max(r.y, 0);
1345
+ const x1 = Math.min(r.x + r.w, this.#frame.w), y1 = Math.min(r.y + r.h, this.#frame.h);
1346
+ if (x1 <= x0 || y1 <= y0) return null;
1347
+ return { left: ir.left - vr.left + x0 * sx, top: ir.top - vr.top + y0 * sy, width: (x1 - x0) * sx, height: (y1 - y0) * sy };
1348
+ };
1349
+ const place = (el, b) => {
1350
+ el.classList.toggle("on", !!b);
1351
+ if (b) Object.assign(el.style, { left: `${b.left}px`, top: `${b.top}px`, width: `${b.width}px`, height: `${b.height}px` });
1352
+ return b;
1353
+ };
1354
+ const hb = place(hlHover, box(this.#hover?.rect));
1355
+ const sb = place(hlSel, box(this.#sel?.rect));
1356
+
1357
+ hlLabel.classList.toggle("on", !!hb);
1358
+ if (hb) {
1359
+ const r = this.#hover.rect;
1360
+ hlLabel.textContent = `${this.#hover.label} ${Math.round(r.w)}×${Math.round(r.h)}`;
1361
+ // above the box, else below it, else inside its top edge
1362
+ const top = hb.top >= 22 ? hb.top - 22 : hb.top + hb.height + 26 <= vr.height ? hb.top + hb.height + 4 : hb.top + 4;
1363
+ Object.assign(hlLabel.style, { left: `${hb.left + (top === hb.top + 4 ? 4 : 0)}px`, top: `${top}px` });
1364
+ }
1365
+
1366
+ // input below the selected element, above it if there is no room
1367
+ if (!note.hidden) {
1368
+ const W = note.offsetWidth, H = note.offsetHeight, pad = 8;
1369
+ const b = sb ?? { left: pad, top: pad, width: 0, height: 0 };
1370
+ let top = b.top + b.height + pad;
1371
+ if (top + H > vr.height - pad) top = b.top - H - pad;
1372
+ top = Math.max(pad, Math.min(top, vr.height - H - pad));
1373
+ const left = Math.max(pad, Math.min(b.left, vr.width - W - pad));
1374
+ Object.assign(note.style, { left: `${left}px`, top: `${top}px` });
1375
+ }
1376
+ }
1377
+
1378
+ #bindAnnotate() {
1379
+ const { img, annotBtn, note, noteBox, noteHint } = this.$;
1380
+ annotBtn.addEventListener("click", () => this.#setAnnotate(!this.#annot));
1381
+ img.addEventListener("mousemove", (e) => {
1382
+ if (!this.#annot || !this.#frame.w) return;
1383
+ this.#hoverPt = this.#point(e);
1384
+ this.#probeHover();
1385
+ });
1386
+ img.addEventListener("mouseleave", () => {
1387
+ if (!this.#annot) return;
1388
+ this.#hoverPt = null;
1389
+ this.#hover = null;
1390
+ this.#drawHl();
1391
+ });
1392
+ img.addEventListener("click", async (e) => {
1393
+ if (!this.#annot || !this.#frame.w) return;
1394
+ e.preventDefault();
1395
+ const t = await this.#inspect(this.#point(e), true);
1396
+ if (t && this.#annot) this.#select(t);
1397
+ });
1398
+ new ResizeObserver(() => this.#drawHl()).observe(img);
1399
+ note.addEventListener("submit", async (e) => {
1400
+ e.preventDefault();
1401
+ const text = noteBox.value.trim();
1402
+ const target = this.#sel;
1403
+ if (!text || !target) return;
1404
+ noteHint.textContent = "보내는 중…";
1405
+ const res = await this.send(text, "annotate", { target });
1406
+ if (!res.ok) { noteHint.textContent = `전송 실패: ${res.error}`; return; }
1407
+ noteBox.value = "";
1408
+ if (this.#sel === target) this.#select(null);
1409
+ });
1410
+ noteBox.addEventListener("keydown", (e) => {
1411
+ if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); note.requestSubmit(); }
1412
+ if (e.key === "Escape") { e.preventDefault(); this.#select(null); }
1413
+ });
1414
+ }
1415
+
1100
1416
  #renderScreenMeta(m) {
1101
1417
  const { live, url, viewport } = this.$;
1102
1418
  if (!m.connected) {
@@ -1112,6 +1428,7 @@ class AgentBridge extends HTMLElement {
1112
1428
  url.textContent = m.title ? `${m.title} · ${m.url}` : m.url || "";
1113
1429
  url.title = m.url || "";
1114
1430
  }
1431
+ this.#drawHl();
1115
1432
  this.#emit("agent-screen", m);
1116
1433
  }
1117
1434
 
@@ -1248,7 +1565,7 @@ class AgentBridge extends HTMLElement {
1248
1565
  return;
1249
1566
  } else if (m.role === "assistant") {
1250
1567
  wrap.className = "msg assistant";
1251
- meta.textContent = `에이전트 · ${time}`;
1568
+ meta.textContent = `에이전트${m.script ? ` · 대본 ${m.script}` : ""} · ${time}`;
1252
1569
  if (this.#md) bubble.innerHTML = this.#md(m.text);
1253
1570
  else { bubble.textContent = m.text; bubble.classList.add("plain"); }
1254
1571
  } else if (m.role === "browser") {
@@ -1310,6 +1627,7 @@ class AgentBridge extends HTMLElement {
1310
1627
  this.$.buttons.forEach((b) => (b.disabled = !canSend));
1311
1628
  if (!s || !s.alive || s.status === "idle") this.#tools.clear();
1312
1629
  this.#renderActivity();
1630
+ this.#renderBusy();
1313
1631
  this.#emit("agent-status", s);
1314
1632
  }
1315
1633
 
@@ -1318,7 +1636,7 @@ class AgentBridge extends HTMLElement {
1318
1636
  const images = this.#images.map(({ type, data }) => ({ type, data }));
1319
1637
  if (action === "message" && !text && !images.length) return;
1320
1638
  this.$.buttons.forEach((b) => (b.disabled = true));
1321
- const res = await this.send(text, action, images);
1639
+ const res = await this.send(text, action, { images: images.length ? images : undefined });
1322
1640
  if (res.ok) {
1323
1641
  this.$.box.value = "";
1324
1642
  this.$.hint.textContent = "";