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.
- package/README.md +24 -35
- package/bin/agello.ts +0 -68
- package/package.json +6 -2
- package/src/panes.ts +86 -0
- package/src/screencast.ts +1 -71
- package/src/server.ts +84 -133
- package/src/terminal.ts +122 -0
- package/web/agent-bridge.js +408 -277
- package/web/index.html +12 -0
- package/web/terminal.js +121 -0
- package/src/player.ts +0 -234
package/web/agent-bridge.js
CHANGED
|
@@ -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',
|
|
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": "손 내림",
|
|
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="
|
|
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
|
|
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="
|
|
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
|
-
#
|
|
392
|
-
#
|
|
393
|
-
#
|
|
394
|
-
#
|
|
395
|
-
#
|
|
396
|
-
#
|
|
397
|
-
#
|
|
398
|
-
#
|
|
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"),
|
|
456
|
+
controlBtn: q(".control-btn"), kbd: q(".kbd"),
|
|
421
457
|
body: q(".body"), screen: q(".screen"), captions: q(".captions"),
|
|
422
|
-
|
|
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
|
-
|
|
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
|
|
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({
|
|
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
|
-
|
|
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);
|
|
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.#
|
|
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"
|
|
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,
|
|
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
|
-
|
|
698
|
-
|
|
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.
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
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
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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()
|
|
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 =
|
|
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
|
-
|
|
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) {
|
|
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();
|