Conversation sync done properly: fork-shaped seeded import adopted by the session controller, paged transfer, replicas of remote chats for offline continuation, move/divergence ledger with queued notices delivered on reconnect, native sidebar menu item + row markers, toasts

This commit is contained in:
Agent committed 2026-10-21 19:10:03 +00:00
1 parent 0f866b1bbd
commit 2e699a061f
7 files changed
+904 -461

No files matched your search

+226 -101
View File
@@ -135,68 +135,88 @@ window.__ModuleLoader__.load({
h('rect', { x: 11.5, y: 5.5, width: 3, height: 8, rx: .8 }));
}
function SessionRow({ dev, s, selfId, devices = [] }) {
// ------------------------------------------------------------------ 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 isRemote = dev.device !== selfId;
const doContinue = async (targetDev) => {
setBusy(true);
try {
const r = await post('continue-session', {
sourceDevice: dev.device,
sessionId: s.sessionId,
targetDevice: targetDev,
});
if (r.conflict) {
alert('Notice: Changes diverged! Both instances modified this session while disconnected.');
}
location.href = sessionUrl(targetDev, selfId, r.sessionId);
if (targetDev === selfId) setTimeout(() => location.reload(), 50);
} catch (e) {
alert('Failed to continue session: ' + e.message);
} finally {
setBusy(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('div', {
className: 'dshr-row-wrap',
style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }
},
h('a', {
className: 'dshr-row', style: { ...S.row, color: 'inherit', textDecoration: 'none', flex: 1, minWidth: 0 },
href: sessionUrl(dev.device, selfId, s.sessionId),
title: `${s.title || s.sessionId}\n${s.cwd || ''}`,
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); },
},
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')),
s.lineage?.conflict && h('span', { style: { ...S.badge('warn'), marginRight: 6 } }, '⚠ Diverged'),
s.lineage?.status === 'moved' && h('span', { style: { ...S.badge('accent'), marginRight: 6 } }, `⇄ Moved to ${s.lineage.movedTo}`),
h('span', { style: S.meta }, ago(s.updatedAt))
),
h('div', { style: { display: 'flex', alignItems: 'center', gap: 4, flexShrink: 0, paddingRight: 6 } },
isRemote && h('button', {
style: { ...S.btn, fontSize: 11, padding: '2px 7px' },
disabled: busy,
title: 'Seamlessly sync conversation to current instance and continue here',
onClick: (e) => { e.preventDefault(); e.stopPropagation(); doContinue(selfId); }
}, busy ? 'Syncing…' : '⇄ Continue here'),
devices.length > 2 && h('select', {
style: { ...S.input, fontSize: 11, padding: '2px 4px', width: 'auto' },
value: '',
disabled: busy,
onChange: (e) => {
const dest = e.target.value;
if (dest) doContinue(dest);
}
},
h('option', { value: '' }, 'Continue on…'),
devices.filter((d) => d.online && d.id !== dev.device).map((d) =>
h('option', { key: d.id, value: d.id }, d.self ? `${d.name} (this device)` : d.name)
)
)
)
);
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 }) {
@@ -264,7 +284,11 @@ window.__ModuleLoader__.load({
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 devices = (st.state?.devices || []).filter((d) => !d.revoked || d.self);
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'),
@@ -301,6 +325,8 @@ window.__ModuleLoader__.load({
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 },
@@ -335,6 +361,31 @@ window.__ModuleLoader__.load({
`;
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 {}; } });
@@ -342,7 +393,7 @@ window.__ModuleLoader__.load({
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);
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] };
@@ -360,43 +411,14 @@ window.__ModuleLoader__.load({
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}`, onClick: () => toggle(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),
h('button', { className: 'dshr-sb-plus', title: `New session on ${dev.name}`, onClick: (e) => create(e, dev, g) }, '+'),
h('span', { className: 'dshr-sb-dev' }, dev.name || dev.device)),
isOpen && shown.map((s) => h('div', {
key: s.sessionId,
className: 'dshr-sb-row dshr-sb-sess',
role: 'treeitem',
style: { display: 'flex', alignItems: 'center' }
},
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}`,
},
s.running ? h('span', { className: 'dshr-sb-run', title: 'running' }) : null,
h('span', { className: 'dshr-sb-title' }, s.title || 'untitled'),
s.lineage?.conflict && h('span', { style: { fontSize: 9, color: '#f85149', marginRight: 4 } }, '⚠'),
s.lineage?.status === 'moved' && h('span', { style: { fontSize: 9, opacity: 0.7, marginRight: 4 } }, '⇄'),
h('span', { className: 'dshr-sb-time' }, ago(s.updatedAt))),
dev.device !== selfId && h('button', {
className: 'dshr-sb-plus',
title: 'Seamlessly sync conversation to this device',
onClick: async (e) => {
e.stopPropagation(); e.preventDefault();
try {
const r = await post('continue-session', { sourceDevice: dev.device, sessionId: s.sessionId, targetDevice: selfId });
location.href = sessionUrl(selfId, selfId, r.sessionId);
setTimeout(() => location.reload(), 50);
} catch (err) {
alert('Sync error: ' + err.message);
}
}
}, '⇄')
)),
!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'));
})));
@@ -628,22 +650,117 @@ window.__ModuleLoader__.load({
const [items, setItems] = useState([]);
useEffect(() => {
const l = (ev) => {
if (ev.type !== 'session-finished') return;
const id = `${ev.device}:${ev.sessionId}:${ev.at}`;
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 text = `${ev.deviceName}: “${ev.title || 'session'}” finished`;
const selfId = store.state?.self?.id;
const done = () => setItems((xs) => xs.filter((x) => x.id !== id));
const actions = [{ label: 'Open', prefix: ' · ', onClick: () => { location.href = sessionUrl(ev.device, store.state?.self?.id, ev.sessionId); done(); } }];
if (P.Toast) return h(P.Toast, { key: id, text, tone: 'success', holdMs: 6000, actions, onDone: done });
return h('div', { key: id, onClick: actions[0].onClick, 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);
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', background: 'var(--dsw-specific-menu, var(--dsw-alias-bg-l1, #1c1c1c))', 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';
@@ -791,6 +908,14 @@ window.__ModuleLoader__.load({
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 */ } }