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:
1 parent
0f866b1bbd
commit
2e699a061f
7 files changed
+904
-461
No files matched your search
@@ -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 */ } }
|
||||
|
||||
Reference in new issue
Block a user