Skip to content

Commit 41a0b68

Browse files
Programcodeclaude
andcommitted
Stop cancelled Ask requests and keep a half-saved chat
- Ask listens for cancellation before it reads the session, so leaving Ask during a token refresh no longer sends the question. - When a new chat is created but its messages fail to save, Ask keeps the chat, so the next save adds to it instead of creating a second chat. - A failed link import shows only translated text and logs the raw error. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 685f4fb commit 41a0b68

5 files changed

Lines changed: 52 additions & 8 deletions

File tree

‎apps/mobile/src/app/add-link.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ export default function AddLinkSheet() {
4545
setDraft(imported);
4646
setTitle(imported.title);
4747
} catch (cause) {
48-
setError(`${t('libraryPage.importFailed')}: ${cause instanceof Error ? cause.message : t('libraryPage.importFailedDesc')}`);
48+
console.warn('Could not import the source:', cause instanceof Error ? cause.message : cause);
49+
setError(`${t('libraryPage.importFailed')}. ${t('libraryPage.importFailedDesc')}`);
4950
} finally {
5051
setBusy(null);
5152
}

‎apps/mobile/src/app/ask.tsx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import {
2626
import { fetchAskConfig, type AskConfig } from '@/lib/ask-config';
2727
import { buildAskDebateContext } from '@/lib/ask-context';
2828
import { useAuth } from '@/lib/auth';
29-
import { fetchChat, saveChat, type ChatMessage, type ChatRecord } from '@/lib/chats';
29+
import { ChatSaveError, fetchChat, saveChat, type ChatMessage, type ChatRecord } from '@/lib/chats';
3030
import { useDebates } from '@/lib/debate-context';
3131
import { useSettings } from '@/lib/settings';
3232
import { fetchSources, type Source } from '@/lib/sources';
@@ -161,7 +161,9 @@ export default function AskScreen() {
161161
if (next && owner === conversation.current) setRecord(next);
162162
} catch (cause) {
163163
console.warn('Could not save the chat:', cause instanceof Error ? cause.message : cause);
164-
if (owner === conversation.current) setNotice(t('debate.recordFailed'));
164+
if (owner !== conversation.current) return;
165+
if (cause instanceof ChatSaveError) setRecord(cause.record);
166+
setNotice(t('debate.recordFailed'));
165167
}
166168
});
167169
// eslint-disable-next-line react-hooks/exhaustive-deps

‎apps/mobile/src/lib/ask.test.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
3+
const session = vi.hoisted(() => ({ resolve: (_token: string) => {} }));
4+
const fetchMock = vi.hoisted(() => vi.fn());
5+
6+
vi.mock('./supabase', () => ({
7+
supabase: { auth: { getSession: () => new Promise((resolve) => (session.resolve = (token) => resolve({ data: { session: { access_token: token } } }))) } },
8+
}));
9+
vi.mock('expo/fetch', () => ({ fetch: fetchMock }));
10+
11+
import { askOnce } from './ask';
12+
13+
describe('askOnce', () => {
14+
it('never sends the request when Ask is left while the session is still being read', async () => {
15+
const controller = new AbortController();
16+
const request = askOnce({
17+
message: 'Why ban homework?',
18+
history: [],
19+
options: { citationMode: false, webSearch: false, prosConsMode: false, argumentMapMode: false },
20+
config: { model: 'test-model', temperature: 0.3, reasoningEffort: 'low' },
21+
language: 'en',
22+
}, controller.signal);
23+
controller.abort();
24+
session.resolve('token');
25+
await expect(request).rejects.toMatchObject({ name: 'AbortError' });
26+
expect(fetchMock).not.toHaveBeenCalled();
27+
});
28+
});

‎apps/mobile/src/lib/ask.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -173,15 +173,19 @@ function parseOutput(output: unknown, resolvedModel?: string): AskOutput {
173173

174174
/** One POST with the website's headers, a 200 s timeout and the caller's abort. */
175175
async function post(path: string, body: Record<string, unknown>, signal: AbortSignal | undefined, run: (response: Awaited<ReturnType<typeof fetch>>) => Promise<AskOutput>): Promise<AskOutput> {
176-
if (signal?.aborted) throw abortError();
177-
const token = await accessToken();
178-
const json = JSON.stringify(body);
179-
if (utf8Length(json) > MAX_REQUEST_BODY_BYTES) throw new AskError('tooLarge', 'Request is too large');
176+
// The deadline and the caller's abort start before the session read, so
177+
// leaving Ask while the token refreshes never sends the request.
180178
const controller = new AbortController();
181179
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
182180
const abortForCaller = () => controller.abort();
183181
signal?.addEventListener('abort', abortForCaller);
182+
if (signal?.aborted) controller.abort();
184183
try {
184+
if (controller.signal.aborted) throw abortError();
185+
const token = await accessToken();
186+
if (controller.signal.aborted) throw abortError();
187+
const json = JSON.stringify(body);
188+
if (utf8Length(json) > MAX_REQUEST_BODY_BYTES) throw new AskError('tooLarge', 'Request is too large');
185189
const response = await fetch(`${supabaseProject.url}/functions/v1/${path}`, {
186190
method: 'POST',
187191
headers: { Authorization: `Bearer ${token}`, apikey: supabaseProject.anonKey, 'Content-Type': 'application/json' },

‎apps/mobile/src/lib/chats.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,14 @@ export interface ChatRecord {
3131
persisted: number;
3232
}
3333

34+
/** A failed message write, with the record to keep: the chat may have been created before it failed. */
35+
export class ChatSaveError extends Error {
36+
constructor(message: string, readonly record: ChatRecord) {
37+
super(message);
38+
this.name = 'ChatSaveError';
39+
}
40+
}
41+
3442
interface MessageRow {
3543
id: string;
3644
role: 'user' | 'assistant';
@@ -109,6 +117,7 @@ export async function saveChat(userId: string, record: ChatRecord, messages: Cha
109117
output: message.output || null,
110118
created_at: new Date(message.timestamp).toISOString(),
111119
})));
112-
if (error) throw new Error(error.message);
120+
// The chat exists now; keep it so the next save adds these messages to it instead of creating another.
121+
if (error) throw new ChatSaveError(error.message, { chatId, debateId, persisted: record.persisted });
113122
return { chatId, debateId, persisted: messages.length };
114123
}

0 commit comments

Comments
 (0)