Files

929 lines
65 KiB
JavaScript

/**
* @local/dsh-remote — browser side.
*
* - Sidebar panel "Devices" (sidebar.panellist + keyed main slot): every linked device
* with its sessions grouped by workspace, live running state, "open" (local session →
* in place, remote → /r/<device>/#/session/<id>), "new session on device".
* - Settings section "Remote devices": role, link status, pairing (master: create code;
* slave: enter master URL + code), device list with revoke/remove and per-device sync
* policy (enabled, allow/deny, opt-in secrets), "sync now".
* - Toast when a session on any device finishes (server-sent events).
* - When the page itself is served under /r/<device>/, a small banner shows which
* device you are looking at, with a link back to the local DSH.
*/
window.__ModuleLoader__.load({
id: '@local/dsh-remote',
factory(require) {
const React = require('react');
const ReactDOM = require('react-dom');
const h = React.createElement;
const { useState, useEffect, useCallback, useMemo, useRef } = React;
let P = {};
try { P = require('@deepseek-ai/dsh-client-ui-primitives') || {}; } catch { P = {}; }
const PANEL_ID = 'dsh-remote-devices';
// When this UI is itself proxied (/r/<dev>/), API calls must go to *this* page's
// host DSH, i.e. the remote device. Relative URLs already do that. For the device
// overview we always want the browser's own DSH, which is the URL root before /r/.
const mount = (() => {
const m = location.pathname.match(/^(.*?)\/r\/([a-z0-9-]+)\//);
return m ? { root: m[1] + '/', device: m[2] } : { root: location.pathname.replace(/[^/]*$/, ''), device: null };
})();
const rootUrl = (p) => mount.root + p;
async function api(path, init) {
const res = await fetch(rootUrl('dsh-remote/' + path), {
...init,
headers: { accept: 'application/json', ...(init && init.body ? { 'content-type': 'application/json' } : {}) },
credentials: 'same-origin',
});
let json;
try { json = await res.json(); } catch { json = {}; }
if (!res.ok) throw new Error(json.error || res.statusText || String(res.status));
return json;
}
const post = (path, body) => api(path, { method: 'POST', body: JSON.stringify(body || {}) });
function sessionUrl(device, selfId, sessionId) {
const base = device === selfId ? mount.root : `${mount.root}r/${device}/`;
return `${base}#/session/${encodeURIComponent(sessionId)}`;
}
const ago = (t) => {
if (!t) return '';
const s = Math.max(0, (Date.now() - t) / 1000);
if (s < 60) return 'just now';
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
return `${Math.floor(s / 86400)}d ago`;
};
const base = (p) => String(p || '').replace(/\/+$/, '').split('/').pop() || p || '';
// ------------------------------------------------------------------ styles
const S = {
page: { padding: '20px 28px', overflow: 'auto', height: '100%', boxSizing: 'border-box', color: 'var(--color-text, inherit)' },
h1: { fontSize: 20, fontWeight: 600, margin: '0 0 4px' },
sub: { opacity: 0.65, fontSize: 13, margin: '0 0 18px' },
card: { border: '1px solid var(--color-border, rgba(127,127,127,.25))', borderRadius: 10, padding: '12px 14px', margin: '0 0 14px', background: 'var(--color-surface, transparent)' },
devHead: { display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8 },
dot: (on) => ({ width: 9, height: 9, borderRadius: 9, background: on ? '#3fb950' : '#8b949e', flex: 'none' }),
badge: (tone) => ({ fontSize: 11, padding: '1px 7px', borderRadius: 9, border: '1px solid currentColor', opacity: 0.8, color: tone === 'accent' ? '#58a6ff' : tone === 'warn' ? '#d29922' : tone === 'ok' ? '#3fb950' : 'inherit' }),
ws: { fontSize: 12, textTransform: 'uppercase', letterSpacing: '.04em', opacity: 0.6, margin: '10px 0 4px' },
row: { display: 'flex', alignItems: 'center', gap: 8, padding: '5px 8px', borderRadius: 6, cursor: 'pointer', fontSize: 14 },
title: { flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
meta: { fontSize: 12, opacity: 0.55, flex: 'none' },
btn: { font: 'inherit', fontSize: 13, padding: '4px 10px', borderRadius: 6, border: '1px solid var(--color-border, rgba(127,127,127,.35))', background: 'transparent', color: 'inherit', cursor: 'pointer' },
btnPrimary: { font: 'inherit', fontSize: 13, padding: '5px 12px', borderRadius: 6, border: '1px solid #2f81f7', background: '#2f81f7', color: '#fff', cursor: 'pointer' },
input: { font: 'inherit', fontSize: 13, padding: '5px 8px', borderRadius: 6, border: '1px solid var(--color-border, rgba(127,127,127,.35))', background: 'transparent', color: 'inherit', minWidth: 0 },
err: { color: '#f85149', fontSize: 13 },
code: { fontFamily: 'ui-monospace, monospace', fontSize: 18, letterSpacing: '.08em', padding: '6px 10px', border: '1px dashed currentColor', borderRadius: 6, display: 'inline-block' },
spin: { display: 'inline-block', width: 8, height: 8, borderRadius: 8, background: '#d29922', animation: 'dshr-pulse 1s infinite' },
};
if (!document.getElementById('dshr-style')) {
const st = document.createElement('style');
st.id = 'dshr-style';
st.textContent = '@keyframes dshr-pulse{0%,100%{opacity:.3}50%{opacity:1}} .dshr-row:hover{background:var(--color-hover,rgba(127,127,127,.12))}';
document.head.appendChild(st);
}
// ------------------------------------------------------------------ live store
const store = {
state: null, sessions: null, error: null, listeners: new Set(), events: new Set(),
emit() { for (const l of this.listeners) l(); },
async refresh() {
try {
const [state, sessions] = await Promise.all([api('state'), api('sessions')]);
this.state = state; this.sessions = sessions.devices; this.error = null;
} catch (e) { this.error = e.message; }
this.emit();
},
};
let es = null;
let refreshTimer = null;
function startStream() {
if (es) return;
try {
es = new EventSource(rootUrl('dsh-remote/stream'));
es.onmessage = (m) => {
let ev; try { ev = JSON.parse(m.data); } catch { return; }
for (const l of store.events) l(ev);
clearTimeout(refreshTimer);
refreshTimer = setTimeout(() => store.refresh(), 300);
};
es.onerror = () => { /* EventSource reconnects on its own */ };
} catch { es = null; }
}
function useStore() {
const [, force] = useState(0);
useEffect(() => {
const l = () => force((x) => x + 1);
store.listeners.add(l);
if (!store.state) store.refresh();
const t = setInterval(() => store.refresh(), 10000);
return () => { store.listeners.delete(l); clearInterval(t); };
}, []);
return store;
}
// ------------------------------------------------------------------ Devices panel
function DeviceIcon({ size }) {
return h('svg', { 'data-dsh-panel-entry': PANEL_ID, viewBox: '0 0 16 16', width: size, height: size, fill: 'none', stroke: 'currentColor', strokeWidth: 1.3, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true },
h('rect', { x: 1.5, y: 2.5, width: 9, height: 6.5, rx: 1 }),
h('path', { d: 'M4 12h4M6 9v3' }),
h('rect', { x: 11.5, y: 5.5, width: 3, height: 8, rx: .8 }));
}
// ------------------------------------------------------------------ conversation sync
const devName = (id) => (store.state?.devices || []).find((d) => d.id === id)?.name
|| (store.sessions || []).find((d) => d.device === id)?.name || id;
const toast = (text, tone = 'info', extra = {}) => { for (const l of store.events) l({ type: 'dshr-local-toast', text, tone, at: Date.now(), ...extra }); };
/**
* Continue a conversation on `targetDevice` (default: this device). The conversation is copied
* through its last finished turn as a native session there; the source is told it moved (or
* told as soon as it is reachable again).
*/
async function continueSession({ sourceDevice, sessionId, title, targetDevice }) {
const selfId = store.state?.self?.id;
const target = targetDevice || selfId;
const r = await post('continue-session', { sourceDevice, sessionId, targetDevice: target, title });
const where = target === selfId ? 'here' : `on ${devName(target)}`;
const notes = [];
if (r.fromMirror) notes.push(`${devName(sourceDevice)} is offline — used the copy synced ${ago(r.syncedAt)}`);
if (r.partialTurnLeftBehind) notes.push('a turn that was still running there was not carried over');
if (!r.sourceNotified) notes.push(`${devName(sourceDevice)} will be told as soon as it is back online`);
if (r.conflict) notes.push(`${devName(sourceDevice)} kept chatting in it meanwhile — the two copies have diverged`);
const msg = { type: 'dshr-local-toast', text: `“${title || 'Conversation'}” continues ${where}${notes.length ? ' · ' + notes.join(' · ') : ''}`, tone: r.conflict ? 'warning' : 'success', holdMs: notes.length ? 10000 : 5000, at: Date.now() };
// The page reloads into the new session; show the outcome after that.
try { sessionStorage.setItem('dshr-pending-toast', JSON.stringify(msg)); } catch { toast(msg.text, msg.tone, msg); }
store.refresh();
// Open the continued conversation as a native session of that device.
const url = sessionUrl(target, selfId, r.sessionId);
const sameDoc = target === (mount.device || selfId);
location.href = url;
if (sameDoc) setTimeout(() => location.reload(), 60);
return r;
}
function lineageBadges(lin, selfId) {
if (!lin) return [];
const out = [];
if (lin.origin) out.push(h('span', { key: 'o', style: { ...S.badge(), marginRight: 6 }, title: `Continued from ${lin.origin.deviceName || lin.origin.device} at event ${lin.origin.baseSeq}${lin.origin.fromMirror ? ' (from its synced copy, it was offline)' : ''}` }, `from ${lin.origin.deviceName || lin.origin.device}`));
if (lin.moved) out.push(h('a', { key: 'm', href: sessionUrl(lin.moved.device, selfId, lin.moved.sessionId), onClick: (e) => e.stopPropagation(), style: { ...S.badge('accent'), marginRight: 6, textDecoration: 'none' }, title: `This conversation continues on ${devName(lin.moved.device)} (moved at event ${lin.moved.atSeq})` }, `moved → ${devName(lin.moved.device)}`));
if (lin.conflict) out.push(h('span', { key: 'c', style: { ...S.badge('warn'), marginRight: 6 }, title: `Both copies got new messages after event ${lin.conflict.atSeq}. Nothing was merged; both stay usable. Other copy: ${devName(lin.conflict.other?.device)}.` }, '⚠ diverged'));
return out;
}
function ContinueControls({ dev, s, devices }) {
const [busy, setBusy] = useState(false);
const [err, setErr] = useState('');
const selfId = store.state?.self?.id;
const targets = devices.filter((d) => d.online && !d.revoked && d.id !== dev.device);
const go = async (target) => {
setBusy(true); setErr('');
try { await continueSession({ sourceDevice: dev.device, sessionId: s.sessionId, title: s.title, targetDevice: target }); }
catch (e) { setErr(e.message); setBusy(false); }
};
return h('span', { style: { display: 'inline-flex', alignItems: 'center', gap: 4, flex: 'none' } },
err && h('span', { style: { ...S.err, margin: 0, maxWidth: 260 }, title: err }, err),
dev.device !== selfId && h('button', {
style: { ...S.btn, fontSize: 11, padding: '2px 8px' }, disabled: busy,
title: dev.offline ? `${dev.name} is offline: continue from the copy synced to this device` : `Copy this conversation to ${store.state?.self?.name || 'this device'} and continue it here`,
onClick: (e) => { e.preventDefault(); e.stopPropagation(); go(selfId); },
}, busy ? 'Syncing…' : 'Continue here'),
targets.filter((d) => d.id !== selfId).length > 0 && h('select', {
style: { ...S.input, fontSize: 11, padding: '2px 4px', width: 'auto' }, value: '', disabled: busy,
title: 'Continue this conversation on another device',
onClick: (e) => e.stopPropagation(),
onChange: (e) => { if (e.target.value) go(e.target.value); },
},
h('option', { value: '' }, 'Continue on…'),
targets.map((d) => h('option', { key: d.id, value: d.id }, d.id === selfId ? `${d.name} (this device)` : d.name))));
}
function SessionRow({ dev, s, selfId, devices = [] }) {
const openable = !dev.offline && !dev.replica;
return h('div', { style: { display: 'flex', alignItems: 'center', gap: 6 } },
h(openable ? 'a' : 'div', {
className: 'dshr-row', style: { ...S.row, color: 'inherit', textDecoration: 'none', flex: 1, minWidth: 0 },
href: openable ? sessionUrl(dev.device, selfId, s.sessionId) : undefined,
title: `${s.title || s.sessionId}\n${s.cwd || ''}${s.replica ? `\nsynced copy from ${ago(s.replica.syncedAt)}` : ''}`,
},
s.running ? h('span', { style: S.spin, title: 'running' }) : h('span', { style: { width: 8, flex: 'none' } }),
h('span', { style: S.title }, s.title || h('i', { style: { opacity: .6 } }, 'untitled')),
...lineageBadges(s.lineage, selfId),
s.replica && h('span', { style: { ...S.badge(), marginRight: 6 }, title: 'Offline device: this is the copy synced to this device' }, `copy · ${ago(s.replica.syncedAt)}`),
h('span', { style: S.meta }, ago(s.updatedAt))),
h(ContinueControls, { dev, s, devices }));
}
function NewSession({ dev, selfId }) {
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState('');
const options = dev.workspaces || [];
const [ws, setWs] = useState(options[0]?.id || '');
const go = async () => {
setBusy(true); setErr('');
try {
const r = await post('new-session', { device: dev.device, workspaceId: ws || undefined });
location.href = sessionUrl(dev.device, selfId, r.sessionId);
if (dev.device === selfId) setTimeout(() => location.reload(), 50);
} catch (e) { setErr(e.message); setBusy(false); }
};
if (!open) return h('button', { style: S.btn, onClick: () => setOpen(true) }, '+ New session');
return h('span', { style: { display: 'inline-flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' } },
h('select', { style: S.input, value: ws, onChange: (e) => setWs(e.target.value) },
options.length ? options.map((w) => h('option', { key: w.id, value: w.id }, w.title || base(w.path))) : h('option', { value: '' }, '(no workspace)')),
h('button', { style: S.btnPrimary, disabled: busy || !options.length, onClick: go }, busy ? 'Creating…' : 'Create'),
h('button', { style: S.btn, onClick: () => setOpen(false) }, 'Cancel'),
err && h('span', { style: S.err }, err));
}
// ---- icons (the primitives have no server glyph, so draw it)
function ServerIcon({ size = 16 }) {
return h('svg', { viewBox: '0 0 16 16', width: size, height: size, fill: 'none', stroke: 'currentColor', strokeWidth: 1.2, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true, 'data-dshr-icon': 'server' },
h('rect', { x: 2, y: 2.5, width: 12, height: 4.2, rx: 1.1 }),
h('rect', { x: 2, y: 9.3, width: 12, height: 4.2, rx: 1.1 }),
h('path', { d: 'M4.6 4.6h.01M4.6 11.4h.01', strokeWidth: 1.8 }));
}
function FolderIcon({ size = 16 }) {
return h('svg', { viewBox: '0 0 16 16', width: size, height: size, fill: 'none', stroke: 'currentColor', strokeWidth: 1.2, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true },
h('path', { d: 'M2 4.5A1.5 1.5 0 0 1 3.5 3h2.6l1.4 1.5h5A1.5 1.5 0 0 1 14 6v5.5a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 2 11.5z' }));
}
/** Workspaces of one device with their sessions (workspace registry + sessions by cwd). */
function groupDevice(dev, q = '') {
const match = (s) => !q || `${s.title || ''} ${s.cwd || ''}`.toLowerCase().includes(q);
const groups = [];
const byPath = new Map();
for (const w of dev.workspaces || []) {
const g = { key: `${dev.device}:${w.id}`, id: w.id, title: w.title || base(w.path) || w.path, path: w.path, sessions: [] };
groups.push(g); byPath.set(w.path, g);
}
for (const s of dev.items || []) {
if (!match(s)) continue;
const p = s.cwd || '';
let g = byPath.get(p);
if (!g) {
g = { key: `${dev.device}:cwd:${p}`, id: undefined, title: base(p) || 'No workspace', path: p, sessions: [] };
groups.push(g); byPath.set(p, g);
}
g.sessions.push(s);
}
return q ? groups.filter((g) => g.sessions.length) : groups;
}
// ------------------------------------------------------------------ Devices page
function DevicesPage() {
const st = useStore();
const [filter, setFilter] = useState('');
const self = st.state?.self;
if (!st.state && !st.error) return h('div', { style: S.page }, 'Loading devices…');
const q = filter.trim().toLowerCase();
const bySessions = Object.fromEntries((st.sessions || []).map((d) => [d.device, d]));
const known = (st.state?.devices || []).filter((d) => !d.revoked || d.self);
// Devices that are offline and only known here through their synced copies.
const replicaOnly = (st.sessions || []).filter((d) => d.offline && !known.some((k) => k.id === d.device))
.map((d) => ({ id: d.device, name: d.name, online: false, replicaOnly: true }));
const devices = [...known, ...replicaOnly];
const online = devices.filter((d) => d.online).length;
return h('div', { style: S.page, 'data-dsh-plugin': 'dsh-remote' },
h('h1', { style: S.h1 }, 'Devices'),
h('p', { style: S.sub },
`${online} of ${devices.length} online`,
st.state ? ` · this device: ${self?.name} (${st.state.role})` : '',
st.state?.role === 'slave' && st.state.master
? (st.state.master.linked ? ` · linked to ${st.state.master.info?.name || st.state.master.url}` : ` · master unreachable${st.state.master.error ? ': ' + st.state.master.error : ''}`)
: ''),
st.error && h('p', { style: S.err }, st.error),
h('div', { style: { display: 'flex', gap: 8, margin: '0 0 14px' } },
h('input', { style: { ...S.input, flex: 1 }, placeholder: 'Filter sessions…', value: filter, onChange: (e) => setFilter(e.target.value) }),
h('button', { style: S.btn, onClick: () => store.refresh() }, 'Refresh')),
devices.map((m) => {
const dev = bySessions[m.id] || { device: m.id, name: m.name, items: [], workspaces: [], self: m.self };
const groups = groupDevice(dev, q);
const running = (dev.items || []).filter((s) => s.running).length;
const state = m.online
? (dev.error ? 'unreachable' : `online${m.connectedAt ? ' · connected ' + ago(m.connectedAt) : ''}`)
: `offline${m.lastSeenAt ? ' · last seen ' + ago(m.lastSeenAt) : ''}`;
return h('div', { key: m.id, style: { ...S.card, opacity: m.online ? 1 : .7 } },
h('div', { style: S.devHead },
h('span', { style: S.dot(m.online && !dev.error) }),
h('strong', null, m.name || m.id),
m.self && h('span', { style: S.badge('accent') }, 'this device'),
m.role === 'master' && h('span', { style: S.badge() }, 'master'),
m.via && h('span', { style: S.badge() }, 'via master'),
m.revoked && h('span', { style: S.badge('warn') }, 'revoked'),
h('span', { style: { flex: 1 } }),
m.online && !m.self && h('a', { style: { ...S.btn, textDecoration: 'none' }, href: `${mount.root}r/${m.id}/` }, 'Open DSH ↗'),
m.online && !dev.error && h(NewSession, { dev, selfId: self?.id })),
h('div', { style: { fontSize: 12, opacity: .65, margin: '0 0 6px' } },
state,
m.platform ? ` · ${m.platform}` : '',
m.online && !dev.error ? ` · ${(dev.workspaces || []).length} workspaces · ${(dev.items || []).length} sessions${running ? ` · ${running} running` : ''}` : ''),
dev.error && h('div', { style: S.err }, dev.error),
!m.online && (dev.items || []).length > 0 && h('div', { style: { fontSize: 12, opacity: .7, margin: '0 0 6px' } },
`Offline — showing the ${(dev.items || []).length} conversation${(dev.items || []).length > 1 ? 's' : ''} synced to this device. “Continue here” picks up from that copy; ${m.name || m.id} is told as soon as it is back online.`),
!dev.error && m.online && !groups.length && h('div', { style: { opacity: .6, fontSize: 13 } }, q ? 'No matching sessions.' : 'No workspaces yet.'),
groups.map((g) => h('div', { key: g.key },
h('div', { style: { ...S.ws, display: 'flex', alignItems: 'center', gap: 6 }, title: g.path },
h('span', { style: { display: 'inline-flex', opacity: .8 } }, m.self ? h(FolderIcon, { size: 14 }) : h(ServerIcon, { size: 14 })),
g.title),
g.sessions.length
? g.sessions.map((s) => h(SessionRow, { key: s.sessionId, dev, s, selfId: self?.id, devices }))
: h('div', { style: { fontSize: 12, opacity: .5, padding: '2px 8px 4px 28px' } }, 'no sessions'))));
}));
}
// ------------------------------------------------------------------ remote workspaces in the sidebar
// The stock Workspaces list belongs to ui-workspace and has no slot for extra groups, so a
// small React root is mounted as the last child of its tree and styled with the same
// design tokens. Rows use a server glyph where the stock rows use a folder; sessions open
// as the usual conversation view of that device (/r/<device>/#/session/<id>).
const SB_CSS = `
.dshr-sb{margin-top:2px;display:flex;flex-direction:column;gap:2px}
.dshr-sb-row{box-sizing:border-box;display:flex;align-items:center;gap:6px;height:34px;padding:0 8px;border-radius:var(--dsw-radius-md,8px);cursor:pointer;user-select:none;color:var(--dsw-alias-label-primary,inherit);font-size:14px;text-decoration:none}
.dshr-sb-row:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}
.dshr-sb-slot{width:16px;height:20px;display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;color:var(--dsw-alias-label-tertiary,#888)}
.dshr-sb-title{flex:1 1 0;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;line-height:20px}
.dshr-sb-dev{flex:0 0 auto;font-size:10px;line-height:16px;color:var(--dsw-alias-label-tertiary,#888);max-width:64px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.dshr-sb-sess{height:32px;padding-inline-start:30px;gap:0}
.dshr-sb-sess .dshr-sb-title{margin:0 6px 0 4px}
.dshr-sb-time{flex:0 0 auto;font-size:10px;line-height:16px;color:var(--dsw-alias-label-tertiary,#888)}
.dshr-sb-plus{display:none;flex:0 0 auto;width:20px;height:20px;border:0;border-radius:6px;background:none;color:inherit;cursor:pointer;line-height:1;font-size:15px}
.dshr-sb-plus:hover{background:rgba(127,127,127,.2)}
.dshr-sb-row:hover .dshr-sb-plus{display:inline-flex;align-items:center;justify-content:center}
.dshr-sb-more{font-size:12px;color:var(--dsw-alias-label-tertiary,#888);padding:2px 8px 2px 30px;cursor:pointer;border:0;background:none;text-align:left}
.dshr-sb-run{width:8px;height:8px;border-radius:8px;background:#d29922;margin-right:2px;flex:0 0 auto;animation:dshr-pulse 1s infinite}
`;
const SB_SESSIONS = 6;
/** One remote conversation in the stock sidebar: open it there, or continue it on this device. */
function SidebarSessionRow({ dev, s, selfId, offline }) {
const [busy, setBusy] = useState(false);
const lin = s.lineage;
const mark = lin?.conflict ? { t: '⚠', c: '#d29922', tip: 'diverged from its continued copy' }
: lin?.moved ? { t: '→', c: '#58a6ff', tip: `continues on ${devName(lin.moved.device)}` } : null;
const cont = async (e) => {
e.stopPropagation(); e.preventDefault();
if (busy) return;
setBusy(true);
try { await continueSession({ sourceDevice: dev.device, sessionId: s.sessionId, title: s.title }); }
catch (err) { toast(`Could not continue “${s.title || 'conversation'}”: ${err.message}`, 'error'); setBusy(false); }
};
const label = h(React.Fragment, null,
s.running ? h('span', { className: 'dshr-sb-run', title: 'running' }) : null,
h('span', { className: 'dshr-sb-title' }, s.title || 'untitled'),
mark && h('span', { style: { fontSize: 11, color: mark.c, marginRight: 4 }, title: mark.tip }, mark.t),
h('span', { className: 'dshr-sb-time' }, busy ? 'syncing…' : ago(s.updatedAt)));
return h('div', { className: 'dshr-sb-row dshr-sb-sess', role: 'treeitem' },
offline
? h('span', { style: { display: 'flex', alignItems: 'center', flex: 1, minWidth: 0 }, title: `${s.title || s.sessionId}\n${dev.name} is offline — click to continue it here from the synced copy`, onClick: cont }, label)
: h('a', { style: { display: 'flex', alignItems: 'center', flex: 1, minWidth: 0, textDecoration: 'none', color: 'inherit' }, href: sessionUrl(dev.device, selfId, s.sessionId), title: `${s.title || s.sessionId}\n${dev.name}` }, label),
h('button', { className: 'dshr-sb-plus', disabled: busy, title: `Continue on ${store.state?.self?.name || 'this device'} (copies the conversation here)`, onClick: cont }, '⇣'));
}
function SidebarRemote() {
const st = useStore();
const [open, setOpen] = useState(() => { try { return JSON.parse(localStorage.getItem('dshr-ws-open') || '{}'); } catch { return {}; } });
const [more, setMore] = useState({});
const selfId = st.state?.self?.id;
// The device whose UI this page is showing already has its own native rows.
const viewed = mount.device || selfId;
const devs = (st.sessions || []).filter((d) => d.device !== viewed && (!d.error || (d.replica && (d.items || []).length)));
if (!st.state || !devs.length) return null;
const toggle = (key) => {
const next = { ...open, [key]: !open[key] };
setOpen(next);
try { localStorage.setItem('dshr-ws-open', JSON.stringify(next)); } catch { /* ignore */ }
};
const create = async (e, dev, g) => {
e.stopPropagation();
try {
const r = await post('new-session', { device: dev.device, workspaceId: g.id, cwd: g.id ? undefined : g.path || undefined });
location.href = sessionUrl(dev.device, selfId, r.sessionId);
} catch (err) { console.warn('[dsh-remote] new session failed:', err.message); }
};
return h('div', { className: 'dshr-sb', 'data-dshr-remote-workspaces': '' },
devs.flatMap((dev) => groupDevice(dev).map((g) => {
const isOpen = !!open[g.key];
const shown = more[g.key] ? g.sessions : g.sessions.slice(0, SB_SESSIONS);
const offline = !!(dev.offline || dev.replica);
return h(React.Fragment, { key: g.key },
h('div', { className: 'dshr-sb-row', role: 'treeitem', 'aria-expanded': isOpen, title: `${dev.name}: ${g.path}${offline ? '\noffline — synced copies' : ''}`, onClick: () => toggle(g.key), style: offline ? { opacity: .7 } : undefined },
h('span', { className: 'dshr-sb-slot' }, h(ServerIcon)),
h('span', { className: 'dshr-sb-title' }, g.title),
!offline && h('button', { className: 'dshr-sb-plus', title: `New session on ${dev.name}`, onClick: (e) => create(e, dev, g) }, '+'),
h('span', { className: 'dshr-sb-dev' }, offline ? `${dev.name || dev.device} · offline` : (dev.name || dev.device))),
isOpen && shown.map((s) => h(SidebarSessionRow, { key: s.sessionId, dev, s, selfId, offline })),
isOpen && !more[g.key] && g.sessions.length > SB_SESSIONS && h('button', { className: 'dshr-sb-more', onClick: () => setMore({ ...more, [g.key]: true }) }, `Show ${g.sessions.length - SB_SESSIONS} more`),
isOpen && !g.sessions.length && h('div', { className: 'dshr-sb-more', style: { cursor: 'default' } }, 'no sessions'));
})));
}
function mountSidebarRemote() {
if (!document.getElementById('dshr-sb-style')) {
const css = document.createElement('style');
css.id = 'dshr-sb-style';
css.textContent = SB_CSS;
document.head.appendChild(css);
}
const host = document.createElement('div');
host.className = 'dshr-sb-host';
host.setAttribute('role', 'none');
const root = ReactDOM.createRoot ? ReactDOM.createRoot(host) : null;
if (root) root.render(h(SidebarRemote)); else ReactDOM.render(h(SidebarRemote), host);
let raf = 0;
const place = () => {
raf = 0;
const tree = document.querySelector('[data-slot="sidebar.workspaces"] [role="tree"]');
if (!tree) return;
if (host.parentElement !== tree || tree.lastElementChild !== host) tree.appendChild(host);
};
const schedule = () => { if (!raf) raf = requestAnimationFrame(place); };
const obs = new MutationObserver(schedule);
obs.observe(document.body, { childList: true, subtree: true });
schedule();
return () => {
obs.disconnect();
try { root ? root.unmount() : ReactDOM.unmountComponentAtNode(host); } catch { /* ignore */ }
host.remove();
};
}
// ------------------------------------------------------------------ Settings section
function Field({ label, children, hint }) {
return h('label', { style: { display: 'grid', gap: 4, margin: '0 0 10px', fontSize: 13 } },
h('span', { style: { opacity: .8 } }, label), children, hint && h('span', { style: { opacity: .55, fontSize: 12 } }, hint));
}
const listToText = (l) => (l || []).join(', ');
const textToList = (t) => String(t || '').split(/[\s,]+/).map((x) => x.trim()).filter(Boolean);
function DeviceSync({ d, offer, onSaved }) {
const [enabled, setEnabled] = useState(d.sync?.enabled !== false);
const [allow, setAllow] = useState(listToText(d.sync?.allow));
const [deny, setDeny] = useState(listToText(d.sync?.deny));
const [secrets, setSecrets] = useState(new Set(d.sync?.secrets || []));
const [msg, setMsg] = useState('');
const save = async () => {
try {
await post(`devices/${d.id}/sync`, { enabled, allow: textToList(allow), deny: textToList(deny), secrets: [...secrets] });
setMsg('Saved; device notified.'); onSaved();
} catch (e) { setMsg(e.message); }
};
return h('div', { style: { margin: '8px 0 0 18px', paddingLeft: 10, borderLeft: '2px solid rgba(127,127,127,.25)' } },
h('label', { style: { display: 'flex', gap: 6, alignItems: 'center', fontSize: 13, marginBottom: 8 } },
h('input', { type: 'checkbox', checked: enabled, onChange: (e) => setEnabled(e.target.checked) }), 'Sync plugins + config to this device'),
h(Field, { label: 'Only these bundles (empty = all)', hint: 'Exact names or prefix*, comma separated' },
h('input', { style: S.input, value: allow, onChange: (e) => setAllow(e.target.value) })),
h(Field, { label: 'Never these bundles' }, h('input', { style: S.input, value: deny, onChange: (e) => setDeny(e.target.value) })),
h('div', { style: { fontSize: 13, margin: '0 0 8px' } },
h('div', { style: { opacity: .8, marginBottom: 4 } }, 'Secrets this device may pull (it must also opt in locally)'),
offer.length ? offer.map((n) => h('label', { key: n, style: { display: 'inline-flex', gap: 4, marginRight: 12 } },
h('input', { type: 'checkbox', checked: secrets.has(n), onChange: (e) => { const s = new Set(secrets); e.target.checked ? s.add(n) : s.delete(n); setSecrets(s); } }),
h('code', null, n)))
: h('span', { style: { opacity: .55 } }, 'None offered. Add names to sync.offerSecrets in the dsh-remote config.')),
h('button', { style: S.btn, onClick: save }, 'Save sync policy'), ' ', msg && h('span', { style: { fontSize: 12, opacity: .75 } }, msg));
}
/**
* Auth proxy (edge) credentials for reaching the master through Authentik /
* oauth2-proxy / Cloudflare Access / Basic auth. Secrets are sent once and stored by
* the host as DSH credential refs; the form never shows them again.
*/
function EdgeAuthFields({ value, onChange }) {
const v = value || { mode: 'none' };
const set = (patch) => onChange({ ...v, ...patch });
const row = (label, el, hint) => h(Field, { label, hint }, el);
const input = (key, props = {}) => h('input', { style: S.input, value: v[key] || '', onChange: (e) => set({ [key]: e.target.value }), ...props });
return h('div', { style: { margin: '6px 0 10px', padding: '8px 10px', border: '1px dashed rgba(127,127,127,.35)', borderRadius: 8 } },
row('Auth proxy in front of the master',
h('select', { style: S.input, value: v.mode, onChange: (e) => onChange({ mode: e.target.value }) },
h('option', { value: 'none' }, 'None (LAN / proxy exempts /dsh-remote/*)'),
h('option', { value: 'basic' }, 'Basic: user + (app) password (Authentik, nginx, Traefik)'),
h('option', { value: 'oauth2' }, 'OAuth2 client credentials (Authentik machine-to-machine)'),
h('option', { value: 'bearer' }, 'Bearer token'),
h('option', { value: 'cookie' }, 'Session cookie (paste, e.g. authentik_proxy_… from a signed-in browser)'),
h('option', { value: 'headers' }, 'Custom headers (Cloudflare Access service token, …)')),
'The slave logs in to the proxy itself, keeps the session cookie and replays it on the link.'),
v.mode === 'basic' && h(React.Fragment, null,
row('Username', input('username', { placeholder: 'e.g. dsh-remote-mac (Authentik service account)' })),
row('Password / app password', input('password', { type: 'password', placeholder: '(stored as credential DSH_REMOTE_EDGE_PASSWORD)' }),
'Authentik: Directory → Tokens and App passwords → create an App password for the user, and enable “Intercept header authentication” on the proxy provider.')),
v.mode === 'oauth2' && h(React.Fragment, null,
row('Token URL', input('tokenUrl', { placeholder: 'https://auth.rizzy.dev/application/o/token/' })),
row('Client ID', input('clientId', { placeholder: 'proxy provider client_id' })),
row('Username (Authentik service account)', input('username')),
row('App password', input('password', { type: 'password' })),
row('Client secret (optional)', input('clientSecret', { type: 'password' }))),
v.mode === 'bearer' && row('Token', input('token', { type: 'password', placeholder: '(stored as credential DSH_REMOTE_EDGE_TOKEN)' })),
v.mode === 'cookie' && row('Proxy session cookie',
h('textarea', { style: { ...S.input, minHeight: 60, fontFamily: 'ui-monospace,monospace' }, value: v.cookie || '', placeholder: 'authentik_proxy_a420a2e1=ABC… (or the whole "Cookie:" header)', onChange: (e) => set({ cookie: e.target.value }) }),
'Sign in to the master in a browser, then copy the proxy cookie: DevTools → Application → Cookies → the master site (Authentik: authentik_proxy_<hash>). It is stored as credential DSH_REMOTE_EDGE_COOKIE; the proxy\'s refreshed cookies are saved back automatically. The master DSH\'s own dsh-auth-* cookie is ignored.'),
v.mode === 'headers' && row('Headers (one per line, "Name: value")',
h('textarea', { style: { ...S.input, minHeight: 60, fontFamily: 'ui-monospace,monospace' }, value: v.headersText || '', placeholder: 'CF-Access-Client-Id: xxx.access\nCF-Access-Client-Secret: yyy', onChange: (e) => set({ headersText: e.target.value }) })));
}
function edgeSpec(v) {
if (!v || v.mode === 'none') return { mode: 'none' };
if (v.mode === 'headers') {
const headers = {};
for (const line of String(v.headersText || '').split('\n')) {
const at = line.indexOf(':');
if (at > 0) headers[line.slice(0, at).trim()] = line.slice(at + 1).trim();
}
return { mode: 'headers', headers };
}
const { headersText, ...rest } = v;
return rest;
}
function EdgeAuthCard({ s }) {
const [edit, setEdit] = useState(null);
const [info, setInfo] = useState(null);
const [busy, setBusy] = useState(false);
const run = async (body) => {
setBusy(true);
try { setInfo(body ? await post('edge-auth', body) : await api('edge-auth')); if (body) setEdit(null); }
catch (e) { setInfo({ error: e.message }); }
setBusy(false); store.refresh();
};
if (s.role !== 'slave' || !s.master?.url) return null;
const cur = info?.edgeAuth || s.master.edgeAuth || { mode: 'none' };
return h('div', { style: S.card },
h('strong', null, 'Auth proxy (edge) login'),
h('div', { style: { fontSize: 13, margin: '4px 0 8px' } },
'Mode: ', h('code', null, cur.mode || 'none'),
cur.username ? h('span', null, ' · user ', h('code', null, cur.username)) : null,
cur.tokenValidUntil ? ` · token valid until ${new Date(cur.tokenValidUntil).toLocaleTimeString()}` : '',
cur.cookies?.length ? ` · proxy session cookie: ${cur.cookies.join(', ')}` : ''),
info?.probe && h('div', { style: { fontSize: 13, margin: '0 0 8px', color: info.probe.reachedDsh ? '#3fb950' : '#f85149' } },
info.probe.reachedDsh ? '✓ The proxy lets this device through to the master DSH.' : `✗ ${info.probe.error}${info.probe.hint ? ` (${info.probe.hint})` : ''}`),
info?.error && h('div', { style: S.err }, info.error),
edit ? h('div', null,
h(EdgeAuthFields, { value: edit, onChange: setEdit }),
h('button', { style: S.btnPrimary, disabled: busy, onClick: () => run({ edgeAuth: edgeSpec(edit) }) }, busy ? 'Saving…' : 'Save & reconnect'), ' ',
h('button', { style: S.btn, onClick: () => setEdit(null) }, 'Cancel'))
: h('span', null,
h('button', { style: S.btn, disabled: busy, onClick: () => run(null) }, busy ? 'Testing…' : 'Test'), ' ',
h('button', { style: S.btn, onClick: () => setEdit({ mode: cur.mode || 'none', username: cur.username, tokenUrl: cur.tokenUrl, clientId: cur.clientId }) }, 'Change')));
}
function SettingsPanel() {
const st = useStore();
const [code, setCode] = useState(null);
const [masterUrl, setMasterUrl] = useState('');
const [pairCode, setPairCode] = useState('');
const [edge, setEdge] = useState({ mode: 'none' });
const [msg, setMsg] = useState('');
const [busy, setBusy] = useState(false);
const [open, setOpen] = useState({});
const [syncRes, setSyncRes] = useState(null);
const s = st.state;
if (!s) return h('div', { style: { padding: 4 } }, st.error ? h('span', { style: S.err }, st.error) : 'Loading…');
const act = async (fn) => { setBusy(true); setMsg(''); try { await fn(); } catch (e) { setMsg(e.message); } setBusy(false); store.refresh(); };
const header = h('div', { style: S.card },
h('div', { style: S.devHead }, h('strong', null, s.self.name), h('span', { style: S.badge('accent') }, s.role), h('code', { style: { opacity: .6, fontSize: 12 } }, s.self.id)),
s.role === 'master' && h('div', { style: { fontSize: 13, opacity: .8 } },
'Slaves pair with this URL: ', h('code', null, s.publicUrl || s.lanUrl || location.origin + mount.root.replace(/\/$/, '')),
s.publicUrl && s.lanUrl ? h('span', null, ' (LAN: ', h('code', null, s.lanUrl), ')') : null),
s.role === 'slave' && h('div', { style: { fontSize: 13 } },
h('span', { style: S.dot(s.master?.linked) }), ' ',
s.master?.linked ? `Linked to ${s.master.info?.name} · ${s.master.url}` : `Not linked (${s.master?.url || 'no master'})${s.master?.error ? ': ' + s.master.error : ''}`));
const pairing = s.role === 'master'
? h('div', { style: S.card },
h('strong', null, 'Pair a new device'),
h('p', { style: { fontSize: 13, opacity: .75, margin: '4px 0 10px' } }, 'Create a one-time code (valid 15 minutes), then enter it in Settings → Remote devices on the new machine.'),
code ? h('div', null, h('span', { style: S.code }, code.code), h('div', { style: { fontSize: 12, opacity: .6, marginTop: 4 } }, `expires ${new Date(code.expiresAt).toLocaleTimeString()}`))
: h('button', { style: S.btnPrimary, disabled: busy, onClick: () => act(async () => setCode(await post('pairing', {}))) }, 'Create pairing code'))
: h('div', { style: S.card },
h('strong', null, s.master?.url ? 'Re-pair with a master' : 'Pair with a master'),
h(Field, { label: 'Master URL', hint: 'e.g. https://dsh.rizzy.dev or http://192.168.0.111:3080' },
h('input', { style: S.input, value: masterUrl || window.__dshrMasterOrigin || '', placeholder: 'https://…', onChange: (e) => setMasterUrl(e.target.value) })),
h(Field, { label: 'Pairing code' }, h('input', { style: { ...S.input, fontFamily: 'ui-monospace,monospace' }, value: pairCode, placeholder: 'ABCD-EFGH-…', onChange: (e) => setPairCode(e.target.value.toUpperCase()) })),
h(EdgeAuthFields, { value: edge, onChange: setEdge }),
h('button', { style: S.btnPrimary, disabled: busy || !(masterUrl || window.__dshrMasterOrigin) || !pairCode, onClick: () => act(async () => {
const r = await post('pair', { masterUrl: masterUrl || window.__dshrMasterOrigin, code: pairCode, edgeAuth: edgeSpec(edge) });
setMsg(r.restartRequired ? 'Paired. Restart DSH on this machine to switch it to slave mode.' : `Paired with ${r.master?.name}.`);
}) }, 'Pair'),
s.master?.url && h('span', null, ' ', h('button', { style: S.btn, disabled: busy, onClick: () => act(() => post('unpair', {})) }, 'Unpair')));
const devices = s.role === 'master' && h('div', { style: S.card },
h('strong', null, 'Paired devices'),
(s.devices || []).filter((d) => !d.self).length === 0 && h('div', { style: { fontSize: 13, opacity: .6, marginTop: 6 } }, 'No devices yet.'),
(s.devices || []).filter((d) => !d.self).map((d) => h('div', { key: d.id, style: { padding: '8px 0', borderTop: '1px solid rgba(127,127,127,.15)' } },
h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, fontSize: 14 } },
h('span', { style: S.dot(d.online) }), h('strong', null, d.name), h('code', { style: { fontSize: 12, opacity: .6 } }, d.id),
d.revoked && h('span', { style: S.badge('warn') }, 'revoked'),
h('span', { style: { fontSize: 12, opacity: .6 } }, d.online ? `online since ${ago(d.connectedAt)}` : d.lastSeenAt ? `seen ${ago(d.lastSeenAt)}` : 'never connected'),
h('span', { style: { flex: 1 } }),
h('button', { style: S.btn, onClick: () => setOpen({ ...open, [d.id]: !open[d.id] }) }, open[d.id] ? 'Hide sync' : 'Sync policy'),
!d.revoked && h('button', { style: S.btn, disabled: busy, onClick: () => confirm(`Revoke ${d.name}? It will be disconnected and must re-pair.`) && act(() => post(`devices/${d.id}/revoke`, {})) }, 'Revoke'),
h('button', { style: S.btn, disabled: busy, onClick: () => confirm(`Remove ${d.name} completely?`) && act(() => post(`devices/${d.id}/remove`, {})) }, 'Remove')),
open[d.id] && h(DeviceSync, { d, offer: s.sync?.offerSecrets || [], onSaved: () => store.refresh() }))));
const syncCard = h('div', { style: S.card },
h('strong', null, 'Plugin + config sync'),
s.role === 'master'
? h('p', { style: { fontSize: 13, opacity: .75, margin: '4px 0 8px' } }, 'Slaves pull this machine\'s bundles (registry + local link: plugins) and its cordis.patch.yml (minus machine-local entries) into a managed layer that their own config overrides.')
: h('div', { style: { fontSize: 13, margin: '4px 0 8px' } },
h('div', null, `Auto sync: ${s.sync?.auto ? 'on' : 'off'} · allow: ${listToText(s.sync?.allow) || 'all'} · deny: ${listToText(s.sync?.deny) || 'none'}`),
h('div', null, 'Accepted secrets: ', listToText(s.sync?.secrets) || h('span', { style: { opacity: .6 } }, 'none (set sync.secrets in this device\'s dsh-remote config)')),
s.lastSync && h('div', { style: { marginTop: 6, opacity: .8 } },
`Last sync ${ago(s.lastSync.at)}: ${s.lastSync.installed?.length || 0} installed, ${s.lastSync.updated?.length || 0} updated, ${s.lastSync.failed?.length || 0} failed, ${s.lastSync.patchEntries || 0} config entries`,
(s.lastSync.failed || []).map((f, i) => h('div', { key: i, style: S.err }, f)))),
h('button', { style: S.btn, disabled: busy, onClick: () => act(async () => {
const r = await post('sync', {});
setSyncRes(r);
if (r.restartRequired) setMsg('Synced. Some plugins changed: restart DSH on this device to load them.');
}) }, s.role === 'master' ? 'Push sync to all devices' : 'Sync now'),
syncRes && s.role === 'master' && h('span', { style: { fontSize: 12, opacity: .7 } }, ` notified ${syncRes.pushed} device(s)`));
return h('div', { 'data-dsh-plugin': 'dsh-remote' }, h(NodeCard, { s }), header, pairing, h(EdgeAuthCard, { s }), devices, syncCard, msg && h('div', { style: { ...S.card, fontSize: 13 } }, msg));
}
// ------------------------------------------------------------------ toasts
function ToastHost() {
const [items, setItems] = useState([]);
useEffect(() => {
const l = (ev) => {
if (!['session-finished', 'session-moved', 'session-conflict', 'dshr-local-toast'].includes(ev.type)) return;
const id = `${ev.type}:${ev.device}:${ev.sessionId}:${ev.at || Date.now()}:${Math.random()}`;
setItems((xs) => [...xs.slice(-2), { id, ev }]);
};
try {
const pending = sessionStorage.getItem('dshr-pending-toast');
if (pending) { sessionStorage.removeItem('dshr-pending-toast'); setTimeout(() => l(JSON.parse(pending)), 400); }
} catch { /* ignore */ }
store.events.add(l);
return () => store.events.delete(l);
}, []);
return h(React.Fragment, null, items.map(({ id, ev }) => {
const selfId = store.state?.self?.id;
const done = () => setItems((xs) => xs.filter((x) => x.id !== id));
const open = (device, sessionId) => ({ label: 'Open', prefix: ' · ', onClick: () => { location.href = sessionUrl(device, selfId, sessionId); done(); } });
let text; let tone = 'success'; let holdMs = 6000; let actions = [];
if (ev.type === 'session-finished') {
text = `${ev.deviceName}: “${ev.title || 'session'}” finished`;
actions = [open(ev.device, ev.sessionId)];
} else if (ev.type === 'session-moved') {
text = `“${ev.title || 'Conversation'}” now continues on ${ev.other?.deviceName || ev.other?.device}`;
tone = 'info'; holdMs = 8000;
actions = [{ ...open(ev.other.device, ev.other.sessionId), label: 'Go there' }];
} else if (ev.type === 'session-conflict') {
text = `“${ev.title || 'Conversation'}” diverged: it got new messages both here and on ${ev.other?.deviceName || ev.other?.device}. Nothing was merged — both copies are kept.`;
tone = 'warning'; holdMs = 12000;
actions = [{ ...open(ev.other.device, ev.other.sessionId), label: 'Other copy' }];
} else {
text = ev.text; tone = ev.tone || 'info'; holdMs = ev.holdMs || 6000;
}
const icon = tone === 'warning' || tone === 'error' ? h('span', { style: { color: '#d29922' } }, '⚠') : undefined;
if (P.Toast) return h(P.Toast, { key: id, text, tone, icon, holdMs, actions, onDone: done });
return h('div', { key: id, onClick: actions[0]?.onClick || done, style: { position: 'fixed', top: 16, left: '50%', transform: 'translateX(-50%)', zIndex: 9999, background: '#1f6feb', color: '#fff', padding: '8px 14px', borderRadius: 8, cursor: 'pointer', fontSize: 14 } }, text);
}));
}
// ------------------------------------------------------------------ native sidebar: this device's chats
// The stock session rows belong to the device whose UI this page shows.
const viewedDevice = () => mount.device || store.state?.self?.id;
const localLineage = (sessionId) => (store.sessions || []).find((d) => d.device === viewedDevice())?.items?.find((s) => s.sessionId === sessionId)?.lineage || null;
const picker = { value: null, listeners: new Set(), set(v) { this.value = v; this.listeners.forEach((l) => l()); } };
/** Re-render on store changes without starting another refresh timer (one per sidebar row). */
function useStoreQuiet() {
const [, force] = useState(0);
useEffect(() => { const l = () => force((x) => x + 1); store.listeners.add(l); return () => store.listeners.delete(l); }, []);
return store;
}
/** "…" menu row: continue this chat on another device. */
function ContinueMenuItem({ sessionId, displayTitle, useMenuOpenState }) {
const [, setMenuOpen] = useMenuOpenState();
const st = useStoreQuiet();
const others = (st.state?.devices || []).filter((d) => d.online && !d.revoked && d.id !== viewedDevice());
if (!others.length) return null;
const onSelect = () => { setMenuOpen(false); picker.set({ sessionId, title: displayTitle }); };
const icon = h(ServerIcon, { size: 16 });
if (P.MenuItemButton) return h(P.MenuItemButton, { icon, onSelect }, 'Continue on another device…');
return h('button', { type: 'button', role: 'menuitem', onClick: onSelect }, 'Continue on another device…');
}
/** Row marker: moved away / diverged / continued from another device. */
function LineageRowMark({ sessionId }) {
useStoreQuiet();
const lin = localLineage(sessionId);
if (!lin) return null;
const m = lin.conflict ? { t: '⚠', c: '#d29922', tip: `Diverged: this chat and its copy on ${devName(lin.conflict.other?.device)} both got new messages. Both are kept.` }
: lin.moved ? { t: '→', c: '#58a6ff', tip: `Continues on ${devName(lin.moved.device)}` }
: lin.origin ? { t: '⇣', c: 'inherit', tip: `Continued from ${lin.origin.deviceName || lin.origin.device}` } : null;
if (!m) return null;
const go = lin.moved && !lin.conflict ? (e) => { e.stopPropagation(); location.href = sessionUrl(lin.moved.device, store.state?.self?.id, lin.moved.sessionId); } : undefined;
return h('span', { title: m.tip, onClick: go, style: { color: m.c, fontSize: 12, padding: '0 4px', cursor: go ? 'pointer' : 'default', opacity: .85 } }, m.t);
}
function PickerHost() {
const [v, setV] = useState(picker.value);
const st = useStoreQuiet();
const [busy, setBusy] = useState('');
const [err, setErr] = useState('');
useEffect(() => { const l = () => { setV(picker.value); setErr(''); setBusy(''); }; picker.listeners.add(l); return () => picker.listeners.delete(l); }, []);
if (!v) return null;
const selfId = st.state?.self?.id;
const source = viewedDevice();
const targets = (st.state?.devices || []).filter((d) => d.online && !d.revoked && d.id !== source);
const close = () => picker.set(null);
const go = async (target) => {
setBusy(target); setErr('');
try { await continueSession({ sourceDevice: source, sessionId: v.sessionId, title: v.title, targetDevice: target }); close(); }
catch (e) { setErr(e.message); setBusy(''); }
};
return h('div', { onClick: close, style: { position: 'fixed', inset: 0, zIndex: 10000, background: 'rgba(0,0,0,.35)', display: 'flex', alignItems: 'center', justifyContent: 'center' } },
h('div', { onClick: (e) => e.stopPropagation(), role: 'dialog', 'aria-label': 'Continue on another device', style: { width: 380, maxWidth: '90vw', backgroundColor: 'var(--dsw-alias-bg-base, #1c1c1c)', backgroundImage: 'linear-gradient(var(--dsw-specific-menu, transparent), var(--dsw-specific-menu, transparent))', color: 'var(--dsw-alias-label-primary, inherit)', border: '1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.3))', borderRadius: 12, padding: 16, boxShadow: '0 12px 40px rgba(0,0,0,.4)', fontSize: 14 } },
h('div', { style: { fontWeight: 600, marginBottom: 4 } }, 'Continue on another device'),
h('div', { style: { fontSize: 12, opacity: .7, marginBottom: 12 } }, `“${v.title || 'Conversation'}” is copied through its last finished reply and opens there as a native chat. This copy is marked as moved.`),
targets.length === 0 && h('div', { style: { opacity: .7 } }, 'No other device is online.'),
targets.map((d) => h('button', { key: d.id, disabled: !!busy, onClick: () => go(d.id), style: { ...S.btn, display: 'flex', width: '100%', alignItems: 'center', gap: 8, marginBottom: 6, padding: '8px 10px', textAlign: 'left' } },
h(d.id === selfId ? FolderIcon : ServerIcon, { size: 14 }),
h('span', { style: { flex: 1 } }, d.id === selfId ? `${d.name} (this browser's device)` : d.name),
busy === d.id && h('span', { style: { fontSize: 12, opacity: .7 } }, 'syncing…'))),
err && h('div', { style: { ...S.err, marginTop: 6 } }, err),
h('div', { style: { textAlign: 'right', marginTop: 8 } }, h('button', { style: S.btn, onClick: close }, 'Cancel'))));
}
function mountPicker() {
const el = document.createElement('div');
el.id = 'dshr-picker';
document.body.appendChild(el);
const root = ReactDOM.createRoot ? ReactDOM.createRoot(el) : null;
if (root) root.render(h(PickerHost)); else ReactDOM.render(h(PickerHost), el);
return () => { try { root ? root.unmount() : ReactDOM.unmountComponentAtNode(el); } catch { /* ignore */ } el.remove(); };
}
function mountToasts() {
const el = document.createElement('div');
el.id = 'dshr-toasts';
document.body.appendChild(el);
const root = ReactDOM.createRoot ? ReactDOM.createRoot(el) : null;
if (root) root.render(h(ToastHost));
else ReactDOM.render(h(ToastHost), el);
return () => { try { root ? root.unmount() : ReactDOM.unmountComponentAtNode(el); } catch { /* ignore */ } el.remove(); };
}
// ------------------------------------------------------------------ auth-proxy sign-in popup
// When the slave's link meets a non-DSH answer from the master (a login wall) and "auth
// middleware" is on, open the master's /dsh-remote/signin through the proxy in a popup. The
// user signs in to the proxy there; the page hands the proxy session cookie back with
// postMessage (after an explicit click) and we store it and retry the link.
const signIn = { win: null, lastAt: 0 };
async function startSignIn(masterUrl, { force = false } = {}) {
if (!masterUrl) return { ok: false, error: 'no master origin' };
if (signIn.win && !signIn.win.closed && !force) { signIn.win.focus(); return { ok: true, pending: true }; }
const url = `${masterUrl.replace(/\/+$/, '')}/dsh-remote/signin?origin=${encodeURIComponent(location.origin)}`;
const win = window.open(url, 'dshr-signin', 'popup=yes,width=520,height=720');
if (!win) return { ok: false, blocked: true, url };
signIn.win = win; signIn.lastAt = Date.now();
return { ok: true, url };
}
window.addEventListener('message', async (ev) => {
const d = ev.data;
if (!d || d.type !== 'dsh-remote-signin' || typeof d.cookie !== 'string') return;
// Only accept from the master we are paired with.
const master = store.state?.master?.url;
let ok = false;
try { ok = !!master && new URL(master).origin === ev.origin; } catch { ok = false; }
if (!ok) return;
try {
await post('edge-auth', { edgeAuth: { mode: 'cookie', cookie: d.cookie } });
await post('retry-link', {});
} catch (e) { console.warn('[dsh-remote] sign-in hand-over failed:', e.message); }
store.refresh();
});
// Auto-open once per distinct auth-required event, when the setting is on.
let lastPrompted = 0;
store.events.add((ev) => {
if (ev.type !== 'auth-required' || mount.device) return;
if (!ev.at || ev.at === lastPrompted || Date.now() - signIn.lastAt < 30000) return;
lastPrompted = ev.at;
showSignInBanner(ev);
});
let bannerEl = null;
function showSignInBanner(ev) {
if (bannerEl) return;
bannerEl = document.createElement('div');
Object.assign(bannerEl.style, { position: 'fixed', top: '14px', left: '50%', transform: 'translateX(-50%)', zIndex: 10000, background: '#1f2937', color: '#fff', padding: '10px 14px', borderRadius: '10px', fontSize: '13px', fontFamily: 'system-ui, sans-serif', boxShadow: '0 6px 24px rgba(0,0,0,.35)', display: 'flex', gap: '10px', alignItems: 'center', maxWidth: '90vw' });
const span = document.createElement('span');
span.textContent = 'The auth proxy in front of the master needs you to sign in.';
const go = document.createElement('button');
go.textContent = 'Sign in';
Object.assign(go.style, { font: 'inherit', padding: '4px 12px', borderRadius: '6px', border: 0, background: '#2f81f7', color: '#fff', cursor: 'pointer' });
const no = document.createElement('button');
no.textContent = 'Later';
Object.assign(no.style, { font: 'inherit', padding: '4px 10px', borderRadius: '6px', border: '1px solid #555', background: 'none', color: '#ddd', cursor: 'pointer' });
const close = () => { bannerEl?.remove(); bannerEl = null; };
// window.open from a click (not from the event) so popup blockers allow it.
go.onclick = async () => { close(); const r = await startSignIn(ev.masterUrl, { force: true }); if (r.blocked) alert(`Allow popups for this site, or open ${r.url} manually.`); };
no.onclick = close;
bannerEl.append(span, go, no);
document.body.appendChild(bannerEl);
}
/** Settings card: node type, origin, auth middleware. */
function NodeCard({ s }) {
const node = s.node || {};
const [type, setType] = useState(node.nodeType || s.role);
const [middleware, setMiddleware] = useState(!!node.authMiddleware);
const [origin, setOrigin] = useState('');
const [msg, setMsg] = useState('');
const [busy, setBusy] = useState(false);
const own = location.origin + mount.root.replace(/\/$/, '');
const save = async (patch) => {
setBusy(true); setMsg('');
try { const r = await post('node', patch); setMsg(r.restartRequired ? 'Saved. Restart DSH on this machine to switch its node type.' : 'Saved.'); }
catch (e) { setMsg(e.message); }
setBusy(false); store.refresh();
};
const restart = async () => { try { await post('restart', {}); setMsg('Restarting… reload the page in a few seconds.'); } catch (e) { setMsg(e.message); } };
const effective = node.effectiveNodeType || s.role;
const needsRestart = node.restartRequired;
return h('div', { style: S.card },
h('strong', null, 'Node'),
h(Field, { label: 'Type' },
h('select', { style: S.input, value: type, disabled: busy, onChange: (e) => { setType(e.target.value); save({ nodeType: e.target.value }); } },
h('option', { value: 'master' }, 'Master: holds plugins, config and sessions; slaves connect to it'),
h('option', { value: 'slave' }, 'Slave: connects to a master and syncs from it'))),
needsRestart && h('div', { style: { fontSize: 13, margin: '0 0 10px', color: '#d29922' } },
`Running as ${effective}; ${node.nodeType} applies after a restart. `,
h('button', { style: S.btn, onClick: restart }, 'Restart DSH')),
effective === 'master'
? h(Field, { label: 'Origin', hint: 'The address slaves use to reach this master (browser-visible origin of this page).' },
h('input', { style: { ...S.input, opacity: .75 }, value: s.publicUrl || s.lanUrl || own, disabled: true, readOnly: true }))
: h(Field, { label: 'Master origin', hint: store.state?.master?.url ? 'Paired. To use another master, re-pair below.' : 'Enter the master\'s origin, then pair below with a pairing code.' },
store.state?.master?.url
? h('input', { style: { ...S.input, opacity: .75 }, value: store.state.master.url, disabled: true, readOnly: true })
: h('input', { style: S.input, value: origin, placeholder: 'https://dsh.example.com or http://192.168.0.111:3080', onChange: (e) => { setOrigin(e.target.value); window.__dshrMasterOrigin = e.target.value; } })),
h('label', { style: { display: 'flex', gap: 8, alignItems: 'flex-start', fontSize: 13, margin: '4px 0 6px' } },
h('input', { type: 'checkbox', checked: middleware, disabled: busy, onChange: (e) => { setMiddleware(e.target.checked); save({ authMiddleware: e.target.checked }); } }),
h('span', null,
h('strong', null, 'Master has an auth middleware'),
h('div', { style: { opacity: .65, fontSize: 12, marginTop: 2 } }, 'On: when the link meets something that is not DSH (a sign-in page of Authentik, oauth2-proxy, …), a sign-in window opens, then the link retries. Off: work as usual, no prompts.'))),
node.authRequired && effective === 'slave' && h('div', { style: { fontSize: 13, margin: '6px 0', color: '#f85149' } },
node.authRequired.reason || 'The master asked for sign-in.', ' ',
h('button', { style: S.btnPrimary, onClick: async () => { const r = await startSignIn(store.state?.master?.url, { force: true }); if (r.blocked) setMsg(`Popup blocked: open ${r.url}`); } }, 'Sign in')),
middleware && effective === 'slave' && store.state?.master?.url && !node.authRequired && h('div', null,
h('button', { style: S.btn, onClick: () => startSignIn(store.state.master.url, { force: true }) }, 'Sign in to the auth proxy…'), ' ',
h('button', { style: S.btn, onClick: async () => { try { const r = await post('retry-link', {}); setMsg(r.linked ? 'Linked.' : (r.error || 'Not linked yet.')); } catch (e) { setMsg(e.message); } store.refresh(); } }, 'Retry link')),
msg && h('div', { style: { fontSize: 12, opacity: .8, marginTop: 6 } }, msg));
}
function mountRemoteBanner() {
if (!mount.device) return () => {};
const el = document.createElement('a');
el.href = mount.root;
el.textContent = `● Remote: ${mount.device} · back to local`;
el.title = 'You are using the DSH of another device through dsh-remote. Sessions run there.';
Object.assign(el.style, { position: 'fixed', bottom: '10px', right: '12px', zIndex: 9998, background: 'rgba(31,111,235,.92)', color: '#fff', padding: '4px 10px', borderRadius: '12px', fontSize: '12px', textDecoration: 'none', fontFamily: 'system-ui, sans-serif' });
document.body.appendChild(el);
return () => el.remove();
}
// ------------------------------------------------------------------ plugin
return {
inject: ['slots'],
apply(ctx) {
// Toasts / banner only belong to the outermost (local) UI, not proxied copies.
startStream();
const disposers = [];
if (!mount.device) disposers.push(mountToasts());
disposers.push(mountRemoteBanner());
// Other machines' workspaces inside the stock Workspaces list (server icon).
disposers.push(mountSidebarRemote());
disposers.push(ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
name: 'sidebar.panellist', id: PANEL_ID, order: 50, label: 'Devices',
}, DeviceIcon)));
disposers.push(ctx.slots.inject('main', () => ctx.slots.register({ name: 'main', key: PANEL_ID }, DevicesPage)));
disposers.push(ctx.slots.inject('settings.section', () => ctx.slots.register({
name: 'settings.section', id: 'dsh-remote', order: 160, label: 'Remote devices',
}, SettingsPanel)));
// Native chat rows: "Continue on another device…" and a moved/diverged marker.
disposers.push(mountPicker());
disposers.push(ctx.slots.inject('sidebar.workspaces.session.menu.item', () => ctx.slots.register({
name: 'sidebar.workspaces.session.menu.item', id: 'dsh-remote-continue', order: 260,
}, ContinueMenuItem)));
disposers.push(ctx.slots.inject('sidebar.workspaces.session.row.action', () => ctx.slots.register({
name: 'sidebar.workspaces.session.row.action', id: 'dsh-remote-lineage', order: 50,
}, LineageRowMark)));
ctx.effect(() => () => {
for (const d of disposers.splice(0)) { try { d && d(); } catch { /* ignore */ } }
try { es && es.close(); } catch { /* ignore */ }
es = null;
}, 'dsh-remote: ui');
},
};
},
});