Skip to content

Commit deb28ba

Browse files
Programcodeclaude
andcommitted
Clear all drops queued notes; recovered notes keep the Library offline; older R2 cases regenerate
From the Codex review of 0c22ab7: - Clear all left notes and changes still queued, so they came back and were inserted after the clear. It now lets a running replay finish and drops the debate's queued writes before clearing (tested). - When duplicate recovery also saved folded edits, updateSource dropped the saved Library list and nothing read it back unless the Library was open, so it could not load offline. Recovery now reads the list back from the server (tested). - Regenerating Arguments on an older R2 or Conclusor case took only the new run's empty contentions and dropped its reinforce or whyWeWin, so the section went empty. The shared merge keeps them (tested in core; the website uses the same merge). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 97d33b4 commit deb28ba

5 files changed

Lines changed: 85 additions & 2 deletions

File tree

‎apps/mobile/src/app/(tabs)/(library)/library.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import { confirmDestructive, promptText } from '@/lib/confirm';
2626
import { useDebates } from '@/lib/debate-context';
2727
import { haptics } from '@/lib/haptics';
2828
import { LIBRARY_ROOMY_WIDTH, LIST_WIDTH, useContentWidth, useLayoutClass } from '@/lib/layout';
29-
import { currentSourceId, waitingSourceIds, withQueuedWrites } from '@/lib/offline-queue';
29+
import { currentSourceId, discardQueuedForDebate, waitingSourceIds, withQueuedWrites } from '@/lib/offline-queue';
3030
import { openRowId } from '@/lib/open-pane';
3131
import { pickSourceFile } from '@/lib/pick-source-file';
3232
import { useSourceActions } from '@/lib/source-actions';
@@ -103,6 +103,8 @@ function LibraryView({ debateId, layout }: { debateId: string | null; layout: Re
103103
const confirmed = await confirmDestructive({ title: t('libraryPage.clearAll'), message: t('libraryPage.clearConfirm'), confirm: t('libraryPage.clearAll'), cancel: t('common.cancel') });
104104
if (!confirmed) return;
105105
try {
106+
// Notes and changes still waiting to sync would come back after the clear; they go first.
107+
await discardQueuedForDebate(userId, debateId);
106108
await clearLibrary(userId, debateId);
107109
setSelected(null);
108110
library.setSources([]);

‎apps/mobile/src/lib/offline-queue.test.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,24 @@ describe('offline queue', () => {
152152
expect(queue.getQueueSnapshot().items).toEqual([]);
153153
});
154154

155+
it('clears every write queued for a debate before Clear all, so none comes back', async () => {
156+
const queue = await load();
157+
copies.set('sources:d1', [row('s1', { title: 'Kept', content: 'Text', debateId: 'd1' })]);
158+
net.offline = true;
159+
const note = queue.queueAddSource('user-a', { title: 'Waiting note', content: 'Text', type: 'text', debateId: 'd1' });
160+
await note;
161+
await queue.queueUpdateSource('user-a', 'd2', 's9', { title: 'Other debate' });
162+
await queue.discardQueuedForDebate('user-a', 'd1');
163+
await cache.copiesWritten();
164+
expect(queue.getQueueSnapshot().items.map((item) => item.kind === 'add' ? item.input.debateId : item.debateId)).toEqual(['d2']);
165+
expect((copies.get('sources:d1') as Source[]).map((item) => item.id)).toEqual(['s1']);
166+
167+
net.offline = false;
168+
await queue.replayOfflineQueue();
169+
expect(sources.addSource).not.toHaveBeenCalled();
170+
expect(sent()).toEqual(['edit s9 {"title":"Other debate"}']);
171+
});
172+
155173
it('puts back what the copies showed before a refused change, even when the server cannot be read again', async () => {
156174
const queue = await load();
157175
copies.set('sources:d1', [row('s1', { title: 'Old', content: 'Text', debateId: 'd1' })]);
@@ -244,6 +262,8 @@ describe('offline queue', () => {
244262
await cache.copiesWritten();
245263
expect((copies.get('sources:d1') as Source[]).map((item) => item.id)).toEqual(['uuid-1']);
246264
expect((copies.get('source:uuid-1') as Source).title).toBe('Final');
265+
// updateSource drops the saved list, so the list is read back from the server.
266+
expect(sources.fetchSources).toHaveBeenCalledWith('user-a', 'd1');
247267
});
248268

249269
it('sends a change made while its note is on the way with the row id', async () => {

‎apps/mobile/src/lib/offline-queue.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,27 @@ export async function queueSetSourcesActive(userId: string, debateId: string, so
486486
});
487487
}
488488

489+
/**
490+
* Clear all for a debate: lets a replay that is running finish, then drops every write still
491+
* queued for that debate (its waiting notes, edits and switches), so none comes back after the
492+
* server clears the Library. Call it before clearLibrary.
493+
*/
494+
export async function discardQueuedForDebate(userId: string, debateId: string): Promise<void> {
495+
await adopt(userId);
496+
if (running) await running.catch(() => undefined);
497+
if (owner !== userId) return;
498+
const debateOf = (item: QueuedWrite) => (item.kind === 'add' ? item.input.debateId : item.debateId);
499+
const dropped = items.filter((item) => debateOf(item) === debateId);
500+
if (dropped.length === 0) return;
501+
items = items.filter((item) => debateOf(item) !== debateId);
502+
const forgotten = new Set(dropped.flatMap((item) => touched(item).map((one) => one.id)));
503+
before = Object.fromEntries(Object.entries(before).filter(([id]) => !forgotten.has(id)));
504+
updateCopy<Source[]>(userId, 'sources', debateId, (list) => list?.filter((source) => !isLocalSourceId(source.id)));
505+
dropped.forEach((item) => takeWaiters(item.key).forEach((waiter) => waiter.reject(new Error('The Library was cleared before this change was saved.'))));
506+
persist();
507+
emit();
508+
}
509+
489510
/**
490511
* Removes a note that has not reached the server, as deleting it does. False
491512
* while the note is on its way: the caller asks again once it has synced.
@@ -576,8 +597,11 @@ async function send(userId: string, item: QueuedWrite): Promise<Source | undefin
576597
}
577598
// This path skips addSource's refresh of the saved copies, which still hold the note under its
578599
// temporary id: they take the row, once, so the note opens and edits offline after a restart.
600+
// A patch above drops the saved list (updateSource), so the list is also read back from the
601+
// server, which just answered; if that read fails, the swap keeps whatever list was saved.
579602
updateCopy<Source[]>(userId, 'sources', input.debateId, (list) => list && [saved, ...list.filter((source) => source.id !== item.tempId && source.id !== id)]);
580603
updateCopy<Source | null>(userId, 'source', id, () => saved);
604+
await fetchSources(userId, input.debateId).catch(() => undefined);
581605
notifySourcesChanged();
582606
return saved;
583607
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { mergeRegeneratedSection } from './generate-output';
4+
import type { GenerateOutput } from './schemas/generate';
5+
6+
const metadata = { side: 'pro' as const, format: 'custom', generatedAt: '2026-10-28T10:00:00.000Z' };
7+
8+
describe('mergeRegeneratedSection', () => {
9+
it('keeps an R2 case body when regenerating an older case whose body was in its arguments', () => {
10+
// A pre-v2 R2 case: its body was three ordinary arguments.
11+
const older: GenerateOutput = {
12+
caseStructure: { thesis: 'Back up our case' },
13+
contentions: [{ id: '1', claim: 'Air', reasoning: 'r', evidence: 'e', needsSource: false, impact: 'i' }],
14+
counters: [],
15+
metadata,
16+
};
17+
// A new R2 run answers with the backed-up arguments and no new ones.
18+
const fresh: GenerateOutput = {
19+
caseStructure: { thesis: 'Back up our case' },
20+
contentions: [],
21+
reinforce: [{ argument: 'Our first argument: clean air', text: 'Roadside NO2 fell within a year.' }],
22+
counters: [],
23+
metadata,
24+
};
25+
const merged = mergeRegeneratedSection(older, fresh, 'contentions');
26+
expect(merged.contentions).toEqual([]);
27+
expect(merged.reinforce).toEqual(fresh.reinforce);
28+
});
29+
});

‎packages/core/src/ai/generate-output.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,15 @@ export function mergeRegeneratedSection(base: GenerateOutput, next: GenerateOutp
134134
...base,
135135
// A new opening replaces an older case's exordio and its framework, burden and definitions.
136136
...(section === 'opening' ? { opening: next.opening, exordio: next.exordio, caseStructure: next.caseStructure } : {}),
137-
...(section === 'contentions' ? { contentions: next.contentions } : {}),
137+
// An older R2 or Conclusor case kept its body in contentions; a new run answers with reinforce or
138+
// whyWeWin and no arguments, so those come across too rather than the section going empty.
139+
...(section === 'contentions'
140+
? {
141+
contentions: next.contentions,
142+
...(next.contentions.length === 0 && next.reinforce?.length ? { reinforce: next.reinforce } : {}),
143+
...(next.contentions.length === 0 && next.whyWeWin?.length ? { whyWeWin: next.whyWeWin } : {}),
144+
}
145+
: {}),
138146
...(section === 'counters' ? { counters: next.counters, predictedOpponentCase: next.predictedOpponentCase } : {}),
139147
...(section === 'reinforce' ? { reinforce: next.reinforce } : {}),
140148
...(section === 'whyWeWin' ? { whyWeWin: next.whyWeWin } : {}),

0 commit comments

Comments
 (0)