atagen

malice

public
Star0
Watch0

Commit 9f996e377406

tarball

atagen <boss@atagen.co>2026-10-25 03:34 UTC

10 files changed+607-5
history: add room repair controls and saved policy settings
Modified file: web/src/App.svelte+150 additions−5 deletions
@@ -4,6 +4,8 @@
44 import LoginPanel from './lib/LoginPanel.svelte';
55 import MessageRow from './lib/MessageRow.svelte';
66 import MentionsInbox from './lib/MentionsInbox.svelte';
7+ import HistoryRepairPanel from './lib/HistoryRepairPanel.svelte';
8+ import type { HistoryRepairPolicy, HistoryRepairReport } from './lib/repair-model';
79 import MxcAvatar from './lib/MxcAvatar.svelte';
810 import PinButton from './lib/PinButton.svelte';
911 import ContextMenu, { type MenuItem } from './lib/ContextMenu.svelte';
@@ -26,6 +28,10 @@
2628 getRooms,
2729 getSession,
2830 getSpaceChildren,
31+ historyRepair,
32+ continueHistoryRepair,
33+ cancelHistoryRepair,
34+ startRecentHistory,
2935 joinRoom,
3036 leaveRoom,
3137 logout,
@@ -60,6 +66,8 @@
6066 mentionsSnapshot,
6167 pushBanner,
6268 imagePacks,
69+ historyRepairs,
70+ publishHistoryRepair,
6371 resetClientState,
6472 rooms,
6573 selectedRoomId,
@@ -67,6 +75,7 @@
6775 softLogout,
6876 timelines,
6977 pinnedTimelines,
78+ pinnedTimelineVersions,
7079 verification
7180 } from './lib/store';
7281 import { discardPending, pendingByRoom, queueSend, resetPending, retryPending } from './lib/pending';
@@ -165,6 +174,115 @@
165174 let spaceChildrenError: string | null = null;
166175 let avatarByUser: Record<string, string | null> = {};
167176 let restoringRoute = false;
177+ let repairPanels: Record<string, boolean> = {};
178+ let repairBusy: Record<string, boolean> = {};
179+ let repairCancelling: Record<string, boolean> = {};
180+ let repairErrors: Record<string, string | null> = {};
181+ let repairJumps: Record<string, { previous: HistoryRepairReport | null }> = {};
182+ let repairAccount: string | null | undefined;
183+ let repairEpoch = 0;
184+ $: if ($sessionUser !== repairAccount) {
185+ repairAccount = $sessionUser;
186+ repairEpoch++;
187+ repairPanels = {};
188+ repairBusy = {};
189+ repairCancelling = {};
190+ repairErrors = {};
191+ repairJumps = {};
192+ }
193+ $: currentRepair = $selectedRoomId ? $historyRepairs[$selectedRoomId] ?? null : null;
194+ $: currentRepairActive = !!($selectedRoomId && repairBusy[$selectedRoomId]) || repairActive(currentRepair);
195+ $: maybeJumpAfterRepair($selectedRoomId, currentRepair, repairJumps);
196+ $: settleRepairCancellation($historyRepairs, repairBusy);
197+
198+ function repairActive(report: HistoryRepairReport | null | undefined): boolean {
199+ return report?.phase === 'FetchingRecent' || report?.phase === 'Bridging';
200+ }
201+
202+ function settleRepairCancellation(reports: Record<string, HistoryRepairReport>, busy: Record<string, boolean>): void {
203+ for (const [roomId, report] of Object.entries(reports)) {
204+ if (repairCancelling[roomId] && !busy[roomId] && !repairActive(report)) {
205+ repairCancelling = { ...repairCancelling, [roomId]: false };
206+ }
207+ }
208+ }
209+
210+ function maybeJumpAfterRepair(roomId: string | null, report: HistoryRepairReport | null, jumps: typeof repairJumps): void {
211+ if (!roomId || roomId !== $selectedRoomId || !report?.recent_available || !jumps[roomId] || report === jumps[roomId].previous) return;
212+ const next = { ...repairJumps };
213+ delete next[roomId];
214+ repairJumps = next;
215+ stickToBottom = true;
216+ const epoch = repairEpoch;
217+ void tick().then(() => {
218+ if (epoch === repairEpoch && $selectedRoomId === roomId) scrollToBottom();
219+ });
220+ }
221+
222+ async function showHistoryRepair(roomId: string): Promise<void> {
223+ if ($selectedRoomId !== roomId) await selectRoom(roomId);
224+ if ($selectedRoomId === roomId) repairPanels = { ...repairPanels, [roomId]: true };
225+ }
226+
227+ async function runHistoryRepair(roomId: string, action: 'start' | 'continue' | 'recent', policy: HistoryRepairPolicy | null = null): Promise<void> {
228+ if (!$sessionUser || repairBusy[roomId] || repairActive($historyRepairs[roomId]) || repairCancelling[roomId]) return;
229+ const account = $sessionUser;
230+ const epoch = repairEpoch;
231+ repairPanels = { ...repairPanels, [roomId]: true };
232+ repairBusy = { ...repairBusy, [roomId]: true };
233+ repairErrors = { ...repairErrors, [roomId]: null };
234+ if (action !== 'continue') repairJumps = { ...repairJumps, [roomId]: { previous: $historyRepairs[roomId] ?? null } };
235+ try {
236+ if (!openReady[roomId]) {
237+ await openRoom(roomId);
238+ if ($sessionUser !== account || epoch !== repairEpoch) return;
239+ openReady = { ...openReady, [roomId]: true };
240+ const failures = { ...openFailed };
241+ delete failures[roomId];
242+ openFailed = failures;
243+ touchWarmRoom(roomId);
244+ }
245+ if (repairCancelling[roomId]) return;
246+ const report = action === 'continue' ? await continueHistoryRepair(roomId)
247+ : action === 'recent' ? await startRecentHistory(roomId) : await historyRepair(roomId, policy);
248+ if ($sessionUser !== account || epoch !== repairEpoch) return;
249+ if (report.room_id !== roomId) throw new Error('History repair returned a different room');
250+ publishHistoryRepair(report, account);
251+ maybeJumpAfterRepair(roomId, report, repairJumps);
252+ } catch (cause) {
253+ if ($sessionUser === account && epoch === repairEpoch) {
254+ repairErrors = { ...repairErrors, [roomId]: cause instanceof Error ? cause.message : 'Could not repair history' };
255+ }
256+ } finally {
257+ if ($sessionUser === account && epoch === repairEpoch) {
258+ repairBusy = { ...repairBusy, [roomId]: false };
259+ repairCancelling = { ...repairCancelling, [roomId]: false };
260+ if (repairJumps[roomId]) {
261+ const next = { ...repairJumps };
262+ delete next[roomId];
263+ repairJumps = next;
264+ }
265+ }
266+ }
267+ }
268+
269+ async function cancelRoomRepair(roomId: string): Promise<void> {
270+ if (!$sessionUser || repairCancelling[roomId]) return;
271+ const account = $sessionUser;
272+ const epoch = repairEpoch;
273+ repairCancelling = { ...repairCancelling, [roomId]: true };
274+ try {
275+ await cancelHistoryRepair(roomId);
276+ if ($sessionUser === account && epoch === repairEpoch && !repairBusy[roomId] && !repairActive($historyRepairs[roomId])) {
277+ repairCancelling = { ...repairCancelling, [roomId]: false };
278+ }
279+ } catch (cause) {
280+ if ($sessionUser === account && epoch === repairEpoch) {
281+ repairCancelling = { ...repairCancelling, [roomId]: false };
282+ repairErrors = { ...repairErrors, [roomId]: cause instanceof Error ? cause.message : 'Could not cancel history repair' };
283+ }
284+ }
285+ }
168286
169287 function motionDuration(ms: number): number {
170288 return typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : ms;
@@ -643,6 +761,11 @@
643761
644762 function noteUserScroll(): void {
645763 timelineViewport?.releaseScroll();
764+ if ($selectedRoomId && repairJumps[$selectedRoomId]) {
765+ const next = { ...repairJumps };
766+ delete next[$selectedRoomId];
767+ repairJumps = next;
768+ }
646769 }
647770
648771 function syncComposer(node: HTMLTextAreaElement) {
@@ -1025,15 +1148,18 @@
10251148 }
10261149
10271150 let pinnedOpenFor: string | null = null;
1151+ let pinnedCloseVersion = 0;
10281152 let pinsExpanded = false;
10291153 let pinPreview: TimelineEvent | null = null;
10301154 let pinJumpBusy = false;
1031- $: syncPinned(selectedRoom?.room_id ?? null, (selectedRoom?.pinned_events.length ?? 0) > 0);
1032- function syncPinned(roomId: string | null, hasPins: boolean): void {
1155+ $: syncPinned(selectedRoom?.room_id ?? null, (selectedRoom?.pinned_events.length ?? 0) > 0, $selectedRoomId ? $pinnedTimelineVersions[$selectedRoomId] ?? 0 : 0, currentRepairActive);
1156+ function syncPinned(roomId: string | null, hasPins: boolean, version: number, repairing: boolean): void {
10331157 const want = hasPins ? roomId : null;
1034- if (want === pinnedOpenFor) return;
1158+ if (want === pinnedOpenFor && version === pinnedCloseVersion) return;
1159+ if (want && repairing) return;
10351160 if (pinnedOpenFor) runCommand({ op: 'close_pinned', room_id: pinnedOpenFor }).catch(() => undefined);
10361161 pinnedOpenFor = want;
1162+ pinnedCloseVersion = version;
10371163 pinsExpanded = false;
10381164 if (want) runCommand({ op: 'open_pinned', room_id: want }).catch(() => undefined);
10391165 }
@@ -1331,7 +1457,7 @@
13311457
13321458 async function paginate(): Promise<void> {
13331459 const roomId = $selectedRoomId;
1334- if (!roomId || paginateBusy) return;
1460+ if (!roomId || paginateBusy || currentRepairActive) return;
13351461 paginateBusy = true;
13361462 try {
13371463 await paginateRoom(roomId);
@@ -1579,6 +1705,9 @@
15791705 items.push({ label: room.favourite ? 'Unpin' : room.space ? 'Pin to top' : 'Pin', action: () => void togglePin(room) });
15801706 items.push({ label: 'Copy link', action: () => copyText(`https://matrix.to/#/${room.canonical_alias ?? room.room_id}`) });
15811707 items.push({ label: 'Copy room ID', action: () => copyText(room.room_id) });
1708+ if (!room.space && room.membership === 'Joined') {
1709+ items.push({ label: 'Repair message history', action: () => void showHistoryRepair(room.room_id) });
1710+ }
15821711 if (room.membership === 'Joined') items.push({ label: 'Leave', danger: true, divider: true, action: () => void leave(room.room_id) });
15831712 return items;
15841713 }
@@ -1976,6 +2105,9 @@
19762105 {#if roomMenuOpen}
19772106 <div class="room-menu" role="menu">
19782107 {#if selectedRoom.membership === 'Joined'}
2108+ <button type="button" role="menuitem" on:click={() => roomMenuAction(() => void showHistoryRepair(selectedRoom.room_id))}>
2109+ Repair message history
2110+ </button>
19792111 <button
19802112 type="button"
19812113 role="menuitem"
@@ -2328,6 +2460,19 @@
23282460 </p>
23292461 </div>
23302462 {:else}
2463+ {#if repairPanels[selectedRoom.room_id] ?? !!currentRepair}
2464+ <HistoryRepairPanel
2465+ report={currentRepair}
2466+ busy={!!repairBusy[selectedRoom.room_id]}
2467+ cancelling={!!repairCancelling[selectedRoom.room_id]}
2468+ error={repairErrors[selectedRoom.room_id] ?? null}
2469+ onStart={(policy) => void runHistoryRepair(selectedRoom.room_id, 'start', policy)}
2470+ onCancel={() => void cancelRoomRepair(selectedRoom.room_id)}
2471+ onContinue={() => void runHistoryRepair(selectedRoom.room_id, 'continue')}
2472+ onRecent={() => void runHistoryRepair(selectedRoom.room_id, 'recent')}
2473+ onClose={() => (repairPanels = { ...repairPanels, [selectedRoom.room_id]: false })}
2474+ />
2475+ {/if}
23312476 {#if pins.length}
23322477 <div class="pinned-strip" class:open={pinsExpanded}>
23332478 <div class="pinned-head">
@@ -2431,7 +2576,7 @@
24312576 {#if hasStart}
24322577 <p class="start-note">Beginning of history</p>
24332578 {:else}
2434- <button type="button" class="ghost-btn" disabled={paginateBusy} on:click={() => void paginate()}>
2579+ <button type="button" class="ghost-btn" disabled={paginateBusy || currentRepairActive} on:click={() => void paginate()}>
24352580 {paginateBusy ? 'Loading…' : 'Load older messages'}
24362581 </button>
24372582 {/if}
Added file: web/src/lib/HistoryRepairPanel.svelte+148 additions
@@ -0,0 +1,148 @@
1+<script lang="ts">
2+ import EmojiText from './EmojiText.svelte';
3+ import type { HistoryRepairPolicy, HistoryRepairReport } from './repair-model';
4+
5+ export let report: HistoryRepairReport | null = null;
6+ export let busy = false;
7+ export let cancelling = false;
8+ export let error: string | null = null;
9+ export let onStart: (policy: HistoryRepairPolicy | null) => void;
10+ export let onCancel: () => void;
11+ export let onContinue: () => void;
12+ export let onRecent: () => void;
13+ export let onClose: () => void;
14+
15+ let expanded = true;
16+ let choice: 'saved' | 'StartRecent' | 'Bridge' | 'BridgeShortGaps' = 'saved';
17+ let threshold: number | undefined = 15000;
18+
19+ $: active = busy || cancelling || report?.phase === 'FetchingRecent' || report?.phase === 'Bridging';
20+ $: canRepair = choice !== 'BridgeShortGaps' || (threshold !== undefined && Number.isSafeInteger(threshold) && threshold >= 0);
21+ $: serverFailure = serverError(report?.failure?.server);
22+ $: paused = !active && report?.phase === 'Paused';
23+ $: canContinue = paused && report?.strategy === 'Bridge';
24+
25+ function serverError(value: unknown): string {
26+ if (!value || typeof value !== 'object' || Array.isArray(value)) return '';
27+ const server = value as Record<string, unknown>;
28+ const status = server.status;
29+ const code = server.errcode;
30+ const message = server.message;
31+ if ((typeof status !== 'number' || !Number.isFinite(status)) && typeof code !== 'string' && typeof message !== 'string') return '';
32+ const parts = [typeof status === 'number' ? `HTTP ${status}` : '', typeof code === 'string' ? code : '', typeof message === 'string' ? message : ''];
33+ return parts.filter(Boolean).join(' · ');
34+ }
35+
36+ function policy(): HistoryRepairPolicy | null {
37+ if (choice === 'saved') return null;
38+ if (choice === 'BridgeShortGaps') return { BridgeShortGaps: { threshold_ms: threshold! } };
39+ return choice;
40+ }
41+
42+ function stopLabel(value: NonNullable<HistoryRepairReport['stop']>): string {
43+ const labels: Record<string, string> = {
44+ Budget: 'Paused at the repair budget',
45+ Cancelled: 'Repair cancelled',
46+ NoAnchor: 'No retained history anchor was found',
47+ HistoryUnavailable: 'History is unavailable',
48+ CursorStalled: 'Repair made no progress'
49+ };
50+ return labels[value];
51+ }
52+
53+ function continuityLabel(value: HistoryRepairReport['continuity']): string {
54+ if (value === 'Unresolved') return 'Continuity unresolved';
55+ if (value === 'Verified') return 'Continuity verified';
56+ if (value === 'AccessibleStart') return 'Beginning of accessible history reached';
57+ return value;
58+ }
59+
60+ function start(): void {
61+ if (canRepair) onStart(policy());
62+ }
63+</script>
64+
65+<section class="repair" aria-label="History repair">
66+ <div class="banner">
67+ <button type="button" class="summary" aria-expanded={expanded} on:click={() => expanded = !expanded}>
68+ <span>History repair</span>
69+ {#if active}<span class="muted">{cancelling ? 'Cancelling…' : report?.phase === 'Bridging' ? 'Bridging history…' : 'Loading recent messages…'}</span>{/if}
70+ {#if report?.recent_available === true}<span class="recent">Recent messages available</span>
71+ {:else if report?.recent_available === false}<span class="muted">Recent messages unavailable</span>
72+ {:else}<span class="muted">{active ? 'Repair in progress' : 'History recovery'}</span>{/if}
73+ {#if report?.recent_available === true && report.continuity === 'Unresolved'}<span class="muted">Continuity unresolved</span>{/if}
74+ <span aria-hidden="true">{expanded ? '▴' : '▾'}</span>
75+ </button>
76+ <button type="button" class="close" aria-label="Close history repair" on:click={onClose}>✕</button>
77+ </div>
78+ {#if expanded}
79+ <div class="details" aria-live="polite">
80+ {#if report}
81+ <p class="state">{#if active}{report.phase === 'FetchingRecent' ? 'Loading recent messages' : 'Bridging retained history'}{:else if report.recent_available}Recent messages are available{:else}Recent messages could not be restored{/if}</p>
82+ <p>{continuityLabel(report.continuity)}{report.continuity === 'Unresolved' ? '. The missing interval has not been linked to retained history.' : '.'}</p>
83+ <p>{report.strategy === 'Recent' ? 'Recent history' : report.strategy === 'Overlap' ? 'Retained overlap' : 'Bridge progress'} · {report.pages} pages and {report.events} events</p>
84+ {#if report.coverage_duration_ms !== null}<p>Gap duration {report.coverage_duration_ms} ms</p>{/if}
85+ {#if report.stop}<p>{stopLabel(report.stop)}</p>{/if}
86+ {#if report.phase === 'Paused' && report.strategy === 'Bridge'}<p class="muted">Keep this room open to continue bridging. Closing it or restarting the app clears unfinished progress.</p>{/if}
87+ {#if report.failure}
88+ <p class="failure"><EmojiText text={report.failure.message} /></p>
89+ {#if serverFailure}<p class="server-error"><EmojiText text={serverFailure} /></p>{/if}
90+ {/if}
91+ {#if !report.threshold_evaluated && typeof report.requested_policy !== 'string'}
92+ <p>The requested threshold of {report.requested_policy.BridgeShortGaps.threshold_ms} ms was not evaluated. Recent messages were used.</p>
93+ {/if}
94+ {/if}
95+ {#if error}<p class="failure" role="alert"><EmojiText text={error} /></p>{/if}
96+ {#if !active}
97+ <label class="field">Repair policy
98+ <select bind:value={choice}>
99+ <option value="saved">Use saved policy</option>
100+ <option value="StartRecent">Start recent</option>
101+ <option value="Bridge">Bridge retained history</option>
102+ <option value="BridgeShortGaps">Bridge short gaps</option>
103+ </select>
104+ </label>
105+ {#if choice === 'BridgeShortGaps'}
106+ <label class="field">Threshold in milliseconds
107+ <input type="number" min="0" step="1" bind:value={threshold} />
108+ </label>
109+ <p class="muted">The threshold cannot yet be evaluated, so this currently starts at recent messages. A threshold of zero disables automatic bridging. Bridge retained history remains available.</p>
110+ {/if}
111+ {/if}
112+ <div class="actions">
113+ {#if active}
114+ <button type="button" class="ghost-btn" disabled={cancelling} on:click={onCancel}>{cancelling ? 'Cancelling…' : 'Cancel repair'}</button>
115+ {#if cancelling}<span role="status">Waiting for the active repair to stop</span>{/if}
116+ {:else}
117+ <button type="button" class="primary-btn" disabled={!canRepair} on:click={start}>Repair history</button>
118+ {#if canContinue}
119+ <button type="button" class="ghost-btn" on:click={onContinue}>Continue bridging</button>
120+ {/if}
121+ {#if paused}
122+ <button type="button" class="ghost-btn" on:click={onRecent}>Use recent window</button>
123+ {/if}
124+ {/if}
125+ <button type="button" class="ghost-btn" on:click={onClose}>Close</button>
126+ </div>
127+ </div>
128+ {/if}
129+</section>
130+
131+<style>
132+ .repair { max-height: min(42vh, 320px); overflow: auto; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--panel); color: var(--text); }
133+ .banner { display: flex; align-items: center; gap: 6px; padding: 6px; }
134+ .summary { display: flex; flex: 1; min-width: 0; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; padding: 5px 7px; border: 0; background: transparent; color: inherit; text-align: left; cursor: pointer; }
135+ .summary span:first-child { font-weight: 600; }
136+ .recent { color: var(--ok-text); font-size: 12px; }
137+ .muted { color: var(--muted); font-size: 12px; }
138+ .close { border: 0; background: transparent; color: var(--muted); cursor: pointer; }
139+ .details { overflow: auto; padding: 0 12px 12px; font-size: 13px; }
140+ .details p { margin: 7px 0; }
141+ .state { font-weight: 600; }
142+ .field { display: flex; flex-direction: column; gap: 5px; margin-top: 10px; }
143+ select, input { width: 100%; min-height: var(--ctl-h); padding: 5px 7px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--bg-soft); color: var(--text); font: inherit; }
144+ .actions { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 10px; }
145+ .actions [role='status'] { color: var(--muted); }
146+ .failure { color: var(--danger-text); }
147+ @media (max-width: 520px) { .summary { flex-wrap: wrap; justify-content: flex-start; } }
148+</style>
Added file: web/src/lib/HistoryRepairSettings.svelte+103 additions
@@ -0,0 +1,103 @@
1+<script lang="ts">
2+ import { get } from 'svelte/store';
3+ import { onMount } from 'svelte';
4+ import { historyRepairPolicy, setHistoryRepairPolicy } from './api';
5+ import { sessionUser } from './store';
6+ import type { HistoryRepairPolicy } from './repair-model';
7+
8+ let selected: 'StartRecent' | 'Bridge' | 'BridgeShortGaps' = 'StartRecent';
9+ let threshold: number | undefined = 15000;
10+ let loading = true;
11+ let loaded = false;
12+ let busy = false;
13+ let error = '';
14+ let notice = '';
15+ let alive = true;
16+
17+ async function loadPolicy(): Promise<void> {
18+ const account = get(sessionUser);
19+ if (!account) {
20+ loading = false;
21+ loaded = false;
22+ error = 'Sign in to load the saved history repair policy.';
23+ return;
24+ }
25+ loading = true;
26+ loaded = false;
27+ error = '';
28+ try {
29+ const policy = await historyRepairPolicy();
30+ if (!alive || get(sessionUser) !== account) return;
31+ if (policy === 'StartRecent' || policy === 'Bridge') selected = policy;
32+ else {
33+ selected = 'BridgeShortGaps';
34+ threshold = policy.BridgeShortGaps.threshold_ms;
35+ }
36+ loaded = true;
37+ } catch (cause) {
38+ if (alive && get(sessionUser) === account) error = cause instanceof Error ? cause.message : 'Could not load the saved history repair policy.';
39+ } finally {
40+ if (alive && get(sessionUser) === account) loading = false;
41+ }
42+ }
43+
44+ onMount(() => {
45+ void loadPolicy();
46+ return () => { alive = false; };
47+ });
48+
49+ async function save(): Promise<void> {
50+ error = '';
51+ notice = '';
52+ const ms = threshold;
53+ if (!loaded || busy || loading) return;
54+ if (selected === 'BridgeShortGaps' && (ms === undefined || !Number.isSafeInteger(ms) || ms < 0)) {
55+ error = 'Enter a whole number of milliseconds that is zero or greater.';
56+ return;
57+ }
58+ const account = get(sessionUser);
59+ if (!account) return;
60+ const policy: HistoryRepairPolicy = selected === 'BridgeShortGaps'
61+ ? { BridgeShortGaps: { threshold_ms: ms! } }
62+ : selected;
63+ busy = true;
64+ try {
65+ await setHistoryRepairPolicy(policy);
66+ if (alive && get(sessionUser) === account) notice = 'History repair policy saved.';
67+ } catch (cause) {
68+ if (alive && get(sessionUser) === account) error = cause instanceof Error ? cause.message : 'Could not save the history repair policy.';
69+ } finally {
70+ if (alive && get(sessionUser) === account) busy = false;
71+ }
72+ }
73+</script>
74+
75+<section aria-label="History repair settings">
76+ <h3 class="sec-h">History repair</h3>
77+ <label class="field">Saved policy
78+ <select bind:value={selected} disabled={loading || busy}>
79+ <option value="StartRecent">Start recent</option>
80+ <option value="Bridge">Bridge retained history</option>
81+ <option value="BridgeShortGaps">Bridge short gaps</option>
82+ </select>
83+ </label>
84+ {#if selected === 'BridgeShortGaps'}
85+ <label class="field">Gap threshold in milliseconds
86+ <input type="number" min="0" step="1" bind:value={threshold} disabled={loading || busy} />
87+ </label>
88+ <p class="note">The threshold cannot yet be evaluated, so this policy currently starts at recent messages. A threshold of zero disables automatic bridging. Choose Bridge retained history to bridge explicitly.</p>
89+ {/if}
90+ <button type="button" class="primary-btn" disabled={!loaded || loading || busy || (selected === 'BridgeShortGaps' && (threshold === undefined || !Number.isSafeInteger(threshold) || threshold < 0))} on:click={save}>{busy ? 'Saving…' : 'Save'}</button>
91+ {#if !loaded && !loading}<button type="button" class="ghost-btn" disabled={busy} on:click={() => void loadPolicy()}>Retry loading saved policy</button>{/if}
92+ {#if error}<p class="error" role="alert">{error}</p>{/if}
93+ {#if notice}<p class="notice" role="status">{notice}</p>{/if}
94+</section>
95+
96+<style>
97+ section { margin-top: 18px; }
98+ .sec-h { margin: 0 0 8px; color: var(--muted); font-size: 12px; letter-spacing: .08em; text-transform: uppercase; }
99+ .field { display: flex; flex-direction: column; gap: 6px; margin: 10px 0; font-size: 13px; }
100+ select, input { width: 100%; min-height: var(--ctl-h); padding: 6px 8px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--bg-soft); color: var(--text); font: inherit; }
101+ .note, .notice { color: var(--muted); font-size: 13px; }
102+ .error { color: var(--danger-text); background: var(--danger-bg); padding: 8px; border-radius: var(--r-sm); }
103+</style>
Modified file: web/src/lib/SettingsPanel.svelte+2 additions
@@ -13,6 +13,7 @@
1313 import { DEFAULT_QUICK_REACTIONS } from './quick-reactions';
1414 import ReactionPicker from './ReactionPicker.svelte';
1515 import ThemeSettings from './ThemeSettings.svelte';
16+ import HistoryRepairSettings from './HistoryRepairSettings.svelte';
1617 import type { ImagePack, QuickReaction } from './types';
1718
1819 export let quick: QuickReaction[];
@@ -35,6 +36,7 @@
3536 </div>
3637
3738 <ThemeSettings />
39+ <HistoryRepairSettings />
3840
3941 <section aria-label="Message spacing">
4042 <h3 class="sec-h">Space between messages</h3>
Modified file: web/src/lib/api.ts+12 additions
@@ -3,12 +3,16 @@
33 import type { EventContext, EventContextPage, MemberSummary, RoomSummary } from './types';
44 import type { Emote } from './emotes';
55 import type { MentionKey, MentionsBoundary, MentionsPage, SavedMentionContext } from './mentions-model';
6+import type { HistoryRepairBudget, HistoryRepairPolicy, HistoryRepairReport } from './repair-model';
7+export { parseHistoryRepairPolicy, parseHistoryRepairReport } from './repair-model';
8+export type { HistoryRepairBudget, HistoryRepairFailure, HistoryRepairPolicy, HistoryRepairReport } from './repair-model';
69 export { mentionKey, parseMentionsPage, parseSavedMentionContext, parseMentionsUpdate } from './mentions-model';
710 export type { MentionKey, MentionReason, MentionCaptureState, MentionEntry, MentionCoverage, MentionGap, MentionsBoundary, MentionsPage, SavedMentionContext, MentionsUpdate } from './mentions-model';
811 import { formatBody } from './markdown';
912 import { mentionedUserIds, type Mention } from './mentions';
1013 import { HttpTransport } from './transport-http';
1114 import type { CommandRequest, SendKind, SessionInfo, Transport, VerifyAction } from './transport';
15+export type { HistoryRepairRequest } from './transport';
1216 import { createDeviceTransport } from 'malice:device-transport';
1317
1418 export { ApiError } from './transport';
@@ -64,6 +68,14 @@ export const retryMentionContext = (room_id: string, event_id: string): Promise<
6468 export const setMentionsRead = (keys: MentionKey[], read: boolean) => transport.setMentionsRead(keys, read);
6569 export const markMentionsReadThrough = (boundary: MentionsBoundary) => transport.markMentionsReadThrough(boundary);
6670 export const recoverMentions = () => transport.recoverMentions();
71+export const historyRepair = (room_id: string, policy: HistoryRepairPolicy | null = null, budget?: HistoryRepairBudget): Promise<HistoryRepairReport> =>
72+ transport.historyRepair(room_id, policy, budget);
73+export const continueHistoryRepair = (room_id: string, budget?: HistoryRepairBudget): Promise<HistoryRepairReport> =>
74+ transport.continueHistoryRepair(room_id, budget);
75+export const cancelHistoryRepair = (room_id: string): Promise<void> => transport.cancelHistoryRepair(room_id);
76+export const startRecentHistory = (room_id: string): Promise<HistoryRepairReport> => transport.startRecentHistory(room_id);
77+export const historyRepairPolicy = (): Promise<HistoryRepairPolicy> => transport.historyRepairPolicy();
78+export const setHistoryRepairPolicy = (policy: HistoryRepairPolicy): Promise<void> => transport.setHistoryRepairPolicy(policy);
6779 export const setTyping = (room_id: string, typing: boolean) => transport.setTyping(room_id, typing);
6880 export const getAccountData = (eventType: string) => transport.getAccountData(eventType);
6981 export const getUrlPreview = (room_id: string, url: string) => transport.getUrlPreview(room_id, url);
Added file: web/src/lib/repair-model.ts+94 additions
@@ -0,0 +1,94 @@
1+export type HistoryRepairPolicy =
2+ | 'StartRecent'
3+ | 'Bridge'
4+ | { BridgeShortGaps: { threshold_ms: number } };
5+
6+export interface HistoryRepairBudget {
7+ max_pages: number;
8+ max_events: number;
9+ max_duration_ms: number;
10+}
11+
12+export interface HistoryRepairFailure {
13+ kind: string;
14+ message: string;
15+ server?: { status: number; errcode: string; message: string } | null;
16+ [key: string]: unknown;
17+}
18+
19+export interface HistoryRepairReport {
20+ room_id: string;
21+ requested_policy: HistoryRepairPolicy;
22+ strategy: 'Recent' | 'Overlap' | 'Bridge';
23+ coverage_duration_ms: number | null;
24+ threshold_evaluated: boolean;
25+ pages: number;
26+ events: number;
27+ phase: 'FetchingRecent' | 'Bridging' | 'Ready' | 'Paused';
28+ continuity: 'Unresolved' | 'Verified' | 'AccessibleStart';
29+ recent_available: boolean;
30+ stop: 'Cancelled' | 'Budget' | 'HistoryUnavailable' | 'NoAnchor' | 'CursorStalled' | null;
31+ failure: HistoryRepairFailure | null;
32+}
33+
34+export const DEFAULT_HISTORY_REPAIR_BUDGET: HistoryRepairBudget = {
35+ max_pages: 10,
36+ max_events: 1000,
37+ max_duration_ms: 15000
38+};
39+
40+function record(value: unknown): Record<string, unknown> | null {
41+ return typeof value === 'object' && value !== null && !Array.isArray(value)
42+ ? (value as Record<string, unknown>)
43+ : null;
44+}
45+
46+function safeNonnegativeInteger(value: unknown): value is number {
47+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
48+}
49+
50+export function parseHistoryRepairPolicy(value: unknown): HistoryRepairPolicy | null {
51+ if (value === 'StartRecent' || value === 'Bridge') return value;
52+ const outer = record(value);
53+ const details = outer && record(outer['BridgeShortGaps']);
54+ if (!outer || Object.keys(outer).length !== 1 || !details || Object.keys(details).length !== 1 || !safeNonnegativeInteger(details['threshold_ms'])) return null;
55+ return { BridgeShortGaps: { threshold_ms: details['threshold_ms'] } };
56+}
57+
58+function isOneOf<T extends string>(value: unknown, values: readonly T[]): value is T {
59+ return typeof value === 'string' && (values as readonly string[]).includes(value);
60+}
61+
62+export function parseHistoryRepairReport(value: unknown): HistoryRepairReport | null {
63+ const data = record(value);
64+ if (!data) return null;
65+ const policy = parseHistoryRepairPolicy(data['requested_policy']);
66+ const failure = data['failure'];
67+ const failureData = failure === null ? null : record(failure);
68+ if (
69+ typeof data['room_id'] !== 'string' || !policy ||
70+ !isOneOf(data['strategy'], ['Recent', 'Overlap', 'Bridge']) ||
71+ !(data['coverage_duration_ms'] === null || safeNonnegativeInteger(data['coverage_duration_ms'])) ||
72+ typeof data['threshold_evaluated'] !== 'boolean' ||
73+ !safeNonnegativeInteger(data['pages']) || !safeNonnegativeInteger(data['events']) ||
74+ !isOneOf(data['phase'], ['FetchingRecent', 'Bridging', 'Ready', 'Paused']) ||
75+ !isOneOf(data['continuity'], ['Unresolved', 'Verified', 'AccessibleStart']) ||
76+ typeof data['recent_available'] !== 'boolean' ||
77+ !(data['stop'] === null || isOneOf(data['stop'], ['Cancelled', 'Budget', 'HistoryUnavailable', 'NoAnchor', 'CursorStalled'])) ||
78+ !(failure === null || (failureData && typeof failureData['kind'] === 'string' && typeof failureData['message'] === 'string'))
79+ ) return null;
80+ return {
81+ room_id: data['room_id'],
82+ requested_policy: policy,
83+ strategy: data['strategy'],
84+ coverage_duration_ms: data['coverage_duration_ms'],
85+ threshold_evaluated: data['threshold_evaluated'],
86+ pages: data['pages'],
87+ events: data['events'],
88+ phase: data['phase'],
89+ continuity: data['continuity'],
90+ recent_available: data['recent_available'],
91+ stop: data['stop'],
92+ failure: failureData as HistoryRepairFailure | null
93+ };
94+}
Modified file: web/src/lib/store.ts+26 additions
@@ -1,6 +1,8 @@
11 import { get, writable } from 'svelte/store';
22 import { parseMentionsPage, parseMentionsUpdate } from './mentions-model';
33 import type { MentionsPage } from './mentions-model';
4+import { parseHistoryRepairReport } from './repair-model';
5+import type { HistoryRepairReport } from './repair-model';
46 import { getSession, subscribeEvents } from './api';
57 import type { SoftLogout } from './api';
68 import { isRecord } from './guards';
@@ -40,6 +42,13 @@ export const blocked = writable<boolean>(false);
4042 export const rooms = writable<RoomSummary[]>([]);
4143 export const timelines = writable<Record<string, TimelineItem[]>>({});
4244 export const pinnedTimelines = writable<Record<string, TimelineItem[]>>({});
45+export const pinnedTimelineVersions = writable<Record<string, number>>({});
46+export const historyRepairs = writable<Record<string, HistoryRepairReport>>({});
47+
48+export function publishHistoryRepair(report: HistoryRepairReport, account: string): void {
49+ if (account !== get(sessionUser)) return;
50+ historyRepairs.update((all) => ({ ...all, [report.room_id]: report }));
51+}
4352 export interface EventContextState {
4453 room_id: string;
4554 request_id: string;
@@ -76,6 +85,8 @@ export function resetClientState(): void {
7685 rooms.set([]);
7786 timelines.set({});
7887 pinnedTimelines.set({});
88+ pinnedTimelineVersions.set({});
89+ historyRepairs.set({});
7990 eventContexts.set({});
8091 connection.set('connecting');
8192 selectedRoomId.set(null);
@@ -125,6 +136,14 @@ function handlePayload(text: string): void {
125136 if (!isRecord(data)) return;
126137 const kind = data['type'];
127138 if (kind === 'snapshot') {
139+ const repairs: Record<string, HistoryRepairReport> = {};
140+ if (isRecord(data['repairs'])) {
141+ for (const [roomId, value] of Object.entries(data['repairs'])) {
142+ const report = parseHistoryRepairReport(value);
143+ if (report && report.room_id === roomId) repairs[roomId] = report;
144+ }
145+ }
146+ historyRepairs.set(repairs);
128147 const mentions = parseMentionsPage(data['mentions']);
129148 if (mentions) publishMentionsPage(mentions);
130149 setRooms(data['rooms']);
@@ -155,6 +174,12 @@ function handlePayload(text: string): void {
155174 if (update) publishMentionsPage(update.snapshot);
156175 return;
157176 }
177+ if (kind === 'history_repair') {
178+ const report = parseHistoryRepairReport(data['report']);
179+ const account = get(sessionUser);
180+ if (report && account) publishHistoryRepair(report, account);
181+ return;
182+ }
158183 if (kind === 'image_packs') {
159184 imagePacks.set(parseImagePacks(data['packs']));
160185 return;
@@ -179,6 +204,7 @@ function handlePayload(text: string): void {
179204 if (kind === 'pinned_closed') {
180205 const roomId = data['room_id'];
181206 if (typeof roomId === 'string') {
207+ pinnedTimelineVersions.update((all) => ({ ...all, [roomId]: (all[roomId] ?? 0) + 1 }));
182208 pinnedTimelines.update((all) => {
183209 const { [roomId]: _closed, ...rest } = all;
184210 return rest;
Modified file: web/src/lib/transport-http.ts+28 additions
@@ -2,6 +2,8 @@ import type { EventContext, EventContextPage, MemberSummary, RoomSummary } from
22 import { parseEventContext, parseEventContextPage } from './types';
33 import { parseMentionsPage, parseSavedMentionContext } from './mentions-model';
44 import type { MentionKey, MentionsBoundary, MentionsPage, SavedMentionContext } from './mentions-model';
5+import { DEFAULT_HISTORY_REPAIR_BUDGET, parseHistoryRepairPolicy, parseHistoryRepairReport } from './repair-model';
6+import type { HistoryRepairBudget, HistoryRepairPolicy, HistoryRepairReport } from './repair-model';
57 import { ApiError, type CommandRequest, type SendKind, type SessionInfo, type Transport, type UploadExtras, type VerifyAction } from './transport';
68
79 async function parseFailure(res: Response, fallback: string): Promise<never> {
@@ -140,6 +142,32 @@ export class HttpTransport implements Transport {
140142 await this.command({ op: 'mark_mentions_read_through', boundary });
141143 }
142144 async recoverMentions(): Promise<void> { await this.command({ op: 'recover_mentions' }); }
145+ async historyRepair(room_id: string, policy: HistoryRepairPolicy | null = null, budget: HistoryRepairBudget = DEFAULT_HISTORY_REPAIR_BUDGET): Promise<HistoryRepairReport> {
146+ const parsed = parseHistoryRepairReport(await this.command({ op: 'history_repair', room_id, policy, budget }));
147+ if (!parsed) throw new ApiError(502, 'Invalid history repair response');
148+ return parsed;
149+ }
150+ async continueHistoryRepair(room_id: string, budget: HistoryRepairBudget = DEFAULT_HISTORY_REPAIR_BUDGET): Promise<HistoryRepairReport> {
151+ const parsed = parseHistoryRepairReport(await this.command({ op: 'continue_history_repair', room_id, budget }));
152+ if (!parsed) throw new ApiError(502, 'Invalid history repair response');
153+ return parsed;
154+ }
155+ async cancelHistoryRepair(room_id: string): Promise<void> {
156+ await this.command({ op: 'cancel_history_repair', room_id });
157+ }
158+ async startRecentHistory(room_id: string): Promise<HistoryRepairReport> {
159+ const parsed = parseHistoryRepairReport(await this.command({ op: 'start_recent_history', room_id }));
160+ if (!parsed) throw new ApiError(502, 'Invalid history repair response');
161+ return parsed;
162+ }
163+ async historyRepairPolicy(): Promise<HistoryRepairPolicy> {
164+ const parsed = parseHistoryRepairPolicy(await this.command({ op: 'history_repair_policy' }));
165+ if (!parsed) throw new ApiError(502, 'Invalid history repair policy response');
166+ return parsed;
167+ }
168+ async setHistoryRepairPolicy(policy: HistoryRepairPolicy): Promise<void> {
169+ await this.command({ op: 'set_history_repair_policy', policy });
170+ }
143171 async eventContextBefore(room_id: string, event_id: string, from: string, limit = 20): Promise<EventContextPage> {
144172 return this.eventContextPage('before', room_id, event_id, from, limit);
145173 }
Modified file: web/src/lib/transport-worker.ts+28 additions
@@ -1,6 +1,8 @@
11 import { parseEventContext, parseEventContextPage } from './types';
22 import { parseMentionsPage, parseSavedMentionContext } from './mentions-model';
33 import type { MentionKey, MentionsBoundary, MentionsPage, SavedMentionContext } from './mentions-model';
4+import { DEFAULT_HISTORY_REPAIR_BUDGET, parseHistoryRepairPolicy, parseHistoryRepairReport } from './repair-model';
5+import type { HistoryRepairBudget, HistoryRepairPolicy, HistoryRepairReport } from './repair-model';
46 import type { EventContext, EventContextPage, MemberSummary, RoomSummary } from './types';
57 import { ApiError, type CommandRequest, type SendKind, type SessionInfo, type SoftLogout, type Transport, type UploadExtras, type VerifyAction } from './transport';
68 import { statusForKind } from './wire-status';
@@ -247,6 +249,32 @@ export class WorkerTransport implements Transport {
247249 async setMentionsRead(keys: MentionKey[], read: boolean): Promise<void> { await this.client.call('set_mentions_read', { keys, read }); }
248250 async markMentionsReadThrough(boundary: MentionsBoundary): Promise<void> { await this.client.call('mark_mentions_read_through', { boundary }); }
249251 async recoverMentions(): Promise<void> { await this.client.call('recover_mentions'); }
252+ async historyRepair(room_id: string, policy: HistoryRepairPolicy | null = null, budget: HistoryRepairBudget = DEFAULT_HISTORY_REPAIR_BUDGET): Promise<HistoryRepairReport> {
253+ const parsed = parseHistoryRepairReport(await this.client.call('history_repair', { room_id, policy, budget }));
254+ if (!parsed) throw new ApiError(502, 'Invalid history repair response');
255+ return parsed;
256+ }
257+ async continueHistoryRepair(room_id: string, budget: HistoryRepairBudget = DEFAULT_HISTORY_REPAIR_BUDGET): Promise<HistoryRepairReport> {
258+ const parsed = parseHistoryRepairReport(await this.client.call('continue_history_repair', { room_id, budget }));
259+ if (!parsed) throw new ApiError(502, 'Invalid history repair response');
260+ return parsed;
261+ }
262+ async cancelHistoryRepair(room_id: string): Promise<void> {
263+ await this.client.call('cancel_history_repair', { room_id });
264+ }
265+ async startRecentHistory(room_id: string): Promise<HistoryRepairReport> {
266+ const parsed = parseHistoryRepairReport(await this.client.call('start_recent_history', { room_id }));
267+ if (!parsed) throw new ApiError(502, 'Invalid history repair response');
268+ return parsed;
269+ }
270+ async historyRepairPolicy(): Promise<HistoryRepairPolicy> {
271+ const parsed = parseHistoryRepairPolicy(await this.client.call('history_repair_policy'));
272+ if (!parsed) throw new ApiError(502, 'Invalid history repair policy response');
273+ return parsed;
274+ }
275+ async setHistoryRepairPolicy(policy: HistoryRepairPolicy): Promise<void> {
276+ await this.client.call('set_history_repair_policy', { policy });
277+ }
250278 eventContext(room_id: string, event_id: string, limit = 20): Promise<EventContext> {
251279 return this.contextCall('event_context', { room_id, event_id, limit });
252280 }
Modified file: web/src/lib/transport.ts+16 additions
@@ -1,5 +1,6 @@
11 import type { EventContext, EventContextPage, MemberSummary, PackUsage, RoomSummary } from './types';
22 import type { MentionKey, MentionsBoundary, MentionsPage, SavedMentionContext } from './mentions-model';
3+import type { HistoryRepairBudget, HistoryRepairPolicy, HistoryRepairReport } from './repair-model';
34
45 export class ApiError extends Error {
56 status: number;
@@ -64,8 +65,17 @@ export type CommandRequest =
6465 | { op: 'clone_pack_image'; from_pack: string; shortcode: string; to: PackTarget }
6566 | { op: 'clone_sticker'; room_id: string; event_id: string; to: PackTarget; shortcode: string }
6667 | { op: 'clone_pack'; from_pack: string; to: PackTarget; display_name: string | null }
68+ | HistoryRepairRequest
6769 | MentionsRequest;
6870
71+export type HistoryRepairRequest =
72+ | { op: 'history_repair'; room_id: string; policy: HistoryRepairPolicy | null; budget: HistoryRepairBudget }
73+ | { op: 'continue_history_repair'; room_id: string; budget: HistoryRepairBudget }
74+ | { op: 'cancel_history_repair'; room_id: string }
75+ | { op: 'start_recent_history'; room_id: string }
76+ | { op: 'history_repair_policy' }
77+ | { op: 'set_history_repair_policy'; policy: HistoryRepairPolicy };
78+
6979 export type MentionsRequest =
7080 | { op: 'mentions_page'; cursor: string | null; limit: number }
7181 | { op: 'mention_context' | 'retry_mention_context'; room_id: string; event_id: string }
@@ -116,6 +126,12 @@ export interface Transport {
116126 setMentionsRead(keys: MentionKey[], read: boolean): Promise<void>;
117127 markMentionsReadThrough(boundary: MentionsBoundary): Promise<void>;
118128 recoverMentions(): Promise<void>;
129+ historyRepair(room_id: string, policy?: HistoryRepairPolicy | null, budget?: HistoryRepairBudget): Promise<HistoryRepairReport>;
130+ continueHistoryRepair(room_id: string, budget?: HistoryRepairBudget): Promise<HistoryRepairReport>;
131+ cancelHistoryRepair(room_id: string): Promise<void>;
132+ startRecentHistory(room_id: string): Promise<HistoryRepairReport>;
133+ historyRepairPolicy(): Promise<HistoryRepairPolicy>;
134+ setHistoryRepairPolicy(policy: HistoryRepairPolicy): Promise<void>;
119135 setTyping(room_id: string, typing: boolean): Promise<void>;
120136 getAccountData(eventType: string): Promise<unknown | null>;
121137 getUrlPreview(room_id: string, url: string): Promise<unknown | null>;