dsh-multi-chat 1.0.3 → 1.0.4

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.
@@ -1,406 +1,406 @@
1
- /**
2
- * WallView: the multi-window wall as a `conversation.view` ring entry. When
3
- * the header's "多窗口" tab is selected, the right panel swaps from the
4
- * chat to this view — a toolbar plus a grid of iframes, one pane per running
5
- * DSH instance (127.0.0.1:<port>). Pure additive UI; no existing slot is
6
- * replaced.
7
- *
8
- * Recursion guard: panes embed `?multi-wall=embed` (the embedded page
9
- * registers no wall UI). The serving instance is itself a pane, so the user
10
- * can watch — or stop — the very instance they are in.
11
- *
12
- * Live data channels: the store owns ports/columns; discovery writes
13
- * `setPorts` from /multi/api/ports (same-origin, served by the node half);
14
- * per-pane liveness arrives from /multi/api/status polls. Components never
15
- * see ctx — the fetch helpers are injected through the registration.
16
- */
17
- import { useEffect, useRef, useState } from 'react'
18
- import clsx from 'clsx'
19
- import {
20
- IconCloseOutline16, IconFullscreenOutline16, IconGlobeOutline14, IconPlusOutline16,
21
- IconRefreshOutline14, IconRefreshOutline16, IconRightUpOutline16, IconStopFill16,
22
- Button, Input, Menu, StateDot,
23
- } from '@deepseek-ai/dsh-client-ui-primitives'
24
- import type { PropsLocale, PropsStore, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
25
- import type { HandleOf } from '@deepseek-ai/dsh-client-ui-slots'
26
- import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
27
- import type { createWallStore } from './store.ts'
28
- import type { WallInjected } from './wall-injected.ts'
29
- import css from './WallView.module.css'
30
-
31
- /** Composed props: the view-ring runtime share, the store, the probe face, and locale. */
32
- export type WallViewProps =
33
- & ConvViewProps
34
- & PropsStore<HandleOf<typeof createWallStore>>
35
- & WallInjected
36
- & PropsLocale<'multiWall'>
37
-
38
- /** Grid column presets, driven by the toolbar menu. 'auto' fills the row. */
39
- const COLUMN_PRESETS = ['auto', '1', '2', '3', '4', '6'] as const
40
-
41
- /** Embed flag appended to every pane URL; such pages register no wall UI. */
42
- const EMBED_FLAG = 'multi-wall=embed'
43
-
44
- /**
45
- * Whether this wall is being viewed through the phone gateway (a non-loopback
46
- * host) rather than on the machine running DSH. When remote, pane iframes must
47
- * load through the gateway's `/gw/<port>` route — a phone's `127.0.0.1` points
48
- * at the phone itself, not the host.
49
- */
50
- function isRemoteViewer(): boolean {
51
- const host = typeof window !== 'undefined' ? window.location.hostname : ''
52
- return host !== 'localhost' && host !== '127.0.0.1' && host !== '::1'
53
- }
54
-
55
- /**
56
- * The iframe URL for a pane. Local viewers embed the loopback instance
57
- * directly; remote (phone) viewers route through the gateway that is already
58
- * serving this page, appending the port so the gateway proxies to it.
59
- * @param port - target DSH instance port.
60
- * @returns the pane URL.
61
- */
62
- function paneUrl(port: number): string {
63
- if (isRemoteViewer()) {
64
- return `${window.location.origin}/gw/${port}/?${EMBED_FLAG}`
65
- }
66
- return `http://127.0.0.1:${port}/?${EMBED_FLAG}`
67
- }
68
-
69
- /**
70
- * One pane: header (port, liveness dot, zoom/refresh/open/stop/remove) plus
71
- * the embedded original DSH UI.
72
- */
73
- function WallPane(props: {
74
- port: number
75
- alive: boolean
76
- zoomed: boolean
77
- stopping: boolean
78
- onZoom: () => void
79
- onStop: () => void
80
- onRemove: () => void
81
- t: TranslateNS<'multiWall'>
82
- }) {
83
- const { port, alive, zoomed, stopping, onZoom, onStop, onRemove, t } = props
84
- return (
85
- <section className={clsx(css.pane, zoomed && css.zoomed)} data-port={port}>
86
- <div className={css.paneHead}>
87
- <StateDot state={alive ? 'done' : 'warning'} size={8} className={css.dot} />
88
- <span className={css.paneTitle}>127.0.0.1:{port}</span>
89
- <div className={css.paneActions}>
90
- <button type="button" className={css.action} title={t('zoom')} onClick={onZoom}>
91
- <IconFullscreenOutline16 size={14} />
92
- </button>
93
- <button type="button" className={css.action} title={t('reload')} onClick={(e) => {
94
- e.currentTarget.closest('section')?.querySelector('iframe')?.contentWindow?.location.reload()
95
- }}>
96
- <IconRefreshOutline16 size={14} />
97
- </button>
98
- <button type="button" className={css.action} title={t('openTab')} onClick={() => {
99
- window.open(paneUrl(port), '_blank')
100
- }}>
101
- <IconRightUpOutline16 size={14} />
102
- </button>
103
- <button
104
- type="button"
105
- className={clsx(css.action, css.danger, stopping && css.confirm)}
106
- title={t('stop')}
107
- onClick={onStop}
108
- >
109
- {stopping ? t('stop.confirm') : <IconStopFill16 size={14} />}
110
- </button>
111
- <button type="button" className={css.action} title={t('remove')} onClick={onRemove}>
112
- <IconCloseOutline16 size={14} />
113
- </button>
114
- </div>
115
- </div>
116
- <div className={css.paneBody}>
117
- <iframe
118
- title={`DSH :${port}`}
119
- src={paneUrl(port)}
120
- loading="lazy"
121
- />
122
- </div>
123
- </section>
124
- )
125
- }
126
-
127
- /**
128
- * Render the wall: toolbar plus the horizontally-filled pane grid. Discovery
129
- * runs on mount and liveness polls every 5s; the store's persisted ports
130
- * survive view switches and reloads.
131
- * @param props - composed slot props.
132
- * @returns the wall surface.
133
- */
134
- export function WallView({ useStore, actions, discover, probe, stop, create, link, t }: WallViewProps) {
135
- const ports = useStore(s => s.ports)
136
- const columns = useStore(s => s.columns)
137
- const [alive, setAlive] = useState<Record<number, boolean>>({})
138
- const [zoomedPort, setZoomedPort] = useState<number | null>(null)
139
- const [confirmingStop, setConfirmingStop] = useState<number | null>(null)
140
- const [scanFrom, setScanFrom] = useState(3070)
141
- const [scanTo, setScanTo] = useState(3110)
142
- const [colsMenuOpen, setColsMenuOpen] = useState(false)
143
- const [creating, setCreating] = useState(false)
144
- const [linkOpen, setLinkOpen] = useState(false)
145
- const [linkInfo, setLinkInfo] = useState<Awaited<ReturnType<WallViewProps['link']>> | null>(null)
146
- const [linkCopied, setLinkCopied] = useState(false)
147
- const [status, setStatus] = useState('')
148
- const aliveRef = useRef<Record<number, boolean>>({})
149
- aliveRef.current = alive
150
-
151
- // Discover on mount (the view renders only while selected), then poll
152
- // liveness while the view stays mounted.
153
- useEffect(() => {
154
- void discover().then(found => {
155
- if (found.length > 0) actions.setPorts(found)
156
- setStatus(found.length > 0 ? t('status.found').replace('{count}', String(found.length)).replace('{ports}', found.join(', ')) : '')
157
- })
158
- const timer = setInterval(() => {
159
- if (ports.length === 0) return
160
- void probe(ports).then(rows => {
161
- const next: Record<number, boolean> = {}
162
- for (const row of rows) next[row.port] = row.alive
163
- setAlive(next)
164
- })
165
- }, 5000)
166
- return () => { clearInterval(timer) }
167
- // eslint-disable-next-line react-hooks/exhaustive-deps
168
- }, [])
169
-
170
- // Stop is destructive: the first click arms a per-pane confirm, the second
171
- // executes it. Any other interaction clears the arm. The serving instance
172
- // itself may be stopped too (the user may want to take this port down).
173
- const handleStop = async (port: number) => {
174
- if (confirmingStop !== port) {
175
- setConfirmingStop(port)
176
- return
177
- }
178
- setConfirmingStop(null)
179
- const result = await stop(port)
180
- if (result.ok) {
181
- actions.removePort(port)
182
- setAlive(current => ({ ...current, [port]: false }))
183
- setStatus(t('stop.done').replace('{port}', String(port)))
184
- } else {
185
- setStatus(t('stop.failed').replace('{port}', String(port)).replace('{error}', result.error ?? ''))
186
- }
187
- }
188
-
189
- const runDiscovery = async () => {
190
- setStatus(t('status.scanning').replace('{from}', String(scanFrom)).replace('{to}', String(scanTo)))
191
- const found = await discover()
192
- if (found.length === 0) {
193
- setStatus(t('status.none').replace('{from}', String(scanFrom)).replace('{to}', String(scanTo)))
194
- return
195
- }
196
- actions.setPorts(found)
197
- setStatus(t('status.found').replace('{count}', String(found.length)).replace('{ports}', found.join(', ')))
198
- }
199
-
200
- // Start a brand-new DSH instance and add it to the wall. Failures always
201
- // surface a concrete reason: the injected face reports HTTP status / body
202
- // problems, and a thrown fetch surfaces its message — never a silent stall.
203
- const handleCreate = async () => {
204
- setCreating(true)
205
- setStatus('')
206
- try {
207
- const result = await create()
208
- if (result.ok && result.port !== undefined) {
209
- actions.addPort(result.port)
210
- setAlive(current => ({ ...current, [result.port!]: true }))
211
- setStatus(t('create.done').replace('{port}', String(result.port)))
212
- } else {
213
- setStatus(t('create.failed').replace('{error}', result.error ?? t('create.unknown')))
214
- }
215
- } catch (error) {
216
- setStatus(t('create.failed').replace('{error}', error instanceof Error ? error.message : String(error)))
217
- } finally {
218
- setCreating(false)
219
- }
220
- }
221
-
222
- // Leave the wall: switch the view ring back to the default chat view. The
223
- // ring's active view lives in the chat store and only the header tab's
224
- // click handler mutates it, so exit reuses that sanctioned path — the chat
225
- // entry is always the first tab (order 0), which makes the click
226
- // locale-independent. No tab (blank session) is a no-op.
227
- const exitWall = () => {
228
- const tab = document.querySelector<HTMLButtonElement>('[role="tablist"] [role="tab"]')
229
- tab?.click()
230
- }
231
-
232
- // Fetch the phone-reachable link(s) for this instance and open the sheet.
233
- const handleLink = async () => {
234
- if (linkInfo === null) {
235
- setStatus(t('link.fetching'))
236
- const info = await link()
237
- setLinkInfo(info)
238
- }
239
- setLinkOpen(true)
240
- setLinkCopied(false)
241
- setStatus('')
242
- }
243
-
244
- // Copy the first phone-reachable link to the clipboard. When the gateway
245
- // issued a token it is appended as `?token=` so the phone bypasses the login
246
- // form entirely (the gateway accepts a matching `?token=` query); the URL is
247
- // otherwise the bare link. The standalone token line is still shown above so
248
- // users who prefer typing it can.
249
- const copyFirstLink = async () => {
250
- if (linkInfo === null || linkInfo.lan.length === 0) return
251
- try {
252
- const base = linkInfo.lan[0] ?? ''
253
- const token = linkInfo.token ?? ''
254
- const url = token !== '' ? `${base}?token=${encodeURIComponent(token)}` : base
255
- await navigator.clipboard.writeText(url)
256
- setLinkCopied(true)
257
- } catch {
258
- setLinkCopied(false)
259
- }
260
- }
261
-
262
- // Every discovered port renders, including the one serving this wall: the
263
- // user may want to watch (or stop) the instance they are in. Recursion is
264
- // prevented by the embed flag, not by hiding the self pane.
265
- const shown = ports
266
-
267
- return (
268
- <div className={css.wall} role="region" aria-label={t('overlay.title')} data-wall-view="">
269
- <div className={css.toolbar}>
270
- <span className={css.title}>{t('overlay.title')}</span>
271
- <span className={css.status}>{status}</span>
272
- <div className={css.controls}>
273
- <label className={css.field}>{t('scan.from')}
274
- <Input
275
- type="number"
276
- value={scanFrom}
277
- onChange={e => setScanFrom(Number(e.target.value))}
278
- />
279
- </label>
280
- <label className={css.field}>{t('scan.to')}
281
- <Input
282
- type="number"
283
- value={scanTo}
284
- onChange={e => setScanTo(Number(e.target.value))}
285
- />
286
- </label>
287
- <Button variant="toolbar" size="sm" onClick={() => { void runDiscovery() }}>{t('scan')}</Button>
288
- <Button
289
- variant="toolbar"
290
- size="sm"
291
- icon={<IconPlusOutline16 size={14} />}
292
- disabled={creating}
293
- onClick={() => { void handleCreate() }}
294
- >
295
- {creating ? t('create.pending') : t('create')}
296
- </Button>
297
- <Menu
298
- open={colsMenuOpen}
299
- anchor={
300
- <Button
301
- variant="toolbar"
302
- size="sm"
303
- icon={<IconRightUpOutline16 size={14} />}
304
- onClick={() => { setColsMenuOpen(true) }}
305
- >
306
- {columns === 'auto' ? t('columns.auto') : columns}
307
- </Button>
308
- }
309
- items={COLUMN_PRESETS.map(c => ({
310
- id: c,
311
- label: c === 'auto' ? t('columns.auto') : c,
312
- }))}
313
- selectedId={columns}
314
- onSelect={(id) => {
315
- actions.setColumns(id)
316
- setColsMenuOpen(false)
317
- }}
318
- onClose={() => { setColsMenuOpen(false) }}
319
- compact
320
- />
321
- <Button
322
- variant="toolbar"
323
- size="sm"
324
- icon={<IconRefreshOutline14 size={14} />}
325
- onClick={() => {
326
- document.querySelectorAll(`.${css.paneBody} iframe`).forEach(f => {
327
- (f as HTMLIFrameElement).contentWindow?.location.reload()
328
- })
329
- setStatus(t('status.refreshed'))
330
- }}
331
- >
332
- {t('refresh')}
333
- </Button>
334
- <Button
335
- variant="toolbar"
336
- size="sm"
337
- icon={<IconGlobeOutline14 size={14} />}
338
- aria-label={t('link.aria')}
339
- onClick={() => { void handleLink() }}
340
- >
341
- {t('link')}
342
- </Button>
343
- <Button
344
- variant="toolbar"
345
- size="sm"
346
- icon={<IconCloseOutline16 size={14} />}
347
- aria-label={t('exit.aria')}
348
- title={t('exit')}
349
- onClick={() => { exitWall() }}
350
- >
351
- {t('exit')}
352
- </Button>
353
- </div>
354
- </div>
355
- {linkOpen && linkInfo !== null && (
356
- <div className={css.linkBar}>
357
- {linkInfo.reachable
358
- ? (
359
- <>
360
- <span className={css.linkText}>
361
- {t('link.reachable').replace('{urls}', linkInfo.lan.join(' '))}
362
- {linkInfo.token !== undefined && linkInfo.token !== ''
363
- ? ` ${t('link.token').replace('{token}', linkInfo.token)}`
364
- : ''}
365
- </span>
366
- {linkInfo.lan.length > 0 && (
367
- <Button variant="outline" size="sm" onClick={() => { void copyFirstLink() }}>
368
- {linkCopied ? t('link.copied') : t('link.copy')}
369
- </Button>
370
- )}
371
- </>
372
- )
373
- : (
374
- <span className={css.linkText}>
375
- {t('link.unreachable').replace('{hint}', linkInfo.hint ?? '')}
376
- </span>
377
- )}
378
- <Button variant="ghost" size="sm" icon={<IconCloseOutline16 size={14} />} onClick={() => { setLinkOpen(false) }}>
379
- {t('overlay.close')}
380
- </Button>
381
- </div>
382
- )}
383
- <div className={css.grid} data-cols={columns}>
384
- {shown.map(port => (
385
- <WallPane
386
- key={port}
387
- port={port}
388
- alive={aliveRef.current[port] ?? true}
389
- zoomed={zoomedPort === port}
390
- stopping={confirmingStop === port}
391
- onZoom={() => setZoomedPort(zoomedPort === port ? null : port)}
392
- onStop={() => { void handleStop(port) }}
393
- onRemove={() => { setConfirmingStop(null); actions.removePort(port) }}
394
- t={t}
395
- />
396
- ))}
397
- {shown.length === 0 && (
398
- <div className={css.empty}>
399
- <p>{t('empty')}</p>
400
- <p className={css.hint}>{t('empty.hint')}</p>
401
- </div>
402
- )}
403
- </div>
404
- </div>
405
- )
406
- }
1
+ /**
2
+ * WallView: the multi-window wall as a `conversation.view` ring entry. When
3
+ * the header's "多窗口" tab is selected, the right panel swaps from the
4
+ * chat to this view — a toolbar plus a grid of iframes, one pane per running
5
+ * DSH instance (127.0.0.1:<port>). Pure additive UI; no existing slot is
6
+ * replaced.
7
+ *
8
+ * Recursion guard: panes embed `?multi-wall=embed` (the embedded page
9
+ * registers no wall UI). The serving instance is itself a pane, so the user
10
+ * can watch — or stop — the very instance they are in.
11
+ *
12
+ * Live data channels: the store owns ports/columns; discovery writes
13
+ * `setPorts` from /multi/api/ports (same-origin, served by the node half);
14
+ * per-pane liveness arrives from /multi/api/status polls. Components never
15
+ * see ctx — the fetch helpers are injected through the registration.
16
+ */
17
+ import { useEffect, useRef, useState } from 'react'
18
+ import clsx from 'clsx'
19
+ import {
20
+ IconCloseOutlineRegular, IconFullscreenOutlineRegular, IconGlobeOutlineRegular, IconPlusOutlineRegular,
21
+ IconRefreshOutlineRegular, IconRightUpOutlineRegular, IconStopFillRegular,
22
+ Button, Input, Menu, StateDot,
23
+ } from '@deepseek-ai/dsh-client-ui-primitives'
24
+ import type { PropsLocale, PropsStore, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
25
+ import type { HandleOf } from '@deepseek-ai/dsh-client-ui-slots'
26
+ import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
27
+ import type { createWallStore } from './store.ts'
28
+ import type { WallInjected } from './wall-injected.ts'
29
+ import css from './WallView.module.css'
30
+
31
+ /** Composed props: the view-ring runtime share, the store, the probe face, and locale. */
32
+ export type WallViewProps =
33
+ & ConvViewProps
34
+ & PropsStore<HandleOf<typeof createWallStore>>
35
+ & WallInjected
36
+ & PropsLocale<'multiWall'>
37
+
38
+ /** Grid column presets, driven by the toolbar menu. 'auto' fills the row. */
39
+ const COLUMN_PRESETS = ['auto', '1', '2', '3', '4', '6'] as const
40
+
41
+ /** Embed flag appended to every pane URL; such pages register no wall UI. */
42
+ const EMBED_FLAG = 'multi-wall=embed'
43
+
44
+ /**
45
+ * Whether this wall is being viewed through the phone gateway (a non-loopback
46
+ * host) rather than on the machine running DSH. When remote, pane iframes must
47
+ * load through the gateway's `/gw/<port>` route — a phone's `127.0.0.1` points
48
+ * at the phone itself, not the host.
49
+ */
50
+ function isRemoteViewer(): boolean {
51
+ const host = typeof window !== 'undefined' ? window.location.hostname : ''
52
+ return host !== 'localhost' && host !== '127.0.0.1' && host !== '::1'
53
+ }
54
+
55
+ /**
56
+ * The iframe URL for a pane. Local viewers embed the loopback instance
57
+ * directly; remote (phone) viewers route through the gateway that is already
58
+ * serving this page, appending the port so the gateway proxies to it.
59
+ * @param port - target DSH instance port.
60
+ * @returns the pane URL.
61
+ */
62
+ function paneUrl(port: number): string {
63
+ if (isRemoteViewer()) {
64
+ return `${window.location.origin}/gw/${port}/?${EMBED_FLAG}`
65
+ }
66
+ return `http://127.0.0.1:${port}/?${EMBED_FLAG}`
67
+ }
68
+
69
+ /**
70
+ * One pane: header (port, liveness dot, zoom/refresh/open/stop/remove) plus
71
+ * the embedded original DSH UI.
72
+ */
73
+ function WallPane(props: {
74
+ port: number
75
+ alive: boolean
76
+ zoomed: boolean
77
+ stopping: boolean
78
+ onZoom: () => void
79
+ onStop: () => void
80
+ onRemove: () => void
81
+ t: TranslateNS<'multiWall'>
82
+ }) {
83
+ const { port, alive, zoomed, stopping, onZoom, onStop, onRemove, t } = props
84
+ return (
85
+ <section className={clsx(css.pane, zoomed && css.zoomed)} data-port={port}>
86
+ <div className={css.paneHead}>
87
+ <StateDot state={alive ? 'done' : 'warning'} size={8} className={css.dot} />
88
+ <span className={css.paneTitle}>127.0.0.1:{port}</span>
89
+ <div className={css.paneActions}>
90
+ <button type="button" className={css.action} title={t('zoom')} onClick={onZoom}>
91
+ <IconFullscreenOutlineRegular size={14} />
92
+ </button>
93
+ <button type="button" className={css.action} title={t('reload')} onClick={(e) => {
94
+ e.currentTarget.closest('section')?.querySelector('iframe')?.contentWindow?.location.reload()
95
+ }}>
96
+ <IconRefreshOutlineRegular size={14} />
97
+ </button>
98
+ <button type="button" className={css.action} title={t('openTab')} onClick={() => {
99
+ window.open(paneUrl(port), '_blank')
100
+ }}>
101
+ <IconRightUpOutlineRegular size={14} />
102
+ </button>
103
+ <button
104
+ type="button"
105
+ className={clsx(css.action, css.danger, stopping && css.confirm)}
106
+ title={t('stop')}
107
+ onClick={onStop}
108
+ >
109
+ {stopping ? t('stop.confirm') : <IconStopFillRegular size={14} />}
110
+ </button>
111
+ <button type="button" className={css.action} title={t('remove')} onClick={onRemove}>
112
+ <IconCloseOutlineRegular size={14} />
113
+ </button>
114
+ </div>
115
+ </div>
116
+ <div className={css.paneBody}>
117
+ <iframe
118
+ title={`DSH :${port}`}
119
+ src={paneUrl(port)}
120
+ loading="lazy"
121
+ />
122
+ </div>
123
+ </section>
124
+ )
125
+ }
126
+
127
+ /**
128
+ * Render the wall: toolbar plus the horizontally-filled pane grid. Discovery
129
+ * runs on mount and liveness polls every 5s; the store's persisted ports
130
+ * survive view switches and reloads.
131
+ * @param props - composed slot props.
132
+ * @returns the wall surface.
133
+ */
134
+ export function WallView({ useStore, actions, discover, probe, stop, create, link, t }: WallViewProps) {
135
+ const ports = useStore(s => s.ports)
136
+ const columns = useStore(s => s.columns)
137
+ const [alive, setAlive] = useState<Record<number, boolean>>({})
138
+ const [zoomedPort, setZoomedPort] = useState<number | null>(null)
139
+ const [confirmingStop, setConfirmingStop] = useState<number | null>(null)
140
+ const [scanFrom, setScanFrom] = useState(3070)
141
+ const [scanTo, setScanTo] = useState(3110)
142
+ const [colsMenuOpen, setColsMenuOpen] = useState(false)
143
+ const [creating, setCreating] = useState(false)
144
+ const [linkOpen, setLinkOpen] = useState(false)
145
+ const [linkInfo, setLinkInfo] = useState<Awaited<ReturnType<WallViewProps['link']>> | null>(null)
146
+ const [linkCopied, setLinkCopied] = useState(false)
147
+ const [status, setStatus] = useState('')
148
+ const aliveRef = useRef<Record<number, boolean>>({})
149
+ aliveRef.current = alive
150
+
151
+ // Discover on mount (the view renders only while selected), then poll
152
+ // liveness while the view stays mounted.
153
+ useEffect(() => {
154
+ void discover().then(found => {
155
+ if (found.length > 0) actions.setPorts(found)
156
+ setStatus(found.length > 0 ? t('status.found').replace('{count}', String(found.length)).replace('{ports}', found.join(', ')) : '')
157
+ })
158
+ const timer = setInterval(() => {
159
+ if (ports.length === 0) return
160
+ void probe(ports).then(rows => {
161
+ const next: Record<number, boolean> = {}
162
+ for (const row of rows) next[row.port] = row.alive
163
+ setAlive(next)
164
+ })
165
+ }, 5000)
166
+ return () => { clearInterval(timer) }
167
+ // eslint-disable-next-line react-hooks/exhaustive-deps
168
+ }, [])
169
+
170
+ // Stop is destructive: the first click arms a per-pane confirm, the second
171
+ // executes it. Any other interaction clears the arm. The serving instance
172
+ // itself may be stopped too (the user may want to take this port down).
173
+ const handleStop = async (port: number) => {
174
+ if (confirmingStop !== port) {
175
+ setConfirmingStop(port)
176
+ return
177
+ }
178
+ setConfirmingStop(null)
179
+ const result = await stop(port)
180
+ if (result.ok) {
181
+ actions.removePort(port)
182
+ setAlive(current => ({ ...current, [port]: false }))
183
+ setStatus(t('stop.done').replace('{port}', String(port)))
184
+ } else {
185
+ setStatus(t('stop.failed').replace('{port}', String(port)).replace('{error}', result.error ?? ''))
186
+ }
187
+ }
188
+
189
+ const runDiscovery = async () => {
190
+ setStatus(t('status.scanning').replace('{from}', String(scanFrom)).replace('{to}', String(scanTo)))
191
+ const found = await discover()
192
+ if (found.length === 0) {
193
+ setStatus(t('status.none').replace('{from}', String(scanFrom)).replace('{to}', String(scanTo)))
194
+ return
195
+ }
196
+ actions.setPorts(found)
197
+ setStatus(t('status.found').replace('{count}', String(found.length)).replace('{ports}', found.join(', ')))
198
+ }
199
+
200
+ // Start a brand-new DSH instance and add it to the wall. Failures always
201
+ // surface a concrete reason: the injected face reports HTTP status / body
202
+ // problems, and a thrown fetch surfaces its message — never a silent stall.
203
+ const handleCreate = async () => {
204
+ setCreating(true)
205
+ setStatus('')
206
+ try {
207
+ const result = await create()
208
+ if (result.ok && result.port !== undefined) {
209
+ actions.addPort(result.port)
210
+ setAlive(current => ({ ...current, [result.port!]: true }))
211
+ setStatus(t('create.done').replace('{port}', String(result.port)))
212
+ } else {
213
+ setStatus(t('create.failed').replace('{error}', result.error ?? t('create.unknown')))
214
+ }
215
+ } catch (error) {
216
+ setStatus(t('create.failed').replace('{error}', error instanceof Error ? error.message : String(error)))
217
+ } finally {
218
+ setCreating(false)
219
+ }
220
+ }
221
+
222
+ // Leave the wall: switch the view ring back to the default chat view. The
223
+ // ring's active view lives in the chat store and only the header tab's
224
+ // click handler mutates it, so exit reuses that sanctioned path — the chat
225
+ // entry is always the first tab (order 0), which makes the click
226
+ // locale-independent. No tab (blank session) is a no-op.
227
+ const exitWall = () => {
228
+ const tab = document.querySelector<HTMLButtonElement>('[role="tablist"] [role="tab"]')
229
+ tab?.click()
230
+ }
231
+
232
+ // Fetch the phone-reachable link(s) for this instance and open the sheet.
233
+ const handleLink = async () => {
234
+ if (linkInfo === null) {
235
+ setStatus(t('link.fetching'))
236
+ const info = await link()
237
+ setLinkInfo(info)
238
+ }
239
+ setLinkOpen(true)
240
+ setLinkCopied(false)
241
+ setStatus('')
242
+ }
243
+
244
+ // Copy the first phone-reachable link to the clipboard. When the gateway
245
+ // issued a token it is appended as `?token=` so the phone bypasses the login
246
+ // form entirely (the gateway accepts a matching `?token=` query); the URL is
247
+ // otherwise the bare link. The standalone token line is still shown above so
248
+ // users who prefer typing it can.
249
+ const copyFirstLink = async () => {
250
+ if (linkInfo === null || linkInfo.lan.length === 0) return
251
+ try {
252
+ const base = linkInfo.lan[0] ?? ''
253
+ const token = linkInfo.token ?? ''
254
+ const url = token !== '' ? `${base}?token=${encodeURIComponent(token)}` : base
255
+ await navigator.clipboard.writeText(url)
256
+ setLinkCopied(true)
257
+ } catch {
258
+ setLinkCopied(false)
259
+ }
260
+ }
261
+
262
+ // Every discovered port renders, including the one serving this wall: the
263
+ // user may want to watch (or stop) the instance they are in. Recursion is
264
+ // prevented by the embed flag, not by hiding the self pane.
265
+ const shown = ports
266
+
267
+ return (
268
+ <div className={css.wall} role="region" aria-label={t('overlay.title')} data-wall-view="">
269
+ <div className={css.toolbar}>
270
+ <span className={css.title}>{t('overlay.title')}</span>
271
+ <span className={css.status}>{status}</span>
272
+ <div className={css.controls}>
273
+ <label className={css.field}>{t('scan.from')}
274
+ <Input
275
+ type="number"
276
+ value={scanFrom}
277
+ onChange={e => setScanFrom(Number(e.target.value))}
278
+ />
279
+ </label>
280
+ <label className={css.field}>{t('scan.to')}
281
+ <Input
282
+ type="number"
283
+ value={scanTo}
284
+ onChange={e => setScanTo(Number(e.target.value))}
285
+ />
286
+ </label>
287
+ <Button variant="toolbar" size="sm" onClick={() => { void runDiscovery() }}>{t('scan')}</Button>
288
+ <Button
289
+ variant="toolbar"
290
+ size="sm"
291
+ icon={<IconPlusOutlineRegular size={14} />}
292
+ disabled={creating}
293
+ onClick={() => { void handleCreate() }}
294
+ >
295
+ {creating ? t('create.pending') : t('create')}
296
+ </Button>
297
+ <Menu
298
+ open={colsMenuOpen}
299
+ anchor={
300
+ <Button
301
+ variant="toolbar"
302
+ size="sm"
303
+ icon={<IconRightUpOutlineRegular size={14} />}
304
+ onClick={() => { setColsMenuOpen(true) }}
305
+ >
306
+ {columns === 'auto' ? t('columns.auto') : columns}
307
+ </Button>
308
+ }
309
+ items={COLUMN_PRESETS.map(c => ({
310
+ id: c,
311
+ label: c === 'auto' ? t('columns.auto') : c,
312
+ }))}
313
+ selectedId={columns}
314
+ onSelect={(id) => {
315
+ actions.setColumns(id)
316
+ setColsMenuOpen(false)
317
+ }}
318
+ onClose={() => { setColsMenuOpen(false) }}
319
+ compact
320
+ />
321
+ <Button
322
+ variant="toolbar"
323
+ size="sm"
324
+ icon={<IconRefreshOutlineRegular size={14} />}
325
+ onClick={() => {
326
+ document.querySelectorAll(`.${css.paneBody} iframe`).forEach(f => {
327
+ (f as HTMLIFrameElement).contentWindow?.location.reload()
328
+ })
329
+ setStatus(t('status.refreshed'))
330
+ }}
331
+ >
332
+ {t('refresh')}
333
+ </Button>
334
+ <Button
335
+ variant="toolbar"
336
+ size="sm"
337
+ icon={<IconGlobeOutlineRegular size={14} />}
338
+ aria-label={t('link.aria')}
339
+ onClick={() => { void handleLink() }}
340
+ >
341
+ {t('link')}
342
+ </Button>
343
+ <Button
344
+ variant="toolbar"
345
+ size="sm"
346
+ icon={<IconCloseOutlineRegular size={14} />}
347
+ aria-label={t('exit.aria')}
348
+ title={t('exit')}
349
+ onClick={() => { exitWall() }}
350
+ >
351
+ {t('exit')}
352
+ </Button>
353
+ </div>
354
+ </div>
355
+ {linkOpen && linkInfo !== null && (
356
+ <div className={css.linkBar}>
357
+ {linkInfo.reachable
358
+ ? (
359
+ <>
360
+ <span className={css.linkText}>
361
+ {t('link.reachable').replace('{urls}', linkInfo.lan.join(' '))}
362
+ {linkInfo.token !== undefined && linkInfo.token !== ''
363
+ ? ` ${t('link.token').replace('{token}', linkInfo.token)}`
364
+ : ''}
365
+ </span>
366
+ {linkInfo.lan.length > 0 && (
367
+ <Button variant="outline" size="sm" onClick={() => { void copyFirstLink() }}>
368
+ {linkCopied ? t('link.copied') : t('link.copy')}
369
+ </Button>
370
+ )}
371
+ </>
372
+ )
373
+ : (
374
+ <span className={css.linkText}>
375
+ {t('link.unreachable').replace('{hint}', linkInfo.hint ?? '')}
376
+ </span>
377
+ )}
378
+ <Button variant="ghost" size="sm" icon={<IconCloseOutlineRegular size={14} />} onClick={() => { setLinkOpen(false) }}>
379
+ {t('overlay.close')}
380
+ </Button>
381
+ </div>
382
+ )}
383
+ <div className={css.grid} data-cols={columns}>
384
+ {shown.map(port => (
385
+ <WallPane
386
+ key={port}
387
+ port={port}
388
+ alive={aliveRef.current[port] ?? true}
389
+ zoomed={zoomedPort === port}
390
+ stopping={confirmingStop === port}
391
+ onZoom={() => setZoomedPort(zoomedPort === port ? null : port)}
392
+ onStop={() => { void handleStop(port) }}
393
+ onRemove={() => { setConfirmingStop(null); actions.removePort(port) }}
394
+ t={t}
395
+ />
396
+ ))}
397
+ {shown.length === 0 && (
398
+ <div className={css.empty}>
399
+ <p>{t('empty')}</p>
400
+ <p className={css.hint}>{t('empty.hint')}</p>
401
+ </div>
402
+ )}
403
+ </div>
404
+ </div>
405
+ )
406
+ }