agello 0.2.1 → 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.
@@ -24,6 +24,12 @@
24
24
  // (tool calls are also kept in the conversation as one-line rows, like the terminal)
25
25
  // agent-screen detail: { connected, browser?, url?, title?, agentControlled? }
26
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).
27
33
  //
28
34
  // Presentation mode (started by the agent: `agello present [x,y,w,h]`)
29
35
  // The screen panel expands to fill the window and shows only the given
@@ -36,12 +42,9 @@
36
42
  // closing it without asking sends action=hand-lower so it can go on.
37
43
  // The viewer's questions show as bubbles too, with the same 10s lifetime.
38
44
  // An end button above it stops the presentation for every viewer.
39
- // Script lines (`agello script` + `present resume`) arrive as agent messages
40
- // with `script: "step.line"`. While the script is not playing and the agent
41
- // is working (e.g. answering a question), a small loader is shown.
42
45
  //
43
46
  // Methods
44
- // el.send(text, action = 'message') -> Promise<{ok, error?}>
47
+ // el.send(text, action = 'message', images = [{type, data (base64)}]) -> Promise<{ok, error?}>
45
48
  // el.clearHistory() clear saved chat for the current pane
46
49
  // el.stopPresent() end presentation mode, tell the agent -> Promise<{ok, error?, notified?}>
47
50
 
@@ -76,6 +79,57 @@ const ACTIONS = {
76
79
  };
77
80
 
78
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
+
79
133
  :host {
80
134
  --_bg: var(--ab-bg, #f6f6f4); --_panel: var(--ab-panel, #fff); --_text: var(--ab-text, #1f1f1f);
81
135
  --_muted: var(--ab-muted, #6b6b6b); --_line: var(--ab-line, #e4e4e0); --_accent: var(--ab-accent, #2f6fed);
@@ -100,7 +154,6 @@ header { display: flex; align-items: center; gap: 10px; padding: 10px 16px; bord
100
154
  background: var(--_panel); }
101
155
  :host([no-header]) header { display: none; }
102
156
  h1 { font-size: 14px; margin: 0; font-weight: 600; }
103
- .clear-btn { font-size: 11px; padding: 2px 9px; border-radius: 999px; }
104
157
  .session { margin-left: auto; display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--_muted); }
105
158
  .dot { width: 9px; height: 9px; border-radius: 50%; background: var(--_muted); }
106
159
  .dot.idle, .dot.done { background: var(--_ok); } .dot.working { background: var(--_busy); }
@@ -202,14 +255,6 @@ h1 { font-size: 14px; margin: 0; font-weight: 600; }
202
255
  .caption a { color: inherit; }
203
256
  @keyframes capIn { from { opacity: 0; translate: 0 10px; } }
204
257
 
205
- /* agent busy while presenting (answering, fixing the script): minimal loader */
206
- .busy { display: none; }
207
- .body.present .busy.on { position: absolute; left: 22px; bottom: 28px; z-index: 5; display: flex; align-items: center;
208
- gap: 9px; padding: 9px 14px; border-radius: 999px; font-size: 13px; color: #fff; background: rgba(20, 20, 22, .72);
209
- -webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px); box-shadow: 0 8px 30px rgba(0,0,0,.35);
210
- animation: capIn .25s ease; }
211
- .busy .spinner { border-color: rgba(255,255,255,.25); border-top-color: #fff; }
212
-
213
258
  /* raise hand: ask the agent mid-presentation */
214
259
  .hand-wrap { display: none; }
215
260
  .body.present .hand-wrap { position: absolute; right: 22px; bottom: 28px; z-index: 5; display: flex;
@@ -314,18 +359,38 @@ button.primary { background: var(--_accent); border-color: var(--_accent); color
314
359
  button:disabled { cursor: not-allowed; opacity: .45; }
315
360
  :host([no-actions]) .secondary { display: none; }
316
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; }
317
369
  `;
318
370
 
319
371
  const TEMPLATE = `
320
- <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>
321
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>
322
386
  <div class="stage">
323
387
  <div class="log" part="log"><div class="inner"><div class="empty">이 pane의 대화가 브라우저에 저장돼요.</div></div><div class="inner queue" part="queue"></div></div>
324
388
  <div class="activity" part="activity"></div>
325
389
  </div>
326
390
  <footer part="composer"><div class="composer">
327
391
  <div class="grip" role="separator" aria-orientation="horizontal" aria-label="입력창 높이 조절 (위아래 방향키)" tabindex="0" title="끌어서 입력창 높이 조절"></div>
328
- <textarea placeholder="에이전트에게 보낼 메시지 (Enter 전송, Shift+Enter 줄바꿈)"></textarea>
392
+ <textarea placeholder="에이전트에게 보낼 메시지 (Enter 전송, Shift+Enter 줄바꿈, 이미지 붙여넣기·끌어놓기)"></textarea>
393
+ <div class="attachments" aria-label="첨부 이미지"></div>
329
394
  <div class="row">
330
395
  <button class="secondary" data-action="approve">승인</button>
331
396
  <button class="secondary" data-action="reject">거절</button>
@@ -339,7 +404,6 @@ const TEMPLATE = `
339
404
  <button class="control-btn" role="switch" aria-checked="false" title="켜면 마우스와 키보드 입력이 브라우저로 전달돼요 (끄기: Shift+Esc)">
340
405
  <span>내 조작</span><span class="track"><span class="knob"></span></span><span class="state-label">OFF</span></button></div>
341
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>
342
- <div class="busy" part="busy" role="status"><span class="spinner"></span><span>답변 준비 중</span></div>
343
407
  <div class="hand-wrap">
344
408
  <form class="ask" part="ask" hidden><textarea aria-label="에이전트에게 질문" placeholder="에이전트에게 질문 (Enter 전송, Esc 닫기)"></textarea>
345
409
  <div class="ask-row"><span class="ask-hint"></span><button type="submit" class="primary">보내기</button></div></form>
@@ -359,6 +423,15 @@ class AgentBridge extends HTMLElement {
359
423
  #es = null;
360
424
  #screenEs = null;
361
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;
362
435
  #frame = { w: 0, h: 0 };
363
436
  #moveQueued = null;
364
437
  #status = null;
@@ -380,9 +453,9 @@ class AgentBridge extends HTMLElement {
380
453
  activity: q(".activity"), box: q("textarea"), hint: q(".hint"),
381
454
  buttons: this.#root.querySelectorAll("button[data-action]"),
382
455
  live: q(".live"), url: q(".screen-bar .url"), viewport: q(".viewport"), img: q(".viewport img"),
383
- controlBtn: q(".control-btn"), kbd: q(".kbd"), clearBtn: q(".clear-btn"),
456
+ controlBtn: q(".control-btn"), kbd: q(".kbd"),
384
457
  body: q(".body"), screen: q(".screen"), captions: q(".captions"),
385
- busy: q(".busy"), hand: q(".hand"), end: q(".end"), ask: q(".ask"), askBox: q(".ask textarea"), askHint: q(".ask-hint"),
458
+ hand: q(".hand"), end: q(".end"), ask: q(".ask"), askBox: q(".ask textarea"), askHint: q(".ask-hint"),
386
459
  };
387
460
  this.$.buttons.forEach((b) => b.addEventListener("click", () => this.#submit(b.dataset.action)));
388
461
  this.#bindGrip();
@@ -395,7 +468,10 @@ class AgentBridge extends HTMLElement {
395
468
  loadMarkdown().then((md) => (this.#md = md));
396
469
  this.#bindControl();
397
470
  this.#bindHand();
398
- 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();
399
475
  }
400
476
 
401
477
  get server() {
@@ -408,6 +484,7 @@ class AgentBridge extends HTMLElement {
408
484
  this.#syncScreen();
409
485
  }
410
486
  disconnectedCallback() {
487
+ this.#showTerminal(false);
411
488
  this.#es?.close();
412
489
  this.#es = null;
413
490
  this.#screenEs?.close();
@@ -416,16 +493,291 @@ class AgentBridge extends HTMLElement {
416
493
  }
417
494
  attributeChangedCallback(name) {
418
495
  if (name === "heading") this.$.heading.textContent = this.getAttribute("heading") || "agello";
419
- 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(); }
420
497
  if (name === "screen" && this.isConnected) this.#syncScreen();
421
498
  }
422
499
 
423
- async send(text, action = "message") {
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 = []) {
424
776
  try {
425
777
  const res = await fetch(`${this.server}/send`, {
426
778
  method: "POST",
427
779
  headers: { "Content-Type": "application/json" },
428
- body: JSON.stringify({ action, text }),
780
+ body: JSON.stringify({ action, text, images: images.length ? images : undefined }),
429
781
  });
430
782
  return await res.json();
431
783
  } catch {
@@ -464,8 +816,7 @@ class AgentBridge extends HTMLElement {
464
816
  on("message", (m) => {
465
817
  this.#addMessage(m);
466
818
  this.#saveMessage(m);
467
- // script lines carry their own on-screen time (>= 10s), others use CAPTION_MS
468
- 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);
469
820
  if (this.#present.on && m.role === "browser" && m.action === "message") this.#caption(m.text, "user");
470
821
  this.#emit("agent-message", m);
471
822
  });
@@ -511,9 +862,8 @@ class AgentBridge extends HTMLElement {
511
862
  const was = this.#present.on;
512
863
  this.#present = p ?? { on: false };
513
864
  this.#emit("agent-present", this.#present);
514
- this.#renderBusy();
515
865
  if (on === was) return;
516
- if (on) this.#setControl(false);
866
+ if (on) { this.#showTerminal(false); this.#setControl(false); }
517
867
  else { this.$.captions.replaceChildren(); this.#openAsk(false); this.#handUp = false; }
518
868
  this.#syncScreen();
519
869
  // FLIP: grow from (or shrink back to) the side panel
@@ -529,12 +879,6 @@ class AgentBridge extends HTMLElement {
529
879
  { duration: on ? 420 : 360, easing: "cubic-bezier(.2, .7, .2, 1)" });
530
880
  }
531
881
 
532
- #renderBusy() {
533
- const s = this.#status;
534
- const busy = this.#present.on && s?.alive && s.status === "working" && this.#present.player?.state !== "playing";
535
- this.$.busy.classList.toggle("on", !!busy);
536
- }
537
-
538
882
  // Raise hand: open a small input over the presentation and send it as a message.
539
883
  #bindHand() {
540
884
  const { hand, ask, askBox, askHint } = this.$;
@@ -594,7 +938,7 @@ class AgentBridge extends HTMLElement {
594
938
 
595
939
  // Agent reply as a short-lived bubble over the presented screen.
596
940
  // kind "user": the viewer's own question (plain text, accent colour).
597
- #caption(text, kind = "agent", ms = AgentBridge.CAPTION_MS) {
941
+ #caption(text, kind = "agent") {
598
942
  if (!text?.trim()) return;
599
943
  const el = document.createElement("div");
600
944
  el.className = `caption ${kind}`;
@@ -609,7 +953,7 @@ class AgentBridge extends HTMLElement {
609
953
  el.classList.add("out");
610
954
  setTimeout(() => el.remove(), 500);
611
955
  };
612
- setTimeout(leave, ms);
956
+ setTimeout(leave, AgentBridge.CAPTION_MS);
613
957
  const live = [...box.children].filter((c) => !c.classList.contains("out"));
614
958
  live.slice(0, Math.max(0, live.length - AgentBridge.CAPTION_MAX)).forEach((c) => c.__leave?.());
615
959
  el.__leave = leave;
@@ -904,7 +1248,7 @@ class AgentBridge extends HTMLElement {
904
1248
  return;
905
1249
  } else if (m.role === "assistant") {
906
1250
  wrap.className = "msg assistant";
907
- meta.textContent = `에이전트${m.script ? ` · 대본 ${m.script}` : ""} · ${time}`;
1251
+ meta.textContent = `에이전트 · ${time}`;
908
1252
  if (this.#md) bubble.innerHTML = this.#md(m.text);
909
1253
  else { bubble.textContent = m.text; bubble.classList.add("plain"); }
910
1254
  } else if (m.role === "browser") {
@@ -966,16 +1310,22 @@ class AgentBridge extends HTMLElement {
966
1310
  this.$.buttons.forEach((b) => (b.disabled = !canSend));
967
1311
  if (!s || !s.alive || s.status === "idle") this.#tools.clear();
968
1312
  this.#renderActivity();
969
- this.#renderBusy();
970
1313
  this.#emit("agent-status", s);
971
1314
  }
972
1315
 
973
1316
  async #submit(action) {
974
1317
  const text = this.$.box.value.trim();
975
- if (action === "message" && !text) return;
1318
+ const images = this.#images.map(({ type, data }) => ({ type, data }));
1319
+ if (action === "message" && !text && !images.length) return;
976
1320
  this.$.buttons.forEach((b) => (b.disabled = true));
977
- const res = await this.send(text, action);
978
- 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
+ }
979
1329
  else this.$.hint.textContent = `전송 실패: ${res.error}`;
980
1330
  this.#renderStatus(this.#status);
981
1331
  this.$.box.focus();
package/web/index.html CHANGED
@@ -12,5 +12,17 @@
12
12
  </head>
13
13
  <body>
14
14
  <agent-bridge screen></agent-bridge>
15
+ <script>
16
+ // Keep the pane picked in the drawer across reloads (?server=http://127.0.0.1:<port>).
17
+ const bridge = document.querySelector("agent-bridge");
18
+ const saved = new URLSearchParams(location.search).get("server");
19
+ if (saved && /^http:\/\/127\.0\.0\.1:\d+$/.test(saved)) bridge.setAttribute("server", saved);
20
+ bridge.addEventListener("agent-pane", (e) => {
21
+ const url = new URL(location.href);
22
+ if (e.detail.server === location.origin) url.searchParams.delete("server");
23
+ else url.searchParams.set("server", e.detail.server);
24
+ history.replaceState(null, "", url);
25
+ });
26
+ </script>
15
27
  </body>
16
28
  </html>