dsh-inner-browser-use 0.34.28 → 0.34.96

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 CHANGED
@@ -10,17 +10,22 @@ live picture of that browser, and your own cursor keeps working on the picture.
10
10
 
11
11
  | Tool | What it does |
12
12
  | --- | --- |
13
- | `sidecar_open` | Starts the browser (or reuses the running one) and opens an address. `headless: false` shows a real window on the desktop instead. |
14
- | `sidecar_view` | Returns the address, the title, every control as `1 … 120`, and the visible text. |
15
- | `sidecar_extract` | Returns the items a CSS selector matches, one compact line each with its link, instead of the whole page. |
16
- | `sidecar_click` | Clicks a numbered control, or an `x`/`y` point. `double: true` double-clicks. A cookie banner or modal over the control is cleared first, then the click happens. |
17
- | `sidecar_type` | Types text into the focused field, or clicks a numbered control first. Chinese goes in as one string. `submit: true` presses Enter. |
18
- | `sidecar_key` | Presses one key: Enter, Tab, Escape, Backspace, Delete, ArrowUp/Down, Home, End, PageUp, PageDown. |
19
- | `sidecar_scroll` | Scrolls the wheel at the centre of the page, or at `x`/`y`. `dy: 600` is about a screen down. |
20
- | `sidecar_shot` | Saves a full-size PNG into `~/.dsh-inner-browser-use/screenshots` and returns the path. |
21
- | `sidecar_state` | Reports whether the browser runs, which profile it uses, its address and title, and how many columns are showing it. |
22
-
23
- `sidecar_view` answers with one page of text:
13
+ | `browser_open` | Starts the browser (or reuses the running one) and opens an address. `headless: false` shows a real window on the desktop instead. |
14
+ | `browser_view` | Returns the address, the title, every control as `1 … 120`, and the visible text. |
15
+ | `browser_read` | Reads the page the way a person does: one Markdown block of headings, paragraphs, lists and links, no controls. Far fewer tokens than a snapshot on long pages, and the links stay usable. Give it a `query` and only the passages that answer it come back, ranked (BM25 over Readability-style blocks, link-heavy menus down-weighted). |
16
+ | `browser_find` | Finds words (or a regex) anywhere on the page: matching controls come back with a clickable number, matching text with its point, each saying whether it is on screen. |
17
+ | `browser_extract` | Returns the items a CSS selector matches, one compact line each with its link, instead of the whole page. |
18
+ | `browser_click` | Clicks a numbered control, or an `x`/`y` point. `double: true` double-clicks. A cookie banner or modal over the control is cleared first, then the click happens. |
19
+ | `browser_type` | Types text into the focused field, or clicks a numbered control first. Chinese goes in as one string. `submit: true` presses Enter. |
20
+ | `browser_key` | Presses one key: Enter, Tab, Escape, Backspace, Delete, Insert, arrows, Home, End, PageUp, PageDown, F1-F12, the numeric keypad, letters, digits, punctuation and every shifted symbol, plus ContextMenu, CapsLock, PrintScreen and media keys. `Ctrl`/`Alt`/`Shift`/`Meta`/`Mod` modifiers, chords with `+`, and `Ctrl+C/X/V/A/Z/Y` run the real editing commands. |
21
+ | `browser_scroll` | Scrolls the wheel at the centre of the page, or at `x`/`y`. `dy: 600` is about a screen down. |
22
+ | `browser_shot` | Saves a full-size PNG into `~/.dsh-inner-browser-use/screenshots` and returns the path. |
23
+ | `browser_state` | Reports whether the browser runs, which profile it uses, its address and title, and how many columns are showing it. |
24
+
25
+ `browser_read` on the same page is the words only, in Markdown (measured: 4,520
26
+ characters of an about page against 2,987 characters of snapshot — and the
27
+ snapshot spends most of its budget on controls; on a long article the gap grows
28
+ with the number of controls skipped). `browser_view` answers with one page of text:
24
29
 
25
30
  ```
26
31
  https://example.com
@@ -34,8 +39,9 @@ Numbers stay valid until the next read. Anything that changes the page returns a
34
39
 
35
40
  ## The column
36
41
 
37
- The pane is a picture of the browser, sent as JPEG over the session's own stream: 1440×900,
38
- quality 72, every frame, about 82 KB and 25 per second on a page at rest.
42
+ The pane is a picture of the browser, sent as JPEG over the session's own stream: quality 72,
43
+ every frame, about 82 KB and 25 per second on a page at rest. The page is laid out at the size
44
+ of the column, so the picture fills the frame instead of sitting letterboxed inside it.
39
45
 
40
46
  Two pointers live on that picture:
41
47
 
@@ -82,7 +88,9 @@ Screenshots are the only thing written outside the profile:
82
88
  | Read, dense page | 444 tokens (1510 chars, 90 controls) |
83
89
  | Read round trip | 1–4 ms |
84
90
  | Click round trip | 2 ms |
85
- | Screenshot | 77 ms, 113 KB |
91
+ | Key round trip | 7 ms |
92
+ | Key to next frame | 15–18 ms |
93
+ | Screenshot | 75 ms, 115 KB |
86
94
 
87
95
  ## Install
88
96
 
@@ -102,6 +110,22 @@ dsh plugin install ./dsh-inner-browser-use-0.16.0.tgz
102
110
  The plugin appears under **Plugins** as *Background browser*. It needs Google Chrome or
103
111
  Chromium on the machine; see [docs/COMPATIBILITY.md](docs/COMPATIBILITY.md).
104
112
 
113
+ ## When it opens
114
+
115
+ Only when the person asks for it by name: "in the browser", "on the web page",
116
+ "open this site", "click it for me". A link on its own is not a request.
117
+
118
+ - A public article, one link's text, docs, a GitHub repo or PR: read it with
119
+ `web_fetch` or Envoy. No browser, no Chrome process.
120
+ - If the page needs a login, is an empty shell to `web_fetch`, or needs a click,
121
+ say why and ask once instead of starting a browser on your own.
122
+ - Asked for, it starts without a question and never opens a second, external
123
+ Chrome. The picture stays in this chat's right column and stays open when the
124
+ work is done.
125
+
126
+ One browser is shared by every chat (see [Sharing](#sharing)), so opening one is
127
+ a tab, not a second Chrome.
128
+
105
129
  ## Use
106
130
 
107
131
  1. Open the chat you want the agent to browse for.
@@ -110,24 +134,35 @@ Chromium on the machine; see [docs/COMPATIBILITY.md](docs/COMPATIBILITY.md).
110
134
  The first call starts the browser and puts its mirror in this chat's right column. Nothing
111
135
  opens before a call: a chat that never asked for a page keeps its column as it was.
112
136
 
137
+ ## Sharing
138
+
139
+ Every chat uses the same background Chrome: one process, one tab per chat, and
140
+ each tab's address saved so it can be closed and reopened cheaply. Past six
141
+ chats the oldest tab nobody is watching is closed (a watched tab — the one on
142
+ someone's screen — never is). Tabs an earlier plugin load left behind are closed
143
+ on first use: one profile used for weeks otherwise keeps a renderer per chat that
144
+ ever opened it. Nothing is launched per chat, and the browser itself is closed 15
145
+ minutes after the last mirror is detached.
146
+
113
147
  ## Frames, downloads, network
114
148
 
115
149
  - **Frames are read, including cross-site ones.** Controls inside an iframe are numbered like
116
150
  the page's own, whether Chrome shares the page's process or runs the frame apart; a click in
117
151
  either lands where the frame actually sits.
118
152
  - **A download lands on disk.** A click that saves a file writes it into the browser's own
119
- profile folder, and `sidecar_downloads` names it, its size and when it arrived.
120
- - **The network is visible.** `sidecar_network` lists what the page asked for, newest first,
153
+ profile folder, and `browser_downloads` names it, its size and when it arrived.
154
+ - **The network is visible.** `browser_network` lists what the page asked for, newest first,
121
155
  so a page that fills itself in from an address can be understood without a screenshot.
122
156
  - **An overlay does not eat the action.** A cookie banner over the button is cleared the way a
123
157
  person would clear it, then the click lands — in the same call, and in every step of a
124
- `sidecar_do` batch. Anything offering to delete, pay or sign out is reported instead.
158
+ `browser_do` batch. Anything offering to delete, pay or sign out is reported instead.
125
159
 
126
160
  ## What it does not do
127
161
 
128
162
  - **It does not touch your Chrome.** The browser runs from a separate profile with no
129
163
  connection to the window you are using.
130
- - **The viewport is fixed.** 1440×900, desktop layout, no resizing and no mobile emulation.
164
+ - **The layout viewport is the column, nothing more.** Desktop layout, no mobile emulation, no
165
+ touch emulation; a very narrow column is laid out narrow rather than zoomed out.
131
166
  - **One page at a time.** A link that opens a tab is followed — the mirror lands on the new
132
167
  page and the popup closes — so there is never a second page to choose between.
133
168
 
package/README.zh.md CHANGED
@@ -9,17 +9,21 @@ profile,不是产品自带的那个浏览页签:右栏放的是它的画面
9
9
 
10
10
  | 工具 | 作用 |
11
11
  | --- | --- |
12
- | `sidecar_open` | 启动浏览器(已在跑就复用)并打开一个地址。传 `headless: false` 会改为在桌面上开真实窗口。 |
13
- | `sidecar_view` | 返回地址、标题、编号成 `1 … 120` 的控件,以及可见文字。 |
14
- | `sidecar_extract` | 按 CSS 选择器把列表项读成一行一条、带链接,比整页读省得多。 |
15
- | `sidecar_click` | 点编号控件,或点 `x`/`y` 坐标。`double: true` 双击。控件被横幅或弹层挡着时,先关掉它再点。 |
16
- | `sidecar_type` | 往焦点输入框输入文字,也可以先点一个编号控件。中文整段送进去。`submit: true` 回车提交。 |
17
- | `sidecar_key` | 按一个键:Enter、Tab、Escape、Backspace、Delete、上下左右、Home、End、PageUp、PageDown。 |
18
- | `sidecar_scroll` | 在页面中心滚轮,也可以指定 `x`/`y`。`dy: 600` 约一屏。 |
19
- | `sidecar_shot` | 存一张原尺寸 PNG 到 `~/.dsh-inner-browser-use/screenshots`,返回路径。 |
20
- | `sidecar_state` | 报浏览器在不在跑、用的哪个 profile、地址、标题,以及有几个右栏在看它。 |
21
-
22
- `sidecar_view` 一次给一页文本:
12
+ | `browser_open` | 启动浏览器(已在跑就复用)并打开一个地址。传 `headless: false` 会改为在桌面上开真实窗口。 |
13
+ | `browser_view` | 返回地址、标题、编号成 `1 … 120` 的控件,以及可见文字。 |
14
+ | `browser_read` | 按人读文章的方式给 Markdown(标题、段落、列表、链接)。带 `query` 只回能回答的段落;问不到时自动改为整页,不再空手回来。页面和上次读的没变就只回一行,不重发正文(`force: true` 强制重发)。 |
15
+ | `browser_extract` | 按 CSS 选择器把列表项读成一行一条、带链接,比整页读省得多。 |
16
+ | `browser_view` 的 `map` | 大页面先看地标:各类控件各多少个、去重后的控件和编号、正文多少字,但不带回正文。 |
17
+ | `browser_click` | 点编号控件,或点 `x`/`y` 坐标。`double: true` 双击。控件被横幅或弹层挡着时,先关掉它再点。 |
18
+ | `browser_type` | 往焦点输入框输入文字,也可以先点一个编号控件。中文整段送进去。`submit: true` 回车提交。 |
19
+ | `browser_key` | 按一个键:Enter、Tab、Escape、Backspace、Delete、上下左右、Home、End、PageUp、PageDown。 |
20
+ | `browser_scroll` | 在页面中心滚轮,也可以指定 `x`/`y`。`dy: 600` 约一屏。 |
21
+ | `browser_shot` | 存一张原尺寸 PNG 到 `~/.dsh-inner-browser-use/screenshots`,返回路径。 |
22
+ | `browser_state` | 报浏览器在不在跑、用的哪个 profile、地址、标题,以及有几个右栏在看它。 |
23
+
24
+ 点、输入、按键之后的那一行会多一句结果:`· navigated to …`(换页了)、`· content changed`(页内有变化)、`· no visible change`(点了没动)、Flash 页则是 `· screen updated` / `· no frame change`;`read: true` 时不再多说。
25
+
26
+ `browser_view` 一次给一页文本:
23
27
 
24
28
  ```
25
29
  https://example.com
@@ -72,6 +76,7 @@ profile 之外只写一样东西:`~/.dsh-inner-browser-use/screenshots/` 里
72
76
  | 单帧 | 1440×900 JPEG,约 82 KB |
73
77
  | 画面 | 约 25 帧每秒 |
74
78
  | 指针移动到箭头跟上 | 0.8–7 ms |
79
+ | 右栏里手动点击到画面更新 | 中位 13 ms,最坏 17 ms(点击派发 2 ms、悬停 8 ms) |
75
80
 
76
81
  ## 安装
77
82
 
@@ -98,12 +103,19 @@ dsh plugin install ./dsh-inner-browser-use-0.12.1.tgz
98
103
  第一次调用会启动浏览器,并把镜像放进这个会话的右栏。没有调用就不会有东西自己打开:没要过
99
104
  页面的会话,右栏保持原样。
100
105
 
106
+ ## Flash 页面
107
+
108
+ `browser_open` 带 `flash: true` 时,这个地址改用插件自带的 Flash 运行时(Electron 87 + Pepper Flash)在**离屏窗口**里播放:窗口一次都不显示,Dock 与菜单栏都不出现,画面只在发起会话的右栏里。切换回来再调一次 `flash: false`,选择会记住。运行时首次使用时下载到 `~/.dsh/inner-browser-use/flash/`,不需要单独安装播放器。
109
+
110
+ `npm run flash-hidden` 会真起一次运行时并断言「窗口未显示 / 帧持续产出 / 两帧不同」;`npm run flash-mirror` 量镜像帧率与按下/抬起/按键耗时;`npm run flash-tools` 量工具层成本(奥拉星登录页实测:点击 28 ms、输入 14 ms、`browser_view` 31 ms;同一页后续回话只说标题,点击 22 字符、输入 13 字符);`npm run flash-local` 用仓库自带的 SWF 在本机起页,不依赖网络,量首帧与输入直达。
111
+
101
112
  ## 框架、下载、网络
102
113
 
103
114
  - **iframe 能读,跨站也能。** 不管是和页面共用进程,还是 Chrome 单独给一个进程,里面的控件都和页面自己的控件一样有编号;点击按框架在页面上的实际位置落点。
104
- - **下载会落到磁盘。** 点一下就下载的文件写在这个浏览器自己的 profile 目录里,`sidecar_downloads` 报出文件名、大小和到达时间。
105
- - **能看到网络。** `sidecar_network` 倒序列出页面请求了什么,自己用接口填内容、屏幕上又看不出原因的页面,不用截图去猜。
106
- - **遮罩不吞动作。** 盖在按钮上的 cookie 横幅会按人的做法先关掉再点下去,同一次调用里完成;`sidecar_do` 的每一步都一样。写着删除、支付、退出的遮罩只报告,不代按。
115
+ - **下载会落到磁盘。** 点一下就下载的文件写在这个浏览器自己的 profile 目录里,`browser_downloads` 报出文件名、大小和到达时间。
116
+ - **PDF 有去处。** Chrome 无头进程画不出 PDF,右栏只会是白图;读到 PDF 时把文件取回归档到该浏览器 profile 的 `downloads/`,结果里直接给绝对路径,交给 `envoy_read` 或本地阅读器。
117
+ - **能看到网络。** `browser_network` 倒序列出页面请求了什么,自己用接口填内容、屏幕上又看不出原因的页面,不用截图去猜。
118
+ - **遮罩不吞动作。** 盖在按钮上的 cookie 横幅会按人的做法先关掉再点下去,同一次调用里完成;`browser_do` 的每一步都一样。写着删除、支付、退出的遮罩只报告,不代按。
107
119
 
108
120
  ## 不做什么
109
121
 
package/client.js CHANGED
@@ -32,7 +32,7 @@ window.__ModuleLoader__.load({
32
32
  const DICT = {
33
33
  zh: {
34
34
  'side.label': '后台浏览器',
35
- 'side.empty': '还没有打开的页面,在上方地址栏输入网址',
35
+ 'side.empty': '后台的独立 Chrome 还没有页面,在上方地址栏输入网址',
36
36
  'side.restore': '恢复上次的页面',
37
37
  'side.connecting': '正在连接…',
38
38
  'side.failed': '画面没有连上',
@@ -45,7 +45,7 @@ window.__ModuleLoader__.load({
45
45
  },
46
46
  en: {
47
47
  'side.label': 'Background browser',
48
- 'side.empty': 'No page open; type an address above',
48
+ 'side.empty': 'The background Chrome has no page yet; type an address above',
49
49
  'side.restore': 'Reopen the last page',
50
50
  'side.connecting': 'Connecting…',
51
51
  'side.failed': 'The picture did not connect',
@@ -68,6 +68,10 @@ window.__ModuleLoader__.load({
68
68
  '.dshib-side-key:hover{background:var(--dsw-alias-bg-subtle);color:var(--dsw-alias-label-primary)}',
69
69
  '.dshib-side-url{flex:1 1 auto;min-width:0;border:1px solid transparent;border-radius:6px;background:var(--dsw-alias-bg-subtle);padding:4px 8px;font:inherit;font-size:12px;color:var(--dsw-alias-label-secondary);outline:none}',
70
70
  '.dshib-side-url:focus{border-color:var(--dsw-alias-border-strong);color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-base)}',
71
+ '.dshib-side-wins{flex:none;display:flex;gap:4px;padding:4px 6px;overflow-x:auto;border-bottom:1px solid var(--dsw-alias-separator)}',
72
+ '.dshib-side-win{flex:0 1 auto;min-width:0;max-width:180px;display:inline-flex;align-items:center;gap:4px;border:0;border-radius:6px;padding:2px 8px;background:transparent;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px;cursor:pointer}',
73
+ '.dshib-side-win[data-on="1"]{background:var(--dsw-alias-bg-subtle);color:var(--dsw-alias-label-primary)}',
74
+ '.dshib-side-win > span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
71
75
  '.dshib-side-stage{flex:1 1 auto;position:relative;min-width:0;min-height:0;display:flex;align-items:center;justify-content:center;overflow:hidden;background:var(--dsw-alias-bg-base);outline:none;cursor:default}',
72
76
  '.dshib-side-frame{display:block;user-select:none;-webkit-user-drag:none;cursor:default}',
73
77
  '.dshib-side-ime{position:absolute;width:1px;height:1px;padding:0;margin:0;border:0;resize:none;overflow:hidden;opacity:0;pointer-events:none}',
@@ -115,7 +119,7 @@ window.__ModuleLoader__.load({
115
119
  return ''
116
120
  }
117
121
  }
118
- const closeHere = (sessionId) => {
122
+ const closeHere = (sessionId, byPerson = false) => {
119
123
  /* A tab the person closed is not a tab they want back on return. */
120
124
  try {
121
125
  void fetch(`${ROUTE}/sidecar/forget?session=${encodeURIComponent(sessionId)}`, { method: 'POST' }) // ui-copy-ignore
@@ -132,6 +136,11 @@ window.__ModuleLoader__.load({
132
136
  const kind = String(record?.kind ?? record?.record?.kind ?? '')
133
137
  const id = String(record?.id ?? record?.record?.id ?? '')
134
138
  if (kind !== SIDE_KIND && id !== SIDE_ID) continue
139
+ /* The person's close arrives from the tab strip: the host is
140
+ already taking that tab down, and closing it again here races
141
+ the host and can leave the tab half closed. Forgetting the ask
142
+ is what this path owes. */
143
+ if (byPerson) continue
135
144
  try {
136
145
  if (typeof record?.close === 'function') record.close()
137
146
  else if (typeof surface?.closeIn === 'function') surface.closeIn(sessionId, id)
@@ -167,6 +176,12 @@ window.__ModuleLoader__.load({
167
176
  single: true,
168
177
  priority: 'extension',
169
178
  title: () => L('side.label'),
179
+ /* The tab strip's close control is not the pane's own button: a
180
+ tab closed there never told the host, the ask stayed alive, and
181
+ the keepalive put the mirror back a few seconds later — a tab
182
+ the person closed that would not stay closed. Every close path
183
+ ends here. */
184
+ onClose: (_tab, where) => closeHere(String(where?.sessionId ?? mountedOf()), true),
170
185
  })))
171
186
  disposers.push(scope.slots.inject(SLOT, () => scope.slots.register({
172
187
  name: SLOT, // ui-copy-ignore
@@ -227,7 +242,10 @@ window.__ModuleLoader__.load({
227
242
  const watchAsk = () => {
228
243
  try {
229
244
  if (typeof surface?.mounted?.subscribe === 'function') {
230
- const off = surface.mounted.subscribe(syncAsk)
245
+ const off = surface.mounted.subscribe(() => {
246
+ syncAsk()
247
+ restore()
248
+ })
231
249
  if (typeof off === 'function') return off
232
250
  }
233
251
  } catch {
@@ -236,10 +254,41 @@ window.__ModuleLoader__.load({
236
254
  return null
237
255
  }
238
256
  syncAsk()
257
+ /* An app restart leaves the browser alive but no ask on the wire, so
258
+ the column would come back empty in a chat that never closed it.
259
+ This chat asking for the state of the browser it still owns is what
260
+ puts the mirror back without anyone pressing anything. */
261
+ const restoreTimers = []
262
+ const restore = (tries = 0) => {
263
+ const who = mountedOf()
264
+ if (who === '') return
265
+ const again = () => {
266
+ if (tries >= 2) return
267
+ restoreTimers.push(setTimeout(() => restore(tries + 1), 1500))
268
+ }
269
+ try {
270
+ fetch(`${ROUTE}/sidecar/state?session=${encodeURIComponent(who)}`) // ui-copy-ignore
271
+ .then((said) => (said.ok === true ? said.json() : null))
272
+ .then((state) => {
273
+ if (state?.owner !== who) return
274
+ const live = state?.alive === true || state?.chat?.alive === true
275
+ if (live !== true) {
276
+ again()
277
+ return
278
+ }
279
+ ask(who)
280
+ })
281
+ .catch(again)
282
+ } catch {
283
+ again()
284
+ }
285
+ }
286
+ restore()
239
287
  const offAsk = watchAsk()
240
288
  const pollAsk = setInterval(syncAsk, 1000)
241
289
  const stopAsk = () => {
242
290
  clearInterval(pollAsk)
291
+ for (const timer of restoreTimers) clearTimeout(timer)
243
292
  if (typeof offAsk === 'function') {
244
293
  try {
245
294
  offAsk()
@@ -274,6 +323,32 @@ window.__ModuleLoader__.load({
274
323
  function SidecarBody(props) {
275
324
  const [frame, setFrame] = React.useState(null)
276
325
  const [ptr, setPtr] = React.useState(null)
326
+ /* The page is laid out at the size of this column, so the picture it
327
+ sends already has the right shape; these are its pixels. */
328
+ const [view, setView] = React.useState({ w: SIDE_VIEW.width, h: SIDE_VIEW.height })
329
+ /* The person at the mirror drives with their own hand: while they are,
330
+ the agent's arrow stays out of the way, and it comes back by itself
331
+ once their hold runs out. */
332
+ const [person, setPerson] = React.useState(false)
333
+ const personTimer = React.useRef(0)
334
+ /* The person's own hand hides the arrow the moment it lands, without
335
+ waiting for the browser to report a hold: the arrow is a claim about
336
+ what the agent did, and a claim that outlives the agent's turn is a lie
337
+ the person has to look past. It returns when their hand stops. */
338
+ const [hand, setHand] = React.useState(false)
339
+ const handTimer = React.useRef(0)
340
+ const touched = () => {
341
+ setHand(true)
342
+ clearTimeout(handTimer.current)
343
+ handTimer.current = setTimeout(() => setHand(false), 1500)
344
+ }
345
+ const hearHold = (message) => {
346
+ const hold = Number(message?.hold ?? 0)
347
+ if (!Number.isFinite(hold) || hold <= 0) return
348
+ setPerson(true)
349
+ clearTimeout(personTimer.current)
350
+ personTimer.current = setTimeout(() => setPerson(false), hold + 120)
351
+ }
277
352
  const [phase, setPhase] = React.useState('connecting')
278
353
  const [note, setNote] = React.useState('')
279
354
  /* The address this chat had before its tab went away: the pane reopens
@@ -283,6 +358,9 @@ window.__ModuleLoader__.load({
283
358
  const [draft, setDraft] = React.useState('')
284
359
  const [editing, setEditing] = React.useState(false)
285
360
  const [size, setSize] = React.useState({ w: 0, h: 0 })
361
+ /* The browser's own windows, this chat's only: a second window a site
362
+ opened is a row here, not a second tab in the column. */
363
+ const [wins, setWins] = React.useState([])
286
364
  const stage = React.useRef(null)
287
365
  /* Which chat owns the mirror, as the sidecar host records it. // ui-copy-ignore
288
366
  Empty until
@@ -384,11 +462,15 @@ window.__ModuleLoader__.load({
384
462
  pending = null
385
463
  setFrame(message)
386
464
  setPtr(message.pointer ?? null)
465
+ if (Number.isFinite(Number(message.w)) && Number.isFinite(Number(message.h))) {
466
+ setView({ w: Number(message.w), h: Number(message.h) })
467
+ }
387
468
  setPhase('live')
388
469
  drawnAt.current = Date.now()
389
470
  if (typeof message.url === 'string' && message.url !== '') setUrl(message.url)
390
471
  }
391
472
  const draw = (message) => {
473
+ hearHold(message)
392
474
  pending = message
393
475
  if (raf !== 0) return
394
476
  if (typeof requestAnimationFrame === 'function') raf = requestAnimationFrame(paint)
@@ -417,6 +499,7 @@ window.__ModuleLoader__.load({
417
499
  } else if (message.type === 'pointer') {
418
500
  /* The arrow is drawn the moment the agent moves, not when the page
419
501
  next repaints: it stays honest even on a page that is idle. */
502
+ hearHold(message)
420
503
  setPtr(message.pointer ?? null)
421
504
  } else if (typeof message.jpeg === 'string') {
422
505
  draw(message)
@@ -425,6 +508,8 @@ window.__ModuleLoader__.load({
425
508
  source.onerror = () => setPhase('lost')
426
509
  return () => {
427
510
  clearInterval(stuck)
511
+ clearTimeout(personTimer.current)
512
+ clearTimeout(handTimer.current)
428
513
  if (raf !== 0 && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(raf)
429
514
  source.close()
430
515
  }
@@ -448,9 +533,87 @@ window.__ModuleLoader__.load({
448
533
  return () => observer.disconnect()
449
534
  }, [])
450
535
 
451
- const scale = size.w > 0 && size.h > 0 ? Math.min(size.w / SIDE_VIEW.width, size.h / SIDE_VIEW.height) : 0
452
- const padX = (size.w - SIDE_VIEW.width * scale) / 2
453
- const padY = (size.h - SIDE_VIEW.height * scale) / 2
536
+ /* The picture carries the page, not the window list: ask for the list on
537
+ a slow clock, and after a switch. */
538
+ const [winsTick, setWinsTick] = React.useState(0)
539
+ React.useEffect(() => {
540
+ if (mine !== true) return undefined
541
+ let alive = true
542
+ const chat = String(props.sessionId ?? '')
543
+ const load = () => {
544
+ fetch(`${ROUTE}/sidecar/tabs?session=${encodeURIComponent(chat)}`) // ui-copy-ignore
545
+ .then((reply) => (reply.ok === true ? reply.json() : null))
546
+ .then((info) => {
547
+ if (alive === false || info?.ok !== true || Array.isArray(info.tabs) !== true) return
548
+ setWins(info.tabs)
549
+ })
550
+ .catch(() => {})
551
+ }
552
+ load()
553
+ const clock = setInterval(load, 2500)
554
+ return () => {
555
+ alive = false
556
+ clearInterval(clock)
557
+ }
558
+ }, [mine, props.sessionId, winsTick])
559
+
560
+ const switchWin = (at) => {
561
+ fetch(`${ROUTE}/sidecar/tabs`, { // ui-copy-ignore
562
+ method: 'POST',
563
+ headers: { 'content-type': 'application/json' },
564
+ body: JSON.stringify({ session: String(props.sessionId ?? ''), op: 'use', index: at }), // ui-copy-ignore
565
+ })
566
+ .then((reply) => (reply.ok === true ? reply.json() : null))
567
+ .then((info) => {
568
+ if (info?.ok === true && Array.isArray(info.tabs) === true) setWins(info.tabs)
569
+ setWinsTick((n) => n + 1)
570
+ })
571
+ .catch(() => {})
572
+ }
573
+
574
+ const scale = size.w > 0 && size.h > 0 ? Math.min(size.w / view.w, size.h / view.h) : 0
575
+ const padX = (size.w - view.w * scale) / 2
576
+ const padY = (size.h - view.h * scale) / 2
577
+
578
+ /* The column tells the page how big it is, a beat after the resize stops:
579
+ a page that relayouts on every pointer step would never settle. */
580
+ React.useEffect(() => {
581
+ if (mine !== true || size.w < 200 || size.h < 200) return undefined
582
+ /* The page already has that shape: a resize that lands between two
583
+ viewport sizes would only cost a relayout and another frame. */
584
+ if (Math.abs(Number(view?.w ?? 0) - size.w) < 1 && Math.abs(Number(view?.h ?? 0) - size.h) < 1) return undefined
585
+ /* The page is told the column's size a beat after the resize stops, and
586
+ again until the browser says it took it: a request that arrives while
587
+ the page is still starting is refused, and a pane that gives up then
588
+ keeps a letterboxed picture for as long as it stays open. */
589
+ let live = true
590
+ let tries = 0
591
+ let timer = 0
592
+ const send = () => {
593
+ if (!live) return
594
+ fetch(`${ROUTE}/sidecar/viewport`, {
595
+ method: 'POST',
596
+ headers: { 'content-type': 'application/json' },
597
+ body: JSON.stringify({ session: String(props.sessionId ?? ''), w: Math.round(size.w), h: Math.round(size.h) }),
598
+ })
599
+ .then((answer) => (answer.ok === true ? answer.json() : { ok: false }))
600
+ .then((said) => {
601
+ if (!live || said?.ok === true || tries >= 5) return
602
+ tries += 1
603
+ timer = setTimeout(send, 400)
604
+ })
605
+ .catch(() => {
606
+ if (!live || tries >= 5) return
607
+ tries += 1
608
+ timer = setTimeout(send, 600)
609
+ })
610
+ }
611
+ timer = setTimeout(send, 250)
612
+ return () => {
613
+ live = false
614
+ clearTimeout(timer)
615
+ }
616
+ }, [mine, size.w, size.h, view?.w, view?.h])
454
617
 
455
618
  const who = String(props.sessionId ?? '')
456
619
  const post = (path, body) => {
@@ -487,7 +650,7 @@ window.__ModuleLoader__.load({
487
650
  const box = el.getBoundingClientRect()
488
651
  const x = (event.clientX - box.left - padX) / scale
489
652
  const y = (event.clientY - box.top - padY) / scale
490
- if (x < 0 || y < 0 || x > SIDE_VIEW.width || y > SIDE_VIEW.height) return null
653
+ if (x < 0 || y < 0 || x > view.w || y > view.h) return null
491
654
  return { x: Math.round(x), y: Math.round(y) }
492
655
  }
493
656
  const ime = React.useRef(null)
@@ -503,6 +666,7 @@ window.__ModuleLoader__.load({
503
666
  }
504
667
  const stageRef = stage
505
668
  const onPointerDown = (event) => {
669
+ touched()
506
670
  const point = toView(event)
507
671
  if (point === null) return
508
672
  const field = ime.current
@@ -515,6 +679,7 @@ window.__ModuleLoader__.load({
515
679
  }
516
680
  const movedAt = React.useRef(0)
517
681
  const onPointerMove = (event) => {
682
+ touched()
518
683
  const point = toView(event)
519
684
  if (point === null) return
520
685
  /* Hover is part of a page: menus open under the hand, so the movement
@@ -526,24 +691,46 @@ window.__ModuleLoader__.load({
526
691
  hover(point)
527
692
  }
528
693
  const onWheel = (event) => {
694
+ touched()
529
695
  event.preventDefault()
530
- const point = toView(event) ?? { x: Math.round(SIDE_VIEW.width / 2), y: Math.round(SIDE_VIEW.height / 2) }
696
+ const point = toView(event) ?? { x: Math.round(view.w / 2), y: Math.round(view.h / 2) }
531
697
  post('/sidecar/input', { kind: 'wheel', x: point.x, y: point.y, dx: event.deltaX, dy: event.deltaY })
532
698
  }
699
+ /* The page's keys are the person's keys: shift makes a capital, a
700
+ shortcut stays a shortcut so Ctrl+V pastes and Ctrl+A selects, and the
701
+ key that goes down also goes up so a game can hold it. */
702
+ const modsOf = (event) => (event.altKey === true ? 1 : 0)
703
+ | (event.ctrlKey === true ? 2 : 0)
704
+ | (event.metaKey === true ? 4 : 0)
705
+ | (event.shiftKey === true ? 8 : 0)
706
+ const MODIFIER_KEYS = ['Alt', 'Control', 'Meta', 'Shift', 'AltGraph', 'OS', 'Super', 'Hyper']
707
+ const keyBody = (event, down) => {
708
+ const modifiers = modsOf(event)
709
+ const shortcut = (modifiers & 7) !== 0
710
+ const typing = down === true && event.key.length === 1 && (modifiers & 7) === 0 && event.repeat !== true
711
+ return {
712
+ kind: 'key',
713
+ key: event.key,
714
+ code: event.code || event.key,
715
+ keyCode: event.keyCode || 0,
716
+ modifiers,
717
+ ...(typing ? { text: event.key } : {}),
718
+ ...(down === false ? { down: false } : {}),
719
+ }
720
+ }
533
721
  const onKeyDown = (event) => {
722
+ touched()
534
723
  /* A key still being composed belongs to the IME, not to the page. */
535
724
  if (event.isComposing === true) return
536
- const named = ['Enter', 'Tab', 'Escape', 'Backspace', 'Delete', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown']
537
- if (named.includes(event.key) === true) {
538
- event.preventDefault()
539
- post('/sidecar/input', { kind: 'key', key: event.key, code: event.code || event.key, keyCode: event.keyCode || 0 }) // ui-copy-ignore
540
- return
541
- }
542
- if (event.metaKey === true || event.ctrlKey === true || event.altKey === true) return
543
- if (event.key != null && event.key.length === 1) {
544
- event.preventDefault()
545
- post('/sidecar/input', { kind: 'text', text: event.key })
546
- }
725
+ if (event.key == null || MODIFIER_KEYS.includes(event.key) === true) return
726
+ event.preventDefault()
727
+ post('/sidecar/input', event.repeat === true ? { ...keyBody(event, true), repeat: true } : keyBody(event, true))
728
+ }
729
+ const onKeyUp = (event) => {
730
+ if (event.isComposing === true) return
731
+ if (event.key == null || MODIFIER_KEYS.includes(event.key) === true) return
732
+ event.preventDefault()
733
+ post('/sidecar/input', keyBody(event, false))
547
734
  }
548
735
  const goto = () => {
549
736
  const address = String(draft ?? '').trim()
@@ -557,7 +744,7 @@ window.__ModuleLoader__.load({
557
744
  return h('div', { className: 'dshib-side' },
558
745
  h('div', { className: 'dshib-side-note' }, L('side.foreign')))
559
746
  }
560
- const pointer = ptr
747
+ const pointer = person === true || hand === true ? null : ptr
561
748
  const noteText = phase === 'failed' ? note || L('side.failed') : phase === 'live' ? L('side.empty') : L('side.connecting')
562
749
  return h('div', { className: 'dshib-side' },
563
750
  h('div', { className: 'dshib-side-bar' },
@@ -579,6 +766,17 @@ window.__ModuleLoader__.load({
579
766
  goto()
580
767
  },
581
768
  })),
769
+ ...(wins.length > 1
770
+ ? [h('div', { className: 'dshib-side-wins' },
771
+ ...wins.map((win) => h('button', {
772
+ key: `${win.index}:${win.url}`,
773
+ className: 'dshib-side-win',
774
+ type: 'button', // ui-copy-ignore
775
+ 'data-on': win.current === true ? '1' : '0',
776
+ title: win.url,
777
+ onClick: () => switchWin(win.index),
778
+ }, h('span', null, String(win.title ?? '') || String(win.url ?? '')))))]
779
+ : []),
582
780
  h('div', {
583
781
  className: 'dshib-side-stage',
584
782
  ref: stage,
@@ -587,6 +785,7 @@ window.__ModuleLoader__.load({
587
785
  onPointerMove,
588
786
  onWheel,
589
787
  onKeyDown,
788
+ onKeyUp,
590
789
  },
591
790
  frame === null && back !== ''
592
791
  ? h('button', {
@@ -627,12 +826,13 @@ window.__ModuleLoader__.load({
627
826
  clearIme()
628
827
  },
629
828
  onKeyDown,
829
+ onKeyUp,
630
830
  }),
631
831
  frame === null ? null : h('img', {
632
832
  className: 'dshib-side-frame',
633
833
  src: `data:image/jpeg;base64,${frame.jpeg}`,
634
- width: Math.max(1, Math.round(SIDE_VIEW.width * scale)),
635
- height: Math.max(1, Math.round(SIDE_VIEW.height * scale)),
834
+ width: Math.max(1, Math.round(view.w * scale)),
835
+ height: Math.max(1, Math.round(view.h * scale)),
636
836
  alt: '', // ui-copy-ignore
637
837
  draggable: false,
638
838
  }),