From 5fec8ea7edeb9e440624198a91de8bb17a07a04a Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 28 Oct 2026 11:47:51 +0000 Subject: [PATCH 01/14] Share the live stream reader, run recovery and draft sections through core The website's live generation reader, its ai_run_results recovery and the live draft sections move to packages/core so the iOS app streams tools the same way. The website keeps its modules as thin adapters with the same exports, so its tests run unchanged. Co-Authored-By: Claude Opus 5.5 --- packages/core/src/ai/live-sections.ts | 229 +++++++++++++++ packages/core/src/ai/live.ts | 261 ++++++++++++++++++ packages/core/src/ai/run-recovery.ts | 97 +++++++ packages/core/src/globals.d.ts | 7 + .../features/tools/live-sections.ts | 222 +-------------- src/lib/ai/live.ts | 251 ++--------------- src/lib/ai/run-recovery.ts | 83 +----- 7 files changed, 628 insertions(+), 522 deletions(-) create mode 100644 packages/core/src/ai/live-sections.ts create mode 100644 packages/core/src/ai/live.ts create mode 100644 packages/core/src/ai/run-recovery.ts diff --git a/packages/core/src/ai/live-sections.ts b/packages/core/src/ai/live-sections.ts new file mode 100644 index 000000000..fcd1f9d32 --- /dev/null +++ b/packages/core/src/ai/live-sections.ts @@ -0,0 +1,229 @@ +// Maps each tool's partially written answer to the sections a live draft +// shows. The labels are the ones the finished results use, so the draft +// reads like the result filling in. Anything not written yet is left out. +// Shared by the website's live drafts and the iOS app's. + +/** The translator the sections need; i18next's `t` fits it. */ +type Translate = (key: string) => string; + +/** The key's text, or `fallback` when the catalog lacks it (i18next answers a missing key with the key). */ +function label(t: Translate, key: string, fallback: string): string { + const value = t(key); + return value && value !== key ? value : fallback; +} + +export interface DraftBlock { + /** A bold first line, such as a contention's claim. */ + heading?: string; + label?: string; + text?: string; + items?: string[]; +} + +export interface DraftSection { + id: string; + title: string; + blocks: DraftBlock[]; +} + +type Draft = Record; + +function record(value: unknown): Draft { + return value && typeof value === 'object' && !Array.isArray(value) ? value as Draft : {}; +} + +function list(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +function text(value: unknown): string | undefined { + if (typeof value === 'number') return String(value); + return typeof value === 'string' && value.trim() ? value : undefined; +} + +function strings(value: unknown): string[] { + return list(value).flatMap((item): string[] => { const entry = text(item); return entry ? [entry] : []; }); +} + +function block(label: string | undefined, value: unknown): DraftBlock[] { + const content = text(value); + return content ? [{ label, text: content }] : []; +} + +function itemsBlock(label: string | undefined, value: unknown): DraftBlock[] { + const items = strings(value); + return items.length ? [{ label, items }] : []; +} + +function heading(value: unknown): DraftBlock[] { + const content = text(value); + return content ? [{ heading: content }] : []; +} + +function section(id: string, title: string, blocks: DraftBlock[]): DraftSection[] { + return blocks.length ? [{ id, title, blocks }] : []; +} + +export function generateDraftSections(draft: Draft, t: Translate): DraftSection[] { + const exordio = record(draft.exordio); + const structure = record(draft.caseStructure); + const opponent = record(draft.predictedOpponentCase); + return [ + ...section('exordio', t('generateResults.exordio'), [ + ...block(t('generateResults.hook'), exordio.hook), + ...block(t('generateResults.transition'), exordio.transition), + ]), + ...section('structure', t('generateResults.caseStructure'), [ + ...block(t('generateResults.thesis'), structure.thesis), + ...block(t('generateResults.framework'), structure.framework), + ...itemsBlock(t('generateResults.keyDefinitions'), structure.keyDefinitions), + ...block(t('generateResults.burdenOfProof'), structure.burdenOfProof), + ]), + ...section('contentions', t('generateResults.arguments'), list(draft.contentions).flatMap((value) => { + const contention = record(value); + return [ + ...heading(contention.claim), + ...block(t('generateResults.reasoning'), contention.reasoning), + ...block(t('generateResults.evidence'), contention.evidence), + ...block(t('generateResults.impact'), contention.impact), + ]; + })), + ...section('opponent', t('generateResults.predictedOpponent'), [ + ...itemsBlock(t('generateResults.likelyArguments'), opponent.likelyArguments), + ...itemsBlock(t('generateResults.strategicWeaknesses'), opponent.strategicWeaknesses), + ...itemsBlock(t('generateResults.keyClashes'), opponent.keyClashes), + ]), + ...section('counters', t('generateResults.preparedCounters'), list(draft.counters).flatMap((value) => { + const counter = record(value); + return [ + ...heading(counter.targetArgument), + ...block(t('generateResults.counter'), counter.counterpoint), + ...block(t('generateResults.rebuttal'), counter.rebuttal), + ]; + })), + ]; +} + +export function attackDraftSections(draft: Draft, t: Translate): DraftSection[] { + const overall = record(draft.overallAssessment); + const weighing = record(draft.weighingAnalysis); + const score = text(overall.score); + return [ + ...section('overall', t('attackResults.overall'), [ + ...block(t('attackResults.caseStrength'), score ? `${score}/100` : undefined), + ...block(undefined, overall.summary), + ...itemsBlock(t('attackResults.strengths'), overall.strengthAreas), + ]), + ...section('weaknesses', t('attackResults.weaknesses'), list(draft.weaknesses).flatMap((value) => { + const weakness = record(value); + return [ + ...heading(weakness.description), + ...block(t('attackResults.whyWeak'), weakness.whyWeak), + ...block(t('attackResults.opponentAttack'), weakness.howOpponentAttacks), + ...block(t('attackResults.improvedVersion'), weakness.improvedVersion), + ]; + })), + ...section('fallacies', t('attackResults.fallacies'), list(draft.fallaciesDetected).flatMap((value) => { + const fallacy = record(value); + return [...heading(fallacy.name), ...block(undefined, fallacy.explanation)]; + })), + ...section('crossEx', t('attackResults.crossEx'), list(draft.crossExQuestions).flatMap((value) => { + const question = record(value); + return [ + ...heading(question.question), + ...block(t('attackResults.purpose'), question.purpose), + ...block(t('attackResults.expectedTrap'), question.expectedTrap), + ]; + })), + ...section('weighing', t('attackResults.weighing'), [ + ...block(t('attackResults.currentWeighing'), weighing.currentWeighing), + ...itemsBlock(t('attackResults.missingComparisons'), weighing.missingComparisons), + ...itemsBlock(t('attackResults.improvements'), weighing.improvementSuggestions), + ]), + ]; +} + +function ideaBlocks(value: unknown): DraftBlock[] { + const idea = record(value); + return [...heading(idea.title), ...block(undefined, idea.description)]; +} + +export function brainstormDraftSections(draft: Draft, t: Translate): DraftSection[] { + return [ + ...section('clarification', t('brainstormResults.clarification'), itemsBlock(undefined, draft.clarifyingQuestions)), + ...list(draft.mindmap).flatMap((value, index) => { + const branch = record(value); + return section(`mindmap-${index}`, text(branch.category) ?? t('brainstormResults.argMap'), list(branch.items).flatMap(ideaBlocks)); + }), + ...list(draft.buckets).flatMap((value, index) => { + const bucket = record(value); + return section(`bucket-${index}`, text(bucket.name) ?? t('brainstormResults.argMap'), [ + ...block(undefined, bucket.keyInsight), + ...list(bucket.ideas).flatMap(ideaBlocks), + ]); + }), + ...section('topFive', t('brainstormResults.argMap'), list(draft.topFive).flatMap((value) => { + const entry = record(value); + return [...ideaBlocks(entry.idea), ...block(undefined, entry.whyTop)]; + })), + ...section('nextSteps', t('brainstormResults.nextSteps'), itemsBlock(undefined, draft.nextSteps)), + ...section('warnings', t('brainstormResults.warnings'), itemsBlock(undefined, draft.warningsAndTradeoffs)), + ]; +} + +export function factoidsDraftSections(draft: Draft, t: Translate): DraftSection[] { + return [ + ...section('factoids', t('factoidsResults.factoids'), list(draft.factoids).flatMap((value) => { + const factoid = record(value); + return [ + ...heading(factoid.shortClaim), + ...block(t('factoidsResults.keyFigure'), factoid.keyFigure), + ...block(t('factoidsResults.context'), factoid.context), + ...block(t('factoidsResults.limitations'), factoid.limitations), + ...block(t('factoidsResults.howToUse'), factoid.howToUse), + ]; + })), + ...section('findMore', t('factoidsResults.findMore'), itemsBlock(undefined, draft.searchSuggestions)), + ...section('notes', t('factoidsResults.importantNotes'), itemsBlock(undefined, draft.warnings)), + ]; +} + +function claims(value: unknown): string[] { + return list(value).flatMap((item): string[] => { const claim = text(record(item).claim); return claim ? [claim] : []; }); +} + +export function crossfireDebriefDraftSections(draft: Draft, t: Translate): DraftSection[] { + const categories = [ + 'concessionsSecured', + 'controlledQuestions', + 'weakOrRepetitiveQuestions', + 'evasiveOrIncompleteAnswers', + 'missedFollowUps', + ] as const; + return [ + ...categories.flatMap((key) => section(key, t(`crossfire.${key}`), itemsBlock(undefined, claims(draft[key])))), + ...section('betterFollowUpQuestions', t('crossfire.betterFollowUpQuestions'), list(draft.betterFollowUpQuestions).flatMap((value) => { + const item = record(value); + return [...heading(item.question), ...block(undefined, item.reason)]; + })), + ...section('strongestExchange', t('crossfire.strongestExchange'), block(undefined, record(draft.strongestExchange).claim)), + ]; +} + +export function sparringScoreDraftSections(draft: Draft, t: Translate): DraftSection[] { + const scorecard = record(draft.scorecard); + return [ + ...section('overall', label(t, 'sparring.scorecard.overallFeedback', 'Overall feedback'), block(undefined, scorecard.overallFeedback)), + ...section('categories', label(t, 'sparring.scorecard.categoryNotes', 'Category notes'), list(scorecard.categories).flatMap((value) => { + const category = record(value); + const name = text(category.name); + return [ + ...heading(name ? label(t, `sparring.category.${name}`, name) : undefined), + ...block(undefined, category.feedback), + ]; + })), + ...section('keyMoments', label(t, 'sparring.scorecard.keyMoments', 'Key moments'), itemsBlock(undefined, scorecard.keyMoments ?? draft.keyMoments)), + ...section('workOnNext', label(t, 'sparring.scorecard.workOnNext', 'Work on next'), itemsBlock(undefined, scorecard.improvementAreas ?? draft.improvementAreas)), + ...section('verdict', label(t, 'sparring.scorecard.verdict', "Judge's verdict"), block(undefined, scorecard.verdict ?? draft.verdict)), + ]; +} diff --git a/packages/core/src/ai/live.ts b/packages/core/src/ai/live.ts new file mode 100644 index 000000000..26dec2980 --- /dev/null +++ b/packages/core/src/ai/live.ts @@ -0,0 +1,261 @@ +// Live generation: read the `ai` function's event stream (see +// supabase/functions/ai/stream.ts) and report the model's text as it is +// written. Shared by the website (src/lib/ai/live.ts) and the iOS app, which +// map the outcome to their own errors. + +import { parsePartialJson } from './partial-json'; +import { consumeSSEChunk, type ParsedSSEEvent } from './sse'; + +export type LivePhase = 'research' | 'writing' | 'checking'; + +export interface LiveGeneration { + /** The answer's JSON as far as the model has written it. Throttled. */ + onDraft: (draft: Record) => void; + /** A step that writes nothing yet started, such as web research. */ + onPhase?: (phase: LivePhase) => void; +} + +const DRAFT_INTERVAL_MS = 50; + +/** + * After `result`, keep reading only this long. The function closes the body + * a moment later; waiting for that close avoids cancelling the fetch, and + * the cap stops a body that never closes from holding the reader open. + */ +export const LIVE_STREAM_DRAIN_MS = 2_000; + +/** + * The function sends a keepalive every 15 s. Three missed in a row means the + * stream is gone even if the connection still looks open, as when a proxy + * holds the tail of a response. + */ +export const LIVE_STREAM_STALL_MS = 45_000; + +/** + * A stalled stream is left open, not cancelled: closing it would make the + * function cancel the run whose stored result the caller is about to read. + * It is cancelled after this long, past any run's wall clock, or when the + * caller aborts. + */ +export const LIVE_STREAM_STALLED_OPEN_MS = 160_000; + +/** + * Why a live stream ended without its answer: the body ended, the + * connection dropped, nothing arrived for LIVE_STREAM_STALL_MS, or the + * `result` event arrived but could not be read. The caller can still recover + * the stored result (run-recovery.ts) before it reports the failure. + */ +export type LiveStreamFailure = 'ended' | 'dropped' | 'stalled' | 'unreadable'; + +export class LiveStreamError extends Error { + readonly reason: LiveStreamFailure; + /** The read error behind a dropped stream. */ + readonly cause?: unknown; + + constructor(reason: LiveStreamFailure, cause?: unknown) { + super(reason === 'unreadable' ? 'The answer arrived but could not be read.' : 'The response ended before the answer was complete.'); + this.name = 'LiveStreamError'; + this.reason = reason; + this.cause = cause; + } +} + +/** The part of a fetch body reader this module uses, in browsers and in Expo's fetch. */ +export interface LiveStreamReader { + read(): Promise<{ done: boolean; value?: Uint8Array }>; + cancel(reason?: unknown): Promise; +} + +/** The part of an AbortSignal this module uses. */ +interface LiveAbortSignal { + readonly aborted: boolean; + readonly reason?: unknown; + addEventListener(type: 'abort', listener: () => void, options?: { once?: boolean }): void; + removeEventListener(type: 'abort', listener: () => void): void; +} + +/** + * How a live stream ended: the `result` event's status and body, or why no + * result arrived. A 2xx result is the answer; any other status is the error + * a plain request would have answered with. + */ +export type LiveOutcome = + | { kind: 'result'; status: number; body: unknown } + | { kind: 'failed'; error: LiveStreamError }; + +function abortError(): Error { + const error = new Error('The request was canceled.'); + error.name = 'AbortError'; + return error; +} + +/** Reads one chunk, or 'stalled' when nothing arrives in time. */ +async function readWithin(reader: LiveStreamReader, ms: number): Promise<{ done: boolean; value?: Uint8Array } | 'stalled'> { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + reader.read(), + new Promise<'stalled'>((resolve) => { + timer = setTimeout(() => resolve('stalled'), ms); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +function isPhase(value: unknown): value is LivePhase { + return value === 'research' || value === 'writing' || value === 'checking'; +} + +/** + * Reads until the body ends, the caller aborts, the read fails, or `capMs` + * elapses. True means the body ended, so the caller must not cancel it. + */ +async function drainUntilEnd(reader: LiveStreamReader, signal: LiveAbortSignal | undefined, capMs: number): Promise { + let timer: ReturnType | undefined; + const cap = new Promise<'cap'>((resolve) => { + timer = setTimeout(() => resolve('cap'), capMs); + }); + try { + while (!signal?.aborted) { + const read = reader.read().then( + (chunk) => ({ kind: 'chunk' as const, chunk }), + (): { kind: 'error' } => ({ kind: 'error' }), + ); + const winner = await Promise.race([read, cap.then((): { kind: 'cap' } => ({ kind: 'cap' }))]); + if (winner.kind !== 'chunk') return false; + if (winner.chunk.done) return true; + } + return false; + } finally { + if (timer) clearTimeout(timer); + } +} + +/** + * Follows an event stream from the `ai` function to its `result`. The + * outcome is returned as soon as `result` arrives. Reading continues until + * the body ends so the fetch is not cancelled in the gap before the server + * closes it. Throws an AbortError when `signal` aborts. + */ +export async function readLiveStream(reader: LiveStreamReader, live: LiveGeneration, signal?: LiveAbortSignal): Promise { + const decoder = new TextDecoder(); + let buffer = ''; + let text = ''; + let shown = ''; + let timer: ReturnType | null = null; + let result: { status: number; body: unknown } | null = null; + let unreadableResult = false; + + const showDraft = () => { + if (timer) clearTimeout(timer); + timer = null; + if (text === shown) return; + shown = text; + const draft = parsePartialJson(text); + if (draft) live.onDraft(draft); + }; + const scheduleDraft = () => { + if (!timer) timer = setTimeout(showDraft, DRAFT_INTERVAL_MS); + }; + + const handle = (events: ParsedSSEEvent[]) => { + for (const { event, data } of events) { + const payload = data && typeof data === 'object' ? (data as Record) : {}; + if (event === 'delta' && typeof payload.text === 'string') { + text += payload.text; + scheduleDraft(); + } else if (event === 'reset') { + // A repair retry writes the answer again from the start. + text = ''; + shown = ''; + live.onDraft({}); + } else if (event === 'phase' && isPhase(payload.phase)) { + // Text written before this phase lands first, so a throttled draft + // cannot arrive after it and look like writing again. + showDraft(); + live.onPhase?.(payload.phase); + } else if (event === 'result') { + // A result that is not the {status, body} the function sends was + // damaged on the way; the stored copy may still be read. + if (typeof payload.status === 'number') result = { status: payload.status, body: payload.body }; + else unreadableResult = true; + } + } + }; + + // A fetch wrapper (the Jam recorder wraps window.fetch) can keep a + // response streaming after its request is aborted, so stop reading here. + const stopReading = () => { + reader.cancel(signal?.reason).catch((): undefined => undefined); + }; + signal?.addEventListener('abort', stopReading, { once: true }); + + // Cancel only while the body is still open. Cancelling after `result`, + // before the server closes, aborts the fetch and drops the body. + let streamEnded = false; + let draining = false; + let stalled = false; + const release = () => { + if (timer) clearTimeout(timer); + timer = null; + if (stalled) { + // Keep the connection, so the run keeps going and stores its result. + setTimeout(stopReading, LIVE_STREAM_STALLED_OPEN_MS); + return; + } + signal?.removeEventListener('abort', stopReading); + if (!streamEnded) reader.cancel().catch((): undefined => undefined); + }; + + let failure: LiveStreamError | null = null; + try { + while (!result && !failure) { + if (signal?.aborted) throw abortError(); + let chunk: Awaited>; + try { + chunk = await readWithin(reader, LIVE_STREAM_STALL_MS); + } catch (error) { + if (signal?.aborted) throw abortError(); + // The connection dropped (ERR_CONNECTION_CLOSED, network change). + failure = new LiveStreamError('dropped', error); + break; + } + if (signal?.aborted) throw abortError(); + if (chunk === 'stalled') { + stalled = true; + failure = new LiveStreamError('stalled'); + break; + } + if (chunk.done) { + streamEnded = true; + break; + } + const parsed = consumeSSEChunk(buffer, decoder.decode(chunk.value, { stream: true })); + buffer = parsed.buffer; + handle(parsed.events); + } + // A last event without its closing blank line still counts. + if (!result && !failure) handle(consumeSSEChunk('', `${buffer}${decoder.decode()}\n\n`).events); + + // Hand the answer back before the tail, so a slow close cannot delay the UI. + if (result && !streamEnded) { + if (timer) clearTimeout(timer); + timer = null; + draining = true; + void drainUntilEnd(reader, signal, LIVE_STREAM_DRAIN_MS) + .then((ended) => { + streamEnded = ended; + }) + .catch((): undefined => undefined) + .finally(release); + } + } finally { + if (!draining) release(); + } + + const final = result as { status: number; body: unknown } | null; + if (!final) return { kind: 'failed', error: failure ?? new LiveStreamError(unreadableResult ? 'unreadable' : 'ended') }; + return { kind: 'result', status: final.status, body: final.body }; +} diff --git a/packages/core/src/ai/run-recovery.ts b/packages/core/src/ai/run-recovery.ts new file mode 100644 index 000000000..991920c45 --- /dev/null +++ b/packages/core/src/ai/run-recovery.ts @@ -0,0 +1,97 @@ +// Recovers a live run's answer after its stream dropped. The ai function +// keeps each streamed run's final result in ai_run_results under the id the +// client sent (supabase/functions/ai/run-results.ts), writing it before it +// sends the `result` event, so an answer the client did not receive can +// still be read. Shared by the website and the iOS app; each passes its own +// read of the table. + +export interface RecoveredRunResult { + status: number; + body: unknown; +} + +/** The caller's read of the signed-in user's ai_run_results row for one feature. */ +export type ReadRunResult = (feature: string) => PromiseLike<{ + data: unknown; + error: unknown; +}>; + +/** No run answers past Supabase's 150 s wall clock, counted from the request. */ +export const RUN_RESULT_WAIT_MS = 155_000; +export const RUN_RESULT_POLL_MS = 2_500; +/** + * A run the server started has a row within moments. With none after this + * long, the request never reached a server that keeps results. + */ +export const RUN_RESULT_MISSING_MS = 8_000; +/** + * A run that loses its client is cancelled and stores its outcome within + * seconds, and one still running stores it when it ends. A row still without + * a result this long after the drop is a run whose worker is gone. A stalled + * stream is left open (live.ts), so its run is still writing and stores its + * result when it ends; it waits the whole window instead. + */ +export const RUN_RESULT_RUNNING_MS = 30_000; + +interface WaitSignal { + readonly aborted: boolean; + addEventListener(type: 'abort', listener: () => void, options?: { once?: boolean }): void; + removeEventListener(type: 'abort', listener: () => void): void; +} + +function abortError(): Error { + const error = new Error('The request was canceled.'); + error.name = 'AbortError'; + return error; +} + +function wait(ms: number, signal?: WaitSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(abortError()); + return; + } + const timer = setTimeout(() => { + signal?.removeEventListener('abort', onAbort); + resolve(); + }, ms); + const onAbort = () => { + clearTimeout(timer); + reject(abortError()); + }; + signal?.addEventListener('abort', onAbort, { once: true }); + }); +} + +/** + * Waits for the stored result of the run named `resultKey`, which started at + * `startedAt` (epoch ms). Resolves with it, or null when there is none to + * wait for: no row (an older server, or a request that never arrived), a + * newer run of the same feature took the row, the table cannot be read, or + * the run outlived any server. Rejects only when `signal` aborts. + */ +export async function recoverRunResult( + read: ReadRunResult, + feature: string, + resultKey: string, + startedAt: number, + signal?: WaitSignal, + runningGraceMs = RUN_RESULT_RUNNING_MS, + now: () => number = Date.now, +): Promise { + const recoveryStartedAt = now(); + while (true) { + if (signal?.aborted) throw abortError(); + const { data, error } = await read(feature); + if (signal?.aborted) throw abortError(); + if (error) return null; + const row = data as { result_key?: unknown; status?: unknown; body?: unknown } | null; + if (row && row.result_key !== resultKey) return null; + if (row && typeof row.status === 'number') return { status: row.status, body: row.body }; + // No row yet, or a run still writing (status null). + if (!row && now() - recoveryStartedAt >= RUN_RESULT_MISSING_MS) return null; + if (row && now() - recoveryStartedAt >= runningGraceMs) return null; + if (now() - startedAt >= RUN_RESULT_WAIT_MS) return null; + await wait(RUN_RESULT_POLL_MS, signal); + } +} diff --git a/packages/core/src/globals.d.ts b/packages/core/src/globals.d.ts index c5bc0ad43..209dbf432 100644 --- a/packages/core/src/globals.d.ts +++ b/packages/core/src/globals.d.ts @@ -1,5 +1,12 @@ // Globals that both browsers and React Native's Hermes provide but the ES lib // does not declare. Declare only what package code uses; never add DOM types. +declare function setTimeout(callback: () => void, ms?: number): number; +declare function clearTimeout(id: number | undefined): void; + +declare class TextDecoder { + decode(input?: Uint8Array, options?: { stream?: boolean }): string; +} + declare const console: { warn(...data: unknown[]): void; }; diff --git a/src/components/features/tools/live-sections.ts b/src/components/features/tools/live-sections.ts index e4a428e7c..01e12e72d 100644 --- a/src/components/features/tools/live-sections.ts +++ b/src/components/features/tools/live-sections.ts @@ -1,220 +1,2 @@ -// Maps each tool's partially written answer to the sections a live draft -// shows. The labels are the ones the finished results use, so the draft -// reads like the result filling in. Anything not written yet is left out. -import type { TFunction } from 'i18next'; - -export interface DraftBlock { - /** A bold first line, such as a contention's claim. */ - heading?: string; - label?: string; - text?: string; - items?: string[]; -} - -export interface DraftSection { - id: string; - title: string; - blocks: DraftBlock[]; -} - -type Draft = Record; - -function record(value: unknown): Draft { - return value && typeof value === 'object' && !Array.isArray(value) ? value as Draft : {}; -} - -function list(value: unknown): unknown[] { - return Array.isArray(value) ? value : []; -} - -function text(value: unknown): string | undefined { - if (typeof value === 'number') return String(value); - return typeof value === 'string' && value.trim() ? value : undefined; -} - -function strings(value: unknown): string[] { - return list(value).flatMap((item): string[] => { const entry = text(item); return entry ? [entry] : []; }); -} - -function block(label: string | undefined, value: unknown): DraftBlock[] { - const content = text(value); - return content ? [{ label, text: content }] : []; -} - -function itemsBlock(label: string | undefined, value: unknown): DraftBlock[] { - const items = strings(value); - return items.length ? [{ label, items }] : []; -} - -function heading(value: unknown): DraftBlock[] { - const content = text(value); - return content ? [{ heading: content }] : []; -} - -function section(id: string, title: string, blocks: DraftBlock[]): DraftSection[] { - return blocks.length ? [{ id, title, blocks }] : []; -} - -export function generateDraftSections(draft: Draft, t: TFunction): DraftSection[] { - const exordio = record(draft.exordio); - const structure = record(draft.caseStructure); - const opponent = record(draft.predictedOpponentCase); - return [ - ...section('exordio', t('generateResults.exordio'), [ - ...block(t('generateResults.hook'), exordio.hook), - ...block(t('generateResults.transition'), exordio.transition), - ]), - ...section('structure', t('generateResults.caseStructure'), [ - ...block(t('generateResults.thesis'), structure.thesis), - ...block(t('generateResults.framework'), structure.framework), - ...itemsBlock(t('generateResults.keyDefinitions'), structure.keyDefinitions), - ...block(t('generateResults.burdenOfProof'), structure.burdenOfProof), - ]), - ...section('contentions', t('generateResults.arguments'), list(draft.contentions).flatMap((value) => { - const contention = record(value); - return [ - ...heading(contention.claim), - ...block(t('generateResults.reasoning'), contention.reasoning), - ...block(t('generateResults.evidence'), contention.evidence), - ...block(t('generateResults.impact'), contention.impact), - ]; - })), - ...section('opponent', t('generateResults.predictedOpponent'), [ - ...itemsBlock(t('generateResults.likelyArguments'), opponent.likelyArguments), - ...itemsBlock(t('generateResults.strategicWeaknesses'), opponent.strategicWeaknesses), - ...itemsBlock(t('generateResults.keyClashes'), opponent.keyClashes), - ]), - ...section('counters', t('generateResults.preparedCounters'), list(draft.counters).flatMap((value) => { - const counter = record(value); - return [ - ...heading(counter.targetArgument), - ...block(t('generateResults.counter'), counter.counterpoint), - ...block(t('generateResults.rebuttal'), counter.rebuttal), - ]; - })), - ]; -} - -export function attackDraftSections(draft: Draft, t: TFunction): DraftSection[] { - const overall = record(draft.overallAssessment); - const weighing = record(draft.weighingAnalysis); - const score = text(overall.score); - return [ - ...section('overall', t('attackResults.overall'), [ - ...block(t('attackResults.caseStrength'), score ? `${score}/100` : undefined), - ...block(undefined, overall.summary), - ...itemsBlock(t('attackResults.strengths'), overall.strengthAreas), - ]), - ...section('weaknesses', t('attackResults.weaknesses'), list(draft.weaknesses).flatMap((value) => { - const weakness = record(value); - return [ - ...heading(weakness.description), - ...block(t('attackResults.whyWeak'), weakness.whyWeak), - ...block(t('attackResults.opponentAttack'), weakness.howOpponentAttacks), - ...block(t('attackResults.improvedVersion'), weakness.improvedVersion), - ]; - })), - ...section('fallacies', t('attackResults.fallacies'), list(draft.fallaciesDetected).flatMap((value) => { - const fallacy = record(value); - return [...heading(fallacy.name), ...block(undefined, fallacy.explanation)]; - })), - ...section('crossEx', t('attackResults.crossEx'), list(draft.crossExQuestions).flatMap((value) => { - const question = record(value); - return [ - ...heading(question.question), - ...block(t('attackResults.purpose'), question.purpose), - ...block(t('attackResults.expectedTrap'), question.expectedTrap), - ]; - })), - ...section('weighing', t('attackResults.weighing'), [ - ...block(t('attackResults.currentWeighing'), weighing.currentWeighing), - ...itemsBlock(t('attackResults.missingComparisons'), weighing.missingComparisons), - ...itemsBlock(t('attackResults.improvements'), weighing.improvementSuggestions), - ]), - ]; -} - -function ideaBlocks(value: unknown): DraftBlock[] { - const idea = record(value); - return [...heading(idea.title), ...block(undefined, idea.description)]; -} - -export function brainstormDraftSections(draft: Draft, t: TFunction): DraftSection[] { - return [ - ...section('clarification', t('brainstormResults.clarification'), itemsBlock(undefined, draft.clarifyingQuestions)), - ...list(draft.mindmap).flatMap((value, index) => { - const branch = record(value); - return section(`mindmap-${index}`, text(branch.category) ?? t('brainstormResults.argMap'), list(branch.items).flatMap(ideaBlocks)); - }), - ...list(draft.buckets).flatMap((value, index) => { - const bucket = record(value); - return section(`bucket-${index}`, text(bucket.name) ?? t('brainstormResults.argMap'), [ - ...block(undefined, bucket.keyInsight), - ...list(bucket.ideas).flatMap(ideaBlocks), - ]); - }), - ...section('topFive', t('brainstormResults.argMap'), list(draft.topFive).flatMap((value) => { - const entry = record(value); - return [...ideaBlocks(entry.idea), ...block(undefined, entry.whyTop)]; - })), - ...section('nextSteps', t('brainstormResults.nextSteps'), itemsBlock(undefined, draft.nextSteps)), - ...section('warnings', t('brainstormResults.warnings'), itemsBlock(undefined, draft.warningsAndTradeoffs)), - ]; -} - -export function factoidsDraftSections(draft: Draft, t: TFunction): DraftSection[] { - return [ - ...section('factoids', t('factoidsResults.factoids'), list(draft.factoids).flatMap((value) => { - const factoid = record(value); - return [ - ...heading(factoid.shortClaim), - ...block(t('factoidsResults.keyFigure'), factoid.keyFigure), - ...block(t('factoidsResults.context'), factoid.context), - ...block(t('factoidsResults.limitations'), factoid.limitations), - ...block(t('factoidsResults.howToUse'), factoid.howToUse), - ]; - })), - ...section('findMore', t('factoidsResults.findMore'), itemsBlock(undefined, draft.searchSuggestions)), - ...section('notes', t('factoidsResults.importantNotes'), itemsBlock(undefined, draft.warnings)), - ]; -} - -function claims(value: unknown): string[] { - return list(value).flatMap((item): string[] => { const claim = text(record(item).claim); return claim ? [claim] : []; }); -} - -export function crossfireDebriefDraftSections(draft: Draft, t: TFunction): DraftSection[] { - const categories = [ - 'concessionsSecured', - 'controlledQuestions', - 'weakOrRepetitiveQuestions', - 'evasiveOrIncompleteAnswers', - 'missedFollowUps', - ] as const; - return [ - ...categories.flatMap((key) => section(key, t(`crossfire.${key}`), itemsBlock(undefined, claims(draft[key])))), - ...section('betterFollowUpQuestions', t('crossfire.betterFollowUpQuestions'), list(draft.betterFollowUpQuestions).flatMap((value) => { - const item = record(value); - return [...heading(item.question), ...block(undefined, item.reason)]; - })), - ...section('strongestExchange', t('crossfire.strongestExchange'), block(undefined, record(draft.strongestExchange).claim)), - ]; -} - -export function sparringScoreDraftSections(draft: Draft, t: TFunction): DraftSection[] { - const scorecard = record(draft.scorecard); - return [ - ...section('overall', t('sparring.scorecard.overallFeedback', 'Overall feedback'), block(undefined, scorecard.overallFeedback)), - ...section('categories', t('sparring.scorecard.categoryNotes', 'Category notes'), list(scorecard.categories).flatMap((value) => { - const category = record(value); - const name = text(category.name); - return [ - ...heading(name ? t(`sparring.category.${name}`, name) : undefined), - ...block(undefined, category.feedback), - ]; - })), - ...section('keyMoments', t('sparring.scorecard.keyMoments', 'Key moments'), itemsBlock(undefined, scorecard.keyMoments ?? draft.keyMoments)), - ...section('workOnNext', t('sparring.scorecard.workOnNext', 'Work on next'), itemsBlock(undefined, scorecard.improvementAreas ?? draft.improvementAreas)), - ...section('verdict', t('sparring.scorecard.verdict', "Judge's verdict"), block(undefined, scorecard.verdict ?? draft.verdict)), - ]; -} +// The live draft sections are shared with the iOS app. +export * from '@debatelab/core/ai/live-sections'; diff --git a/src/lib/ai/live.ts b/src/lib/ai/live.ts index e15818f09..92ecca432 100644 --- a/src/lib/ai/live.ts +++ b/src/lib/ai/live.ts @@ -1,123 +1,26 @@ // Live generation: read the `ai` function's event stream (see // supabase/functions/ai/stream.ts) and report the model's text as it is -// written. The finished request resolves exactly as a plain one does. +// written. The reader is shared with the iOS app +// (packages/core/src/ai/live.ts); here the finished request resolves exactly +// as a plain one does. import { FunctionsFetchError, FunctionsHttpError } from '@supabase/supabase-js'; -import { parsePartialJson } from '@debatelab/core/ai/partial-json'; -import { consumeSSEChunk, type ParsedSSEEvent } from './sse'; - -export type LivePhase = 'research' | 'writing' | 'checking'; - -export interface LiveGeneration { - /** The answer's JSON as far as the model has written it. Throttled. */ - onDraft: (draft: Record) => void; - /** A step that writes nothing yet started, such as web research. */ - onPhase?: (phase: LivePhase) => void; -} - -const DRAFT_INTERVAL_MS = 50; - -/** - * After `result`, keep reading only this long. The function closes the body - * a moment later; waiting for that close avoids cancelling the fetch, and - * the cap stops a body that never closes from holding the reader open. - */ -export const LIVE_STREAM_DRAIN_MS = 2_000; - -/** - * The function sends a keepalive every 15 s. Three missed in a row means the - * stream is gone even if the connection still looks open, as when a proxy - * holds the tail of a response. - */ -export const LIVE_STREAM_STALL_MS = 45_000; - -/** - * A stalled stream is left open, not cancelled: closing it would make the - * function cancel the run whose stored result the caller is about to read. - * It is cancelled after this long, past any run's wall clock, or when the - * caller aborts. - */ -export const LIVE_STREAM_STALLED_OPEN_MS = 160_000; - -/** - * Why a live stream ended without its answer: the body ended, the - * connection dropped, nothing arrived for LIVE_STREAM_STALL_MS, or the - * `result` event arrived but could not be read. The caller can still recover - * the stored result (run-recovery.ts) before it reports the failure. - */ -export type LiveStreamFailure = 'ended' | 'dropped' | 'stalled' | 'unreadable'; - -export class LiveStreamError extends Error { - readonly reason: LiveStreamFailure; - /** The read error behind a dropped stream. */ - readonly cause?: unknown; - - constructor(reason: LiveStreamFailure, cause?: unknown) { - super(reason === 'unreadable' ? 'The answer arrived but could not be read.' : 'The response ended before the answer was complete.'); - this.name = 'LiveStreamError'; - this.reason = reason; - this.cause = cause; - } -} - -/** Reads one chunk, or 'stalled' when nothing arrives in time. */ -async function readWithin(reader: ReadableStreamDefaultReader, ms: number): Promise | 'stalled'> { - let timer: ReturnType | undefined; - try { - return await Promise.race([ - reader.read(), - new Promise<'stalled'>((resolve) => { - timer = setTimeout(() => resolve('stalled'), ms); - }), - ]); - } finally { - if (timer) clearTimeout(timer); - } -} - -function isPhase(value: unknown): value is LivePhase { - return value === 'research' || value === 'writing' || value === 'checking'; -} - -/** - * Reads until the body ends, the caller aborts, the read fails, or `capMs` - * elapses. True means the body ended, so the caller must not cancel it. - */ -async function drainUntilEnd( - reader: ReadableStreamDefaultReader, - signal: AbortSignal | undefined, - capMs: number, -): Promise { - let timer: ReturnType | undefined; - const cap = new Promise<'cap'>((resolve) => { - timer = setTimeout(() => resolve('cap'), capMs); - }); - try { - while (!signal?.aborted) { - const read = reader.read().then( - (chunk) => ({ kind: 'chunk' as const, chunk }), - (): { kind: 'error' } => ({ kind: 'error' }), - ); - const winner = await Promise.race([ - read, - cap.then((): { kind: 'cap' } => ({ kind: 'cap' })), - ]); - if (winner.kind !== 'chunk') return false; - if (winner.chunk.done) return true; - } - return false; - } finally { - if (timer) clearTimeout(timer); - } -} +import { readLiveStream, type LiveGeneration } from '@debatelab/core/ai/live'; + +export { + LIVE_STREAM_DRAIN_MS, + LIVE_STREAM_STALL_MS, + LIVE_STREAM_STALLED_OPEN_MS, + LiveStreamError, + type LiveGeneration, + type LivePhase, + type LiveStreamFailure, +} from '@debatelab/core/ai/live'; /** * Reads a successful `ai` response. A JSON body (a server from before live * generation, or a cached reply) is returned as is; an event stream is * followed to its `result`, whose status decides success or failure. - * The answer is returned as soon as `result` arrives. Reading continues - * until the body ends so the fetch is not cancelled in the gap before the - * server closes it. */ export async function readLiveResponse( response: Response, @@ -127,129 +30,13 @@ export async function readLiveResponse( const contentType = response.headers.get('content-type') ?? ''; if (!contentType.includes('text/event-stream') || !response.body) return await response.json() as T; - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - let text = ''; - let shown = ''; - let timer: ReturnType | null = null; - let result: { status: number; body: unknown } | null = null; - let unreadableResult = false; - - const showDraft = () => { - if (timer) clearTimeout(timer); - timer = null; - if (text === shown) return; - shown = text; - const draft = parsePartialJson(text); - if (draft) live.onDraft(draft); - }; - const scheduleDraft = () => { - if (!timer) timer = setTimeout(showDraft, DRAFT_INTERVAL_MS); - }; - - const handle = (events: ParsedSSEEvent[]) => { - for (const { event, data } of events) { - const payload = data && typeof data === 'object' ? data as Record : {}; - if (event === 'delta' && typeof payload.text === 'string') { - text += payload.text; - scheduleDraft(); - } else if (event === 'reset') { - // A repair retry writes the answer again from the start. - text = ''; - shown = ''; - live.onDraft({}); - } else if (event === 'phase' && isPhase(payload.phase)) { - // Text written before this phase lands first, so a throttled draft - // cannot arrive after it and look like writing again. - showDraft(); - live.onPhase?.(payload.phase); - } else if (event === 'result') { - // A result that is not the {status, body} the function sends was - // damaged on the way; the stored copy may still be read. - if (typeof payload.status === 'number') result = { status: payload.status, body: payload.body }; - else unreadableResult = true; - } - } - }; - - // A fetch wrapper (the Jam recorder wraps window.fetch) can keep a - // response streaming after its request is aborted, so stop reading here. - const stopReading = () => { - reader.cancel(signal?.reason).catch((): undefined => undefined); - }; - signal?.addEventListener('abort', stopReading, { once: true }); - - // Cancel only while the body is still open. Cancelling after `result`, - // before the server closes, aborts the fetch and drops the body. - let streamEnded = false; - let draining = false; - let stalled = false; - const release = () => { - if (timer) clearTimeout(timer); - timer = null; - if (stalled) { - // Keep the connection, so the run keeps going and stores its result. - setTimeout(stopReading, LIVE_STREAM_STALLED_OPEN_MS); - return; - } - signal?.removeEventListener('abort', stopReading); - if (!streamEnded) reader.cancel().catch((): undefined => undefined); - }; - - let failure: LiveStreamError | null = null; - try { - while (!result && !failure) { - if (signal?.aborted) throw new DOMException('The request was canceled.', 'AbortError'); - let chunk: Awaited>; - try { - chunk = await readWithin(reader, LIVE_STREAM_STALL_MS); - } catch (error) { - if (signal?.aborted) throw new DOMException('The request was canceled.', 'AbortError'); - // The connection dropped (ERR_CONNECTION_CLOSED, network change). - failure = new LiveStreamError('dropped', error); - break; - } - if (signal?.aborted) throw new DOMException('The request was canceled.', 'AbortError'); - if (chunk === 'stalled') { - stalled = true; - failure = new LiveStreamError('stalled'); - break; - } - if (chunk.done) { - streamEnded = true; - break; - } - const parsed = consumeSSEChunk(buffer, decoder.decode(chunk.value, { stream: true })); - buffer = parsed.buffer; - handle(parsed.events); - } - // A last event without its closing blank line still counts. - if (!result && !failure) handle(consumeSSEChunk('', `${buffer}${decoder.decode()}\n\n`).events); - - // Hand the answer back before the tail, so a slow close cannot delay the UI. - if (result && !streamEnded) { - if (timer) clearTimeout(timer); - timer = null; - draining = true; - void drainUntilEnd(reader, signal, LIVE_STREAM_DRAIN_MS) - .then((ended) => { - streamEnded = ended; - }) - .catch((): undefined => undefined) - .finally(release); - } - } finally { - if (!draining) release(); - } - - const final = result as { status: number; body: unknown } | null; - if (!final) return new FunctionsFetchError(failure ?? new LiveStreamError(unreadableResult ? 'unreadable' : 'ended')); - if (final.status >= 200 && final.status < 300) return final.body as T; + const outcome = await readLiveStream(response.body.getReader(), live, signal); + if (outcome.kind === 'failed') return new FunctionsFetchError(outcome.error); + if (outcome.status >= 200 && outcome.status < 300) return outcome.body as T; // The same error a plain request would have produced, so error mapping // (limits, model access, provider failures) does not change. - return new FunctionsHttpError(new Response(JSON.stringify(final.body ?? {}), { - status: final.status, + return new FunctionsHttpError(new Response(JSON.stringify(outcome.body ?? {}), { + status: outcome.status, headers: { 'Content-Type': 'application/json' }, })); } diff --git a/src/lib/ai/run-recovery.ts b/src/lib/ai/run-recovery.ts index 4e700c75a..261273705 100644 --- a/src/lib/ai/run-recovery.ts +++ b/src/lib/ai/run-recovery.ts @@ -1,59 +1,20 @@ -// Recovers a live run's answer after its stream dropped. The ai function -// keeps each streamed run's final result in ai_run_results under the id the -// browser sent (supabase/functions/ai/run-results.ts), writing it before it -// sends the `result` event, so an answer the browser did not receive can -// still be read. +// Recovers a live run's answer after its stream dropped. The waiting rules are +// shared with the iOS app (packages/core/src/ai/run-recovery.ts); this reads +// the browser's ai_run_results row. +import { + recoverRunResult as recoverStoredRunResult, + RUN_RESULT_RUNNING_MS, + type RecoveredRunResult, +} from '@debatelab/core/ai/run-recovery'; import { supabase } from '@/lib/supabase/client'; -export interface RecoveredRunResult { - status: number; - body: unknown; -} - -/** No run answers past Supabase's 150 s wall clock, counted from the request. */ -export const RUN_RESULT_WAIT_MS = 155_000; -export const RUN_RESULT_POLL_MS = 2_500; -/** - * A run the server started has a row within moments. With none after this - * long, the request never reached a server that keeps results. - */ -export const RUN_RESULT_MISSING_MS = 8_000; -/** - * A run that loses its browser is cancelled and stores its outcome within - * seconds, and one still running stores it when it ends. A row still without - * a result this long after the drop is a run whose worker is gone. A stalled - * stream is left open (live.ts), so its run is still writing and stores its - * result when it ends; it waits the whole window instead. - */ -export const RUN_RESULT_RUNNING_MS = 30_000; +export { RUN_RESULT_MISSING_MS, RUN_RESULT_POLL_MS, RUN_RESULT_RUNNING_MS, RUN_RESULT_WAIT_MS, type RecoveredRunResult } from '@debatelab/core/ai/run-recovery'; -function wait(ms: number, signal?: AbortSignal): Promise { - return new Promise((resolve, reject) => { - if (signal?.aborted) { - reject(new DOMException('The request was canceled.', 'AbortError')); - return; - } - const timer = setTimeout(() => { - signal?.removeEventListener('abort', onAbort); - resolve(); - }, ms); - const onAbort = () => { - clearTimeout(timer); - reject(new DOMException('The request was canceled.', 'AbortError')); - }; - signal?.addEventListener('abort', onAbort, { once: true }); - }); -} +const readRunResult = (feature: string) => + supabase.from('ai_run_results').select('result_key,status,body').eq('feature', feature).maybeSingle(); -/** - * Waits for the stored result of the run named `resultKey`, which started at - * `startedAt` (epoch ms). Resolves with it, or null when there is none to - * wait for: no row (an older server, or a request that never arrived), a - * newer run of the same feature took the row, the table cannot be read, or - * the run outlived any server. Rejects only when `signal` aborts. - */ -export async function recoverRunResult( +export function recoverRunResult( feature: string, resultKey: string, startedAt: number, @@ -61,23 +22,5 @@ export async function recoverRunResult( runningGraceMs = RUN_RESULT_RUNNING_MS, now: () => number = Date.now, ): Promise { - const recoveryStartedAt = now(); - while (true) { - if (signal?.aborted) throw new DOMException('The request was canceled.', 'AbortError'); - const { data, error } = await supabase - .from('ai_run_results') - .select('result_key,status,body') - .eq('feature', feature) - .maybeSingle(); - if (signal?.aborted) throw new DOMException('The request was canceled.', 'AbortError'); - if (error) return null; - const row = data as { result_key?: unknown; status?: unknown; body?: unknown } | null; - if (row && row.result_key !== resultKey) return null; - if (row && typeof row.status === 'number') return { status: row.status, body: row.body }; - // No row yet, or a run still writing (status null). - if (!row && now() - recoveryStartedAt >= RUN_RESULT_MISSING_MS) return null; - if (row && now() - recoveryStartedAt >= runningGraceMs) return null; - if (now() - startedAt >= RUN_RESULT_WAIT_MS) return null; - await wait(RUN_RESULT_POLL_MS, signal); - } + return recoverStoredRunResult(readRunResult, feature, resultKey, startedAt, signal, runningGraceMs, now); } From 25e2b7b130043e31a6fd71b526c8fa322812a9f8 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 28 Oct 2026 11:47:51 +0000 Subject: [PATCH 02/14] Stream Generate, Attack, Brainstorm and Factoids live in the iOS app The four tools ask the ai function to stream, as the website does, and show the answer as it is written: the phase line and elapsed time first, then the result's sections filling in with a caret. A stream that drops before its answer reads it back from ai_run_results under the request's own id. A refusal inside the stream keeps its limit details for the limit sheets. The screenshot script streams part of each answer from inside the page for new "-live" shots, and matches the sentence-case Brainstorm and Factoids buttons. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/AGENTS.md | 2 +- apps/mobile/scripts/screenshots.ts | 95 ++++++++-- apps/mobile/src/app/attack/index.tsx | 28 ++- apps/mobile/src/app/brainstorm/index.tsx | 29 ++- apps/mobile/src/app/factoids/index.tsx | 29 ++- apps/mobile/src/app/generate.tsx | 30 ++- apps/mobile/src/components/live-run.tsx | 227 +++++++++++++++++++++++ apps/mobile/src/components/owl.tsx | 2 +- apps/mobile/src/components/tool-form.tsx | 24 ++- apps/mobile/src/lib/ai-request.test.ts | 88 +++++++++ apps/mobile/src/lib/ai-request.ts | 80 +++++++- apps/mobile/src/lib/results.ts | 17 +- 12 files changed, 589 insertions(+), 62 deletions(-) create mode 100644 apps/mobile/src/components/live-run.tsx create mode 100644 apps/mobile/src/lib/ai-request.test.ts diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 842c984da..511d11f3d 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -23,7 +23,7 @@ The iOS app DebateOwl (`@debatelab/mobile`; the bundle id is still `com.adam.deb - `src/lib/`: auth (`auth.tsx`), `native-capabilities.ts` (what the running binary has beyond Expo Go), the Supabase client, session storage (AES key in SecureStore, ciphertext in AsyncStorage) and i18n. `debates.ts`, `settings.tsx`, `subscription.ts` and `account.ts` read and write the website's tables the way its stores do (`src/lib/stores/debate-store.ts`, `settings-store.ts`, `src/hooks/use-subscription.ts`, `src/lib/account.ts`), so the app and the website share one Current Debate and one set of settings. Change them together with the website code they mirror. - `sources.ts` mirrors the session store's `addSource` and `removeSource` and `src/lib/source-import.ts`. The Library always shows the Current Debate, also in one-off mode, as on the website. Adding a file (the website parses files in the browser) and Scan pages are not in the app yet. - Ask mirrors `src/lib/ai/streaming.ts` and `askChat` in `src/lib/ai/client.ts`: `ask.ts` sends the requests (plain questions stream from `ai-stream` through `expo/fetch`; with Citations, Web search, Pros/Cons or Argument map on, the whole answer comes from `ai`, because `ai-stream` refuses those options), `ai-request.ts` holds the shared POST with the website's headers, timeout and error kinds, `feature-config.ts` reads each tool's model from `user_settings.feature_configs` (the model picker, `components/model-picker.tsx`, writes it back through `settings.tsx`'s serial queue in the website's canonical shape; tools read it through the same queue, so they run with the model just picked), `context-request.ts` builds the Current Debate context with the website's default selection and pins, and `chats.ts` saves finished exchanges to `chats` and `messages`. The SSE parser is shared in `packages/core/src/ai/sse.ts`. `markdown.ts` reads answers, because react-markdown needs the DOM. -- Generate and Attack mirror `generateCase` and `attackCase` in `src/lib/ai/client.ts` and the session store's `recordResult`: `results.ts` sends the requests through `ai-request.ts`, checks the output with the website's transform (moved to `packages/core/src/ai/generate-output.ts`), keeps researched sources in the debate's Auto-saved folder (`packages/core/src/ai/discovered-sources.ts`), saves the server's source rankings onto the Library sources a result used (`packages/core/src/ai/source-assessments.ts`, also for Ask's whole answers), says when the server shortened the input, and saves rows to `cases` and `attacks`. `motion.ts` and `motion-gate.ts` are the website's motion gate. `open-results.ts` holds results made in this session, so one-off work and failed saves still open as documents. It and the last Generate and Attack forms live in `session-memory.ts`, which the auth provider clears on every account change, including sign-out. `components/document.tsx` draws results as documents (mock 03); Brainstorm and Factoids reuse it. Export renders `export-html.ts` to a PDF with expo-print and shares it (`export.ts`); the web preview cannot print, so `export.web.ts` keeps the HTML and copied text on `window` for the screenshot script. Word export and Google Docs are not in the app. +- Generate and Attack mirror `generateCase` and `attackCase` in `src/lib/ai/client.ts` and the session store's `recordResult`: `results.ts` sends the requests through `ai-request.ts`'s `postAI`, which streams them as the website does (`stream: true` with a `resultKey`; the reader is `packages/core/src/ai/live.ts`, shared with the website's `src/lib/ai/live.ts`) and reads a dropped stream's answer back from `ai_run_results` (`packages/core/src/ai/run-recovery.ts`). The four tool screens show the draft with `components/live-run.tsx` (the website's `LiveToolRun`; the sections come from `packages/core/src/ai/live-sections.ts`). `results.ts` also checks the output with the website's transform (moved to `packages/core/src/ai/generate-output.ts`), keeps researched sources in the debate's Auto-saved folder (`packages/core/src/ai/discovered-sources.ts`), saves the server's source rankings onto the Library sources a result used (`packages/core/src/ai/source-assessments.ts`, also for Ask's whole answers), says when the server shortened the input, and saves rows to `cases` and `attacks`. `motion.ts` and `motion-gate.ts` are the website's motion gate. `open-results.ts` holds results made in this session, so one-off work and failed saves still open as documents. It and the last Generate and Attack forms live in `session-memory.ts`, which the auth provider clears on every account change, including sign-out. `components/document.tsx` draws results as documents (mock 03); Brainstorm and Factoids reuse it. Export renders `export-html.ts` to a PDF with expo-print and shares it (`export.ts`); the web preview cannot print, so `export.web.ts` keeps the HTML and copied text on `window` for the screenshot script. Word export and Google Docs are not in the app. - Brainstorm and Factoids mirror `brainstorm` and `factoids` in `src/lib/ai/client.ts` and their pages, through the same `results.ts`: the brainstorm transform and the verified-factoid selection live in `packages/core/src/ai/brainstorm-output.ts` and `factoids-output.ts`, rows go to `brainstorms` and `factoids`. Brainstorm has the motion gate; Factoids, like Attack, follows the Current Debate and saves with side `neutral`. A factoid's bookmark saves it to the Current Debate's `Factoids` folder, and a search suggestion reopens the Factoids form with that query. `prepareToolHref` in `components/prepare-tools.ts` is where both tabs open a tool. - Timer mirrors `src/pages/TimerPage.tsx`: `timer.ts` holds the formats and a clock made of start timestamps, never counted ticks, so it is right after the app returns from the background (`timer.test.ts`). `timer-signals.ts` plays the bundled beeps in `assets/sounds` (made by `scripts/render-timer-sounds.ts`) with expo-audio, taps with expo-haptics and keeps the screen awake while a speech runs; `timer-signals.web.ts` only records the signals for the screenshot script. Lock-screen timers wait for the development build (`TODO(live-activity)`). - Crossfire is the website's typed mode: `crossfire.ts` sends `crossfire` and its signal requests as `src/lib/ai/client.ts` does (Ask's model settings, 75 s and 5 s deadlines), with the round's id as `quotaSession` on turns and the debrief so the round is charged once, as the website does and saves a finished round to `crossfires` in its debate, as CrossfirePage's `recordResult` call; the reducer and schemas are `packages/core/src/crossfire`. The unfinished round is a session slot, so leaving the screen keeps it. Voice rounds need the development build, so the voice switch shows disabled. The website cannot reopen a saved round, so neither can the app. diff --git a/apps/mobile/scripts/screenshots.ts b/apps/mobile/scripts/screenshots.ts index 5b19459ed..ab568d1f0 100644 --- a/apps/mobile/scripts/screenshots.ts +++ b/apps/mobile/scripts/screenshots.ts @@ -45,7 +45,8 @@ * * Generate and Attack screenshots never call a model and never save: the * page answers the ai function with a fixed case or attack, holds it open - * for the "generating" shot, and answers case, attack and source writes + * for the "generating" shot, streams part of it from inside the page and + * holds that open for the "-live" shot, and answers case, attack and source writes * locally. The PDF export's HTML (the web export cannot run expo-print) is * rendered in a page of its own, and the "Copy as text" output is written * next to the screenshots. Brainstorm and Factoids are shot the same way, @@ -441,14 +442,57 @@ const FAKE_FACTOIDS = { type FakeFeature = 'generate' | 'brainstorm' | 'factoids'; -/** Fakes the tools' AI calls and saves for this page (see the header). `hold` keeps that tool waiting. */ -async function fakeResults(page: Page, hold: FakeFeature | null) { - const outputs: Record = { - generate: { output: FAKE_CASE, discoveredSources: FAKE_SOURCES }, - attack: { output: FAKE_ATTACK, discoveredSources: [] }, - brainstorm: { output: FAKE_BRAINSTORM, discoveredSources: FAKE_SOURCES }, - factoids: { output: FAKE_FACTOIDS, discoveredSources: FAKE_SOURCES }, - }; +const FAKE_OUTPUTS: Record = { + generate: { output: FAKE_CASE, discoveredSources: FAKE_SOURCES }, + attack: { output: FAKE_ATTACK, discoveredSources: [] }, + brainstorm: { output: FAKE_BRAINSTORM, discoveredSources: FAKE_SOURCES }, + factoids: { output: FAKE_FACTOIDS, discoveredSources: FAKE_SOURCES }, +}; +// The live stream writes this share of the answer's JSON, then stays open. +const LIVE_HOLD_SHARE = 0.55; + +/** + * Answers `feature`'s streamed request inside the page (expo/fetch uses the + * page's fetch) the way the ai function streams: research, then writing, + * then part of the answer's JSON a few characters at a time, and then it + * stays open, so a shot catches the live draft. Call before the first navigation. + */ +async function fakeLiveStream(page: Page, feature: FakeFeature) { + const text = JSON.stringify(FAKE_OUTPUTS[feature].output); + await page.addInitScript(({ name, partial }) => { + const realFetch = window.fetch.bind(window); + window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + if (!url.endsWith('/functions/v1/ai')) return realFetch(input, init); + // expo/fetch may hand over a Request rather than a body. + const text = typeof init?.body === 'string' ? init.body : input instanceof Request ? await input.clone().text() : ''; + const body = text ? (JSON.parse(text) as { feature?: string; stream?: boolean }) : null; + if (body?.feature !== name || !body.stream) return realFetch(input, init); + // No named helper functions here: tsx wraps them in a __name call the page does not have. + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + async start(controller) { + controller.enqueue(encoder.encode('event: phase\ndata: {"phase":"research"}\n\n')); + await new Promise((resolve) => setTimeout(resolve, 150)); + controller.enqueue(encoder.encode('event: phase\ndata: {"phase":"writing"}\n\n')); + for (let index = 0; index < partial.length; index += 40) { + await new Promise((resolve) => setTimeout(resolve, 5)); + controller.enqueue(encoder.encode(`event: delta\ndata: ${JSON.stringify({ text: partial.slice(index, index + 40) })}\n\n`)); + } + }, + }); + return new Response(stream, { status: 200, headers: { 'content-type': 'text/event-stream' } }); + }; + }, { name: feature, partial: text.slice(0, Math.floor(text.length * LIVE_HOLD_SHARE)) }); +} + +/** + * Fakes the tools' AI calls and saves for this page (see the header). `hold` keeps that tool + * waiting; with `live`, it writes part of its answer first (fakeLiveStream). + */ +async function fakeResults(page: Page, hold: FakeFeature | null, live = false) { + const outputs = FAKE_OUTPUTS; + if (hold && live) await fakeLiveStream(page, hold); await page.route(`https://${STAGING_HOST}/functions/v1/ai`, async (route) => { const feature = (route.request().postDataJSON() as { feature?: string } | null)?.feature; if (feature && feature === hold) return; // Never answered: the page shows its progress state. @@ -488,6 +532,20 @@ async function shootResultScreens(context: BrowserContext, prefix: string, langu await shoot(waiting, `${prefix}-generating`); await waiting.close(); + const writing = await context.newPage(); + watchErrors(writing, problems, `${prefix} results live`); + writing.on('dialog', (dialog) => void dialog.dismiss()); + await pinSettings(writing, language); + await fakeResults(writing, 'generate', true); + await writing.goto(`${ORIGIN}/generate?from=prepare`); + await writing.getByTestId('generate-motion').waitFor({ timeout: 30_000 }); + await writing.waitForFunction(() => (document.querySelector('[data-testid="generate-motion"]') as HTMLTextAreaElement | null)?.value.trim()); + await writing.getByRole('button', { name: /^(Generate debate case|Vitaeset generálása|Generar caso de debate)$/i }).click(); + await writing.getByTestId('live-draft').waitFor({ timeout: 15_000 }); + await writing.waitForTimeout(1200); + await shoot(writing, `${prefix}-generating-live`); + await writing.close(); + const page = await open(false); await page.goto(`${ORIGIN}/generate?from=prepare`); await page.getByTestId('generate-motion').waitFor({ timeout: 30_000 }); @@ -541,20 +599,20 @@ async function shootResultScreens(context: BrowserContext, prefix: string, langu } const TOOLS = { - brainstorm: { field: 'brainstorm-motion', submit: /^(Start Brainstorming|Ötletelés Indítása|Comenzar Lluvia de Ideas)$/, document: 'brainstorm-document', working: 'brainstorming' }, - factoids: { field: 'factoids-claim', submit: /^(Find Factoids|Tények Keresése|Buscar Datos)$/, document: 'factoids-document', working: 'finding-factoids' }, + brainstorm: { field: 'brainstorm-motion', submit: /^(Start brainstorming|Ötletelés indítása|Comenzar lluvia de ideas)$/i, document: 'brainstorm-document', working: 'brainstorming' }, + factoids: { field: 'factoids-claim', submit: /^(Find factoids|Tények keresése|Buscar datos)$/i, document: 'factoids-document', working: 'finding-factoids' }, } as const; const FACTOIDS_CLAIM = 'Car-free city centres cut asthma admissions'; /** Brainstorm or Factoids: the form, its progress, the document open and collapsed, and Export, from fixed outputs. */ async function shootTool(context: BrowserContext, prefix: string, language: string, problems: string[], tool: keyof typeof TOOLS) { const spec = TOOLS[tool]; - const open = async (hold: boolean) => { + const open = async (hold: boolean, live = false) => { const page = await context.newPage(); watchErrors(page, problems, `${prefix} ${tool}`); page.on('dialog', (dialog) => void dialog.dismiss()); await pinSettings(page, language); - await fakeResults(page, hold ? tool : null); + await fakeResults(page, hold ? tool : null, live); await page.goto(`${ORIGIN}/prepare`); await page.getByTestId(`prepare-tool-${tool}`).waitFor({ timeout: 30_000 }); await settle(page); @@ -573,6 +631,13 @@ async function shootTool(context: BrowserContext, prefix: string, language: stri await shoot(waiting, `${prefix}-${spec.working}`); await waiting.close(); + const writing = await open(true, true); + await writing.getByRole('button', { name: spec.submit }).click(); + await writing.getByTestId('live-draft').waitFor({ timeout: 15_000 }); + await writing.waitForTimeout(1200); + await shoot(writing, `${prefix}-${spec.working}-live`); + await writing.close(); + const page = await open(false); await page.getByRole('button', { name: spec.submit }).click(); await page.getByTestId(spec.document).waitFor({ timeout: 30_000 }); @@ -1683,12 +1748,12 @@ async function main() { } if (want('results')) { await shootResultScreens(signedIn, prefix, language, problems); - shots += 9; + shots += 10; } for (const tool of ['brainstorm', 'factoids'] as const) { if (!want(tool)) continue; await shootTool(signedIn, prefix, language, problems, tool); - shots += 6; + shots += 7; } if (want('timer')) { await shootTimerScreens(signedIn, prefix, language, problems); diff --git a/apps/mobile/src/app/attack/index.tsx b/apps/mobile/src/app/attack/index.tsx index c07b7b1b0..e475041ea 100644 --- a/apps/mobile/src/app/attack/index.tsx +++ b/apps/mobile/src/app/attack/index.tsx @@ -1,3 +1,4 @@ +import { attackDraftSections } from '@debatelab/core/ai/live-sections'; import type { AttackInput } from '@debatelab/core/ai/schemas/attack'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { useCallback, useEffect, useRef, useState } from 'react'; @@ -11,7 +12,8 @@ import { Field } from '@/components/field'; import { FormGroup, SelectRow, SwitchRow } from '@/components/form-rows'; import { ModelPicker } from '@/components/model-picker'; import { Text } from '@/components/text'; -import { ToolError, ToolProgress, ToolTitle, WorkingIn } from '@/components/tool-form'; +import { LiveToolRun, useLiveRun } from '@/components/live-run'; +import { ToolError, ToolTitle, WorkingIn } from '@/components/tool-form'; import { AIError, isAbortError, type AIErrorKind } from '@/lib/ai-request'; import { useAuth } from '@/lib/auth'; import { backLabel } from '@/lib/back-label'; @@ -35,8 +37,6 @@ const DEFAULT_FORM: AttackInput = { }; const lastForm = sessionSlot(() => DEFAULT_FORM); -// The website's one progress line while the tool runs. -const PROGRESS = ['attack.working']; /** * Attack, as the website's AttackPage. Opened from a case, the case's text @@ -67,11 +67,14 @@ export default function AttackScreen() { return next; }), []); + const liveRun = useLiveRun(); + const endLiveRun = liveRun.end; const abortActive = useCallback(() => { requests.current += 1; abort.current?.abort(); abort.current = null; - }, []); + endLiveRun(); + }, [endLiveRun]); useEffect(() => () => { live.current = false; @@ -121,7 +124,13 @@ export default function AttackScreen() { debateId ? buildDebateContext(forUser, debateId, 'attack', 'both') : Promise.resolve(undefined), ]); if (!isCurrent()) return; - const { result, discoveredSources, truncated, assessments } = await attackCase(input, { config, language: outputLanguage, debateContext, signal: controller.signal }); + const { result, discoveredSources, truncated, assessments } = await attackCase(input, { + config, + language: outputLanguage, + debateContext, + signal: controller.signal, + live: liveRun.begin(), + }); if (!isCurrent()) return; const assessed = await keepSourceAssessments(forUser, debateId, assessments, controller.signal); if (!isCurrent()) return; @@ -155,6 +164,7 @@ export default function AttackScreen() { const kind = cause instanceof AIError ? cause.kind : 'failed'; if (!(cause instanceof AIError)) console.warn('Attack failed:', cause instanceof Error ? cause.message : cause); if (kind === 'debateMissing') void refresh(); + liveRun.end(); setError(kind); setPhase('form'); } finally { @@ -172,13 +182,15 @@ export default function AttackScreen() { {attacking ? ( - t(key))} + { abortActive(); setPhase('form'); }} + pose="skeptical" /> ) : ( <> diff --git a/apps/mobile/src/app/brainstorm/index.tsx b/apps/mobile/src/app/brainstorm/index.tsx index f416a6f97..3768ad1d3 100644 --- a/apps/mobile/src/app/brainstorm/index.tsx +++ b/apps/mobile/src/app/brainstorm/index.tsx @@ -1,3 +1,4 @@ +import { brainstormDraftSections } from '@debatelab/core/ai/live-sections'; import type { BrainstormInput } from '@debatelab/core/ai/schemas/brainstorm'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { useCallback, useEffect, useRef, useState } from 'react'; @@ -11,7 +12,8 @@ import { Field } from '@/components/field'; import { FormGroup, SelectRow } from '@/components/form-rows'; import { ModelPicker } from '@/components/model-picker'; import { Text } from '@/components/text'; -import { ToolError, ToolProgress, ToolTitle, WorkingIn } from '@/components/tool-form'; +import { LiveToolRun, useLiveRun } from '@/components/live-run'; +import { ToolError, ToolTitle, WorkingIn } from '@/components/tool-form'; import { AIError, isAbortError, type AIErrorKind } from '@/lib/ai-request'; import { useAuth } from '@/lib/auth'; import { backLabel } from '@/lib/back-label'; @@ -31,8 +33,6 @@ const DEFAULT_FORM: BrainstormInput = { motion: '', side: 'both', context: '', o // The last form stays for the session, as on the website; session memory forgets it when the account changes. const lastForm = sessionSlot(() => DEFAULT_FORM); -// The website's one progress line while the tool runs. -const PROGRESS = ['brainstorm.working']; /** Brainstorm, as the website's BrainstormPage: a native form, a calm wait, then the ideas as a document. */ export default function BrainstormScreen() { @@ -60,11 +60,14 @@ export default function BrainstormScreen() { return next; }), []); + const liveRun = useLiveRun(); + const endLiveRun = liveRun.end; const abortActive = useCallback(() => { requests.current += 1; abort.current?.abort(); abort.current = null; - }, []); + endLiveRun(); + }, [endLiveRun]); useEffect(() => () => { live.current = false; @@ -101,7 +104,13 @@ export default function BrainstormScreen() { debateId ? buildDebateContext(forUser, debateId, 'brainstorm', input.side) : Promise.resolve(undefined), ]); if (!isCurrent()) return; - const { result, discoveredSources, truncated, assessments } = await brainstormIdeas(input, { config, language: outputLanguage, debateContext, signal: controller.signal }); + const { result, discoveredSources, truncated, assessments } = await brainstormIdeas(input, { + config, + language: outputLanguage, + debateContext, + signal: controller.signal, + live: liveRun.begin(), + }); if (!isCurrent()) return; const assessed = await keepSourceAssessments(forUser, debateId, assessments, controller.signal); if (!isCurrent()) return; @@ -145,6 +154,7 @@ export default function BrainstormScreen() { if (!(cause instanceof AIError)) console.warn('Brainstorm failed:', cause instanceof Error ? cause.message : cause); // A missing debate was deleted elsewhere; reload the list so the app stops using it. if (kind === 'debateMissing') void refresh(); + liveRun.end(); setError(kind); setPhase('form'); } finally { @@ -206,7 +216,14 @@ export default function BrainstormScreen() { {working ? ( - t(key))} detail={form.motion} onCancel={cancel} /> + ) : ( <> String(index + 3) // The last form stays for the session, as on the website; session memory forgets it when the account changes. const lastForm = sessionSlot(() => DEFAULT_FORM); -// The website's one progress line while the tool runs. -const PROGRESS = ['factoids.working']; /** * Factoids, as the website's FactoidsPage. Factoids research a claim, not @@ -66,11 +66,14 @@ export default function FactoidsScreen() { return next; }), []); + const liveRun = useLiveRun(); + const endLiveRun = liveRun.end; const abortActive = useCallback(() => { requests.current += 1; abort.current?.abort(); abort.current = null; - }, []); + endLiveRun(); + }, [endLiveRun]); useEffect(() => () => { live.current = false; @@ -109,7 +112,13 @@ export default function FactoidsScreen() { debateId ? buildDebateContext(forUser, debateId, 'factoids', input.direction === 'against' ? 'con' : 'pro') : Promise.resolve(undefined), ]); if (!isCurrent()) return; - const { result, discoveredSources, truncated, assessments } = await findFactoids(input, { config, language: outputLanguage, debateContext, signal: controller.signal }); + const { result, discoveredSources, truncated, assessments } = await findFactoids(input, { + config, + language: outputLanguage, + debateContext, + signal: controller.signal, + live: liveRun.begin(), + }); if (!isCurrent()) return; const assessed = await keepSourceAssessments(forUser, debateId, assessments, controller.signal); if (!isCurrent()) return; @@ -152,6 +161,7 @@ export default function FactoidsScreen() { const kind = cause instanceof AIError ? cause.kind : 'failed'; if (!(cause instanceof AIError)) console.warn('Factoids failed:', cause instanceof Error ? cause.message : cause); if (kind === 'debateMissing') void refresh(); + liveRun.end(); setError(kind); setPhase('form'); } finally { @@ -185,7 +195,14 @@ export default function FactoidsScreen() { {working ? ( - t(key))} detail={form.claim} onCancel={cancel} /> + ) : ( <> (() => DEFAULT_FORM); -// The website's one progress line while the case is written. -const PROGRESS = ['generate.working']; - /** Generate, as the website's GeneratePage: a native form, a calm wait, then the case as a document. */ export default function GenerateScreen() { const { t } = useTranslation(); @@ -58,6 +57,7 @@ export default function GenerateScreen() { const requests = useRef(0); const abort = useRef(null); const live = useRef(true); + const liveRun = useLiveRun(); const setForm = useCallback((patch: Partial) => setFormState((previous) => { @@ -66,11 +66,13 @@ export default function GenerateScreen() { return next; }), []); + const endLiveRun = liveRun.end; const abortActive = useCallback(() => { requests.current += 1; abort.current?.abort(); abort.current = null; - }, []); + endLiveRun(); + }, [endLiveRun]); // Leaving cancels a case still on its way, and nothing is saved. useEffect(() => () => { @@ -98,7 +100,13 @@ export default function GenerateScreen() { debateId ? buildDebateContext(forUser, debateId, 'generate', input.side) : Promise.resolve(undefined), ]); if (!isCurrent()) return; - const { result, discoveredSources, truncated, assessments } = await generateCase(input, { config, language: outputLanguage, debateContext, signal: controller.signal }); + const { result, discoveredSources, truncated, assessments } = await generateCase(input, { + config, + language: outputLanguage, + debateContext, + signal: controller.signal, + live: liveRun.begin(), + }); if (!isCurrent()) return; const assessed = await keepSourceAssessments(forUser, debateId, assessments, controller.signal); if (!isCurrent()) return; @@ -137,6 +145,7 @@ export default function GenerateScreen() { if (!(cause instanceof AIError)) console.warn('Generate failed:', cause instanceof Error ? cause.message : cause); // A missing debate was deleted elsewhere; reload the list so the app stops using it. if (kind === 'debateMissing') void refresh(); + liveRun.end(); setError(kind); setPhase('form'); } finally { @@ -198,7 +207,14 @@ export default function GenerateScreen() { {generating ? ( - t(key))} detail={form.motion} onCancel={cancel} /> + ) : ( <> ; +} + +/** One screen's live run: `begin` hands the request its updates; a later `begin` or `end` silences them. */ +export function useLiveRun() { + const [state, setState] = useState({}); + const run = useRef(0); + const live = useRef(true); + useEffect(() => () => { + live.current = false; + }, []); + + const begin = useCallback((): LiveGeneration => { + const id = ++run.current; + setState({}); + const current = () => live.current && run.current === id; + return { + onDraft: (draft) => { + if (current()) setState((previous) => ({ ...previous, phase: 'writing', draft })); + }, + onPhase: (phase) => { + if (current()) setState((previous) => ({ ...previous, phase })); + }, + }; + }, []); + + const end = useCallback(() => { + run.current += 1; + if (live.current) setState({}); + }, []); + + return { state, begin, end }; +} + +/** Whole seconds since the run started, as the website's useElapsedSeconds. */ +export function useElapsedSeconds() { + const [elapsed, setElapsed] = useState(0); + useEffect(() => { + const startedAt = Date.now(); + const timer = setInterval(() => setElapsed(Math.floor((Date.now() - startedAt) / 1000)), 1000); + return () => clearInterval(timer); + }, []); + return elapsed; +} + +/** The status line for a phase that writes nothing yet, or the tool's own label. */ +function usePhaseLabel(phase: LivePhase | undefined, fallback: string): string { + const { t } = useTranslation(); + if (phase === 'research') return t('tools.live.researching'); + if (phase === 'checking') return t('tools.live.checking'); + if (phase === 'writing') return t('tools.live.writing'); + return fallback; +} + +/** A blinking bar where the model is writing; steady under Reduce Motion, as on the website. */ +function Caret() { + const { colors } = useTheme(); + const opacity = useRef(new Animated.Value(1)).current; + const reduceMotion = useReduceMotion(); + useEffect(() => { + if (reduceMotion) { + opacity.setValue(1); + return; + } + const loop = Animated.loop( + Animated.sequence([ + Animated.timing(opacity, { toValue: 0.25, duration: 500, useNativeDriver: true }), + Animated.timing(opacity, { toValue: 1, duration: 500, useNativeDriver: true }), + ]), + ); + loop.start(); + return () => loop.stop(); + }, [opacity, reduceMotion]); + return ; +} + +function Block({ block, writing }: { block: DraftBlock; writing: boolean }) { + const { colors } = useTheme(); + if (block.heading) { + return ( + + + {block.heading} + + {writing ? : null} + + ); + } + return ( + + {/* Result labels read "Reasoning:" inline; as a heading the colon goes. */} + {block.label ? ( + + {block.label.replace(/[::]\s*$/, '').toLocaleUpperCase()} + + ) : null} + {block.items ? ( + block.items.map((item, index) => ( + + + • + + + {item} + {writing && index === block.items!.length - 1 ? : null} + + + )) + ) : ( + + {block.text} + {writing ? : null} + + )} + + ); +} + +/** + * Before any text: the tool's owl, the phase or the tool's own line, the + * elapsed time and Cancel. Then a strip that says it is writing live, and + * one card per section written so far. + */ +export function LiveToolRun({ + label, + run, + sections, + detail, + onCancel, + pose, +}: { + label: string; + run: LiveRunState; + sections: DraftSection[]; + detail?: string; + onCancel: () => void; + /** The tool's own owl before the answer starts; the phase picks it otherwise. */ + pose?: OwlPose; +}) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const status = usePhaseLabel(run.phase, label); + const elapsed = useElapsedSeconds(); + // The owl looks things up while researching or checking sources, as on the website. + if (sections.length === 0) { + const looking = run.phase === 'research' || run.phase === 'checking'; + return ; + } + + const writing = run.phase === 'writing'; + const lastSection = sections.length - 1; + const lastBlock = sections[lastSection].blocks.length - 1; + return ( + + + + + + {writing ? t('tools.live.writingLive') : status} + + {elapsed >= 5 ? ( + + {t('tools.elapsed', { seconds: elapsed })} + + ) : null} + + + + {sections.map((section, sectionIndex) => ( + + + {section.title} + + {section.blocks.map((block, blockIndex) => ( + + ))} + + ))} + + ); +} + +const styles = StyleSheet.create({ + root: { gap: 14, marginTop: 4 }, + strip: { flexDirection: 'row', alignItems: 'center', gap: 12, borderWidth: 2, borderRadius: radius.field, paddingHorizontal: 12, paddingVertical: 9 }, + stripText: { flex: 1, flexDirection: 'row', flexWrap: 'wrap', alignItems: 'baseline', columnGap: 8 }, + stripStatus: { fontSize: 15, lineHeight: 20 }, + elapsed: { fontSize: 14, fontVariant: ['tabular-nums'] }, + section: { borderWidth: 2, borderRadius: radius.card, padding: 16, gap: 10 }, + sectionTitle: { fontSize: 18, lineHeight: 23 }, + block: { gap: 4 }, + label: { fontSize: 12, letterSpacing: 0.6 }, + heading: { fontSize: 15.5, lineHeight: 22, flexShrink: 1 }, + body: { fontSize: 15, lineHeight: 22, flexShrink: 1 }, + inline: { flexDirection: 'row', alignItems: 'flex-end', flexWrap: 'wrap' }, + bullet: { flexDirection: 'row', gap: 8 }, + marker: { fontSize: 15, lineHeight: 22 }, + bulletText: { flex: 1 }, + caret: { width: 3, height: 17, borderRadius: 2, marginLeft: 2, marginBottom: 3 }, +}); diff --git a/apps/mobile/src/components/owl.tsx b/apps/mobile/src/components/owl.tsx index 611e675e5..126ce027d 100644 --- a/apps/mobile/src/components/owl.tsx +++ b/apps/mobile/src/components/owl.tsx @@ -104,7 +104,7 @@ export function OwlMark({ size }: { size: number }) { } /** Whether iOS's Reduce Motion is on, kept current while the screen shows. */ -function useReduceMotion(): boolean { +export function useReduceMotion(): boolean { const [reduce, setReduce] = useState(false); useEffect(() => { let active = true; diff --git a/apps/mobile/src/components/tool-form.tsx b/apps/mobile/src/components/tool-form.tsx index 97ea80b1e..6d020286c 100644 --- a/apps/mobile/src/components/tool-form.tsx +++ b/apps/mobile/src/components/tool-form.tsx @@ -77,9 +77,21 @@ export function ToolError({ title, error, onRetry }: { title: string; error: AIE ); } -/** A calm progress state that names what is happening, cycling the website's messages. */ -/** A tool at work: the owl in the tool's pose, the website's progress line, the request's subject and Cancel. */ -export function ToolProgress({ messages, detail, onCancel, pose = 'thinking' }: { messages: string[]; detail?: string; onCancel: () => void; pose?: OwlPose }) { +/** A tool at work: the owl in the tool's pose, the website's progress line, the elapsed time once it matters, the request's subject and Cancel. */ +export function ToolProgress({ + messages, + detail, + elapsed, + onCancel, + pose = 'thinking', +}: { + messages: string[]; + detail?: string; + /** Seconds since the request started; shown from 5, as the website's ToolRunStatus. */ + elapsed?: number; + onCancel: () => void; + pose?: OwlPose; +}) { const { t } = useTranslation(); const { colors } = useTheme(); const [index, setIndex] = useState(0); @@ -93,6 +105,11 @@ export function ToolProgress({ messages, detail, onCancel, pose = 'thinking' }: {messages[index]} + {elapsed !== undefined && elapsed >= 5 ? ( + + {t('tools.elapsed', { seconds: elapsed })} + + ) : null} {detail ? ( {detail} @@ -115,5 +132,6 @@ const styles = StyleSheet.create({ errorActions: { flexDirection: 'row', gap: 20 }, progress: { borderWidth: 2, borderStyle: 'dashed', borderRadius: radius.card + 2, paddingVertical: 36, paddingHorizontal: 20, alignItems: 'center', gap: 12, marginTop: 8 }, progressText: { fontSize: 18, lineHeight: 24, textAlign: 'center' }, + progressElapsed: { fontSize: 14, marginTop: -6, fontVariant: ['tabular-nums'] }, progressDetail: { fontSize: 15, lineHeight: 21, textAlign: 'center' }, }); diff --git a/apps/mobile/src/lib/ai-request.test.ts b/apps/mobile/src/lib/ai-request.test.ts new file mode 100644 index 000000000..310e363dd --- /dev/null +++ b/apps/mobile/src/lib/ai-request.test.ts @@ -0,0 +1,88 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +// A live tool request: the answer streams from the ai function, a refusal +// inside the stream keeps its limit details, and a stream that drops before +// its answer is read back from ai_run_results under the request's own id. + +const { fetchMock, runRows } = vi.hoisted(() => ({ + fetchMock: vi.fn(), + /** What each read of ai_run_results returns. */ + runRows: [] as Array | null>, +})); + +vi.mock('expo/fetch', () => ({ fetch: fetchMock })); +vi.mock('./supabase', () => ({ + supabase: { + auth: { getSession: async () => ({ data: { session: { access_token: 'token' } } }) }, + from: () => { + const chain = { + select: () => chain, + eq: () => chain, + maybeSingle: async () => ({ data: (runRows.length > 1 ? runRows.shift() : runRows[0]) ?? null, error: null }), + }; + return chain; + }, + }, +})); + +import { AIError, postAI } from './ai-request'; + +const event = (name: string, data: unknown) => `event: ${name}\ndata: ${JSON.stringify(data)}\n\n`; + +function streamed(chunks: string[]) { + const encoder = new TextEncoder(); + return new Response(new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(encoder.encode(chunk)); + controller.close(); + }, + }), { status: 200, headers: { 'Content-Type': 'text/event-stream' } }); +} + +const sentBody = () => JSON.parse((fetchMock.mock.calls[0] as [string, { body: string }])[1].body) as Record; + +afterEach(() => { + fetchMock.mockReset(); + runRows.length = 0; +}); + +describe('postAI', () => { + it('streams the answer, reporting the draft, and parses the result', async () => { + fetchMock.mockResolvedValue(streamed([ + event('phase', { phase: 'writing' }) + event('delta', { text: '{"thesis":"Free' }), + event('delta', { text: ' transit"}' }) + event('result', { status: 200, body: { output: { thesis: 'Free transit' } } }), + ])); + const phases: string[] = []; + const answer = await postAI({ feature: 'generate' }, { live: { onDraft: () => {}, onPhase: (phase) => phases.push(phase) } }, ({ output }) => output); + expect(answer).toEqual({ thesis: 'Free transit' }); + expect(phases).toEqual(['writing']); + expect(sentBody()).toMatchObject({ feature: 'generate', stream: true, resultKey: expect.stringMatching(/^req_\d+_[a-z0-9]+$/) }); + }); + + it('turns a refusal inside the stream into the limit error, with its details', async () => { + fetchMock.mockResolvedValue(streamed([ + event('result', { status: 402, body: { error: 'Daily limit reached', code: 'limit_reached', limit: 20, current: 20, units: 2 } }), + ])); + const error = await postAI({ feature: 'generate' }, { live: { onDraft: () => {} } }, ({ output }) => output).catch((cause: unknown) => cause); + expect(error).toBeInstanceOf(AIError); + expect(error).toMatchObject({ kind: 'limitReached', details: { code: 'limit_reached', limit: 20, current: 20, units: 2 } }); + }); + + it('reads back the stored answer when the stream ends before its result', async () => { + // The server stored the answer under the id this request sent. + fetchMock.mockImplementation(async (_url: string, init: { body: string }) => { + const { resultKey } = JSON.parse(init.body) as { resultKey: string }; + runRows.push({ result_key: resultKey, status: 200, body: { output: { thesis: 'Recovered' } } }); + return streamed([event('phase', { phase: 'writing' }) + event('delta', { text: '{"thes' })]); + }); + const answer = await postAI({ feature: 'attack' }, { live: { onDraft: () => {} } }, ({ output }) => output); + expect(answer).toEqual({ thesis: 'Recovered' }); + }); + + it('fails as a network error when no stored answer belongs to the dropped run', async () => { + fetchMock.mockResolvedValue(streamed([event('delta', { text: '{' })])); + runRows.push({ result_key: 'someone-elses-run', status: 200, body: { output: {} } }); + const error = await postAI({ feature: 'factoids' }, { live: { onDraft: () => {} } }, ({ output }) => output).catch((cause: unknown) => cause); + expect(error).toMatchObject({ kind: 'network' }); + }); +}); diff --git a/apps/mobile/src/lib/ai-request.ts b/apps/mobile/src/lib/ai-request.ts index ee9674304..9af7ebd77 100644 --- a/apps/mobile/src/lib/ai-request.ts +++ b/apps/mobile/src/lib/ai-request.ts @@ -1,3 +1,5 @@ +import { readLiveStream, type LiveGeneration } from '@debatelab/core/ai/live'; +import { recoverRunResult } from '@debatelab/core/ai/run-recovery'; import { parseSourceAssessments, type SourceAssessmentUpdate } from '@debatelab/core/ai/source-assessments'; import { fetch } from 'expo/fetch'; @@ -26,8 +28,17 @@ export type AIErrorKind = | 'debateMissing' | 'failed'; +/** What the server said about a refusal besides its message, as the website's AISubscriptionError keeps it. */ +export interface AIErrorDetails { + code?: string; + limit?: number; + current?: number; + /** The units the refused request would have cost. */ + units?: number; +} + export class AIError extends Error { - constructor(readonly kind: AIErrorKind, message: string) { + constructor(readonly kind: AIErrorKind, message: string, readonly details: AIErrorDetails = {}) { super(message); this.name = 'AIError'; } @@ -76,10 +87,12 @@ async function accessToken(): Promise { export function errorFrom(status: number | undefined, payload: Record | null, fallback: string): AIError { const message = typeof payload?.error === 'string' ? payload.error : typeof payload?.message === 'string' ? payload.message : fallback; const code = typeof payload?.code === 'string' ? payload.code : undefined; + const number = (key: string) => (typeof payload?.[key] === 'number' ? (payload[key] as number) : undefined); + const details: AIErrorDetails = { code, limit: number('limit'), current: number('current'), units: number('units') }; if (status === 401) return new AIError('auth', message); - if (code === 'upgrade_required') return new AIError('modelRestricted', message); + if (code === 'upgrade_required') return new AIError('modelRestricted', message, details); if (status === 402 || code === 'trial_expired' || code === 'limit_reached') { - return new AIError(code === 'trial_expired' ? 'trialExpired' : 'limitReached', message); + return new AIError(code === 'trial_expired' ? 'trialExpired' : 'limitReached', message, details); } if (status === 403) return /model|upgrade/i.test(message) ? new AIError('modelRestricted', message) : new AIError('auth', message); if (status === 404) return new AIError('debateMissing', message); @@ -99,7 +112,7 @@ export async function postFunction( path: string, body: Record, signal: AbortSignal | undefined, - run: (response: FunctionResponse) => Promise, + run: (response: FunctionResponse, signal: AbortSignal) => Promise, timeoutMs = REQUEST_TIMEOUT_MS, ): Promise { // The deadline and the caller's abort start before the session read, so @@ -125,7 +138,7 @@ export async function postFunction( throw new AIError('network', cause instanceof Error ? cause.message : 'Network request failed'); }); if (!response.ok) throw errorFrom(response.status, await payloadOf(response), `Request failed (${response.status})`); - return await run(response); + return await run(response, controller.signal); } catch (error) { if (signal?.aborted) throw abortError(); if (controller.signal.aborted) throw new AIError('timeout', 'The request took too long'); @@ -141,12 +154,67 @@ export async function postFunction( * still carry an error, as the website's callSupabaseAI handles. */ export async function functionOutput(response: FunctionResponse): Promise<{ output: unknown; data: Record }> { - const data = await payloadOf(response); + return outputOf(await payloadOf(response)); +} + +function outputOf(data: Record | null): { output: unknown; data: Record } { if (typeof data?.error === 'string') throw errorFrom(undefined, data, data.error); if (!data?.output) throw new AIError('failed', 'The answer was missing'); return { output: data.output, data }; } +function recordOf(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : null; +} + +const readRunResult = (feature: string) => + supabase.from('ai_run_results').select('result_key,status,body').eq('feature', feature).maybeSingle(); + +/** + * One request to the ai function, read as the website's callSupabaseAI reads + * it. With `live`, the answer streams (`stream: true`) and `live` sees the + * model's text as it is written; a stream that drops before its answer is + * recovered from ai_run_results under the request's own id, as the website + * does. Either way `parse` gets the finished answer's `output` and payload. + */ +export function postAI( + body: Record, + options: { signal?: AbortSignal; live?: LiveGeneration }, + parse: (answer: { output: unknown; data: Record }) => T | Promise, +): Promise { + const { live } = options; + if (!live) return postFunction('ai', body, options.signal, async (response) => parse(await functionOutput(response))); + const startedAt = Date.now(); + const resultKey = `req_${startedAt}_${Math.random().toString(36).slice(2, 8)}`; + const feature = String(body.feature); + return postFunction('ai', { ...body, stream: true, resultKey }, options.signal, async (response, signal) => { + // A server from before live generation answers with the whole JSON body. + if (!(response.headers.get('content-type') ?? '').includes('text/event-stream') || !response.body) { + return parse(await functionOutput(response)); + } + const outcome = await readLiveStream(response.body.getReader(), live, signal); + let status: number; + let payload: unknown; + if (outcome.kind === 'result') { + status = outcome.status; + payload = outcome.body; + } else { + // The run keeps going and stores its answer before sending it, so it may be waiting. + // Offline, the read fails at once and the request fails as a network error. + const recovered = await recoverRunResult(readRunResult, feature, resultKey, startedAt, signal, outcome.error.reason === 'stalled' ? Infinity : undefined); + if (!recovered) { + throw outcome.error.reason === 'unreadable' + ? new AIError('failed', 'The streamed answer could not be read') + : new AIError('network', outcome.error.message); + } + status = recovered.status; + payload = recovered.body; + } + if (status < 200 || status >= 300) throw errorFrom(status, recordOf(payload), `Request failed (${status})`); + return parse(outputOf(recordOf(payload))); + }); +} + /** What a whole answer says besides its output, which the website acts on for every feature (callSupabaseAI). */ export interface ResponseExtras { /** The server shortened the input to fit; the website tells the student. */ diff --git a/apps/mobile/src/lib/results.ts b/apps/mobile/src/lib/results.ts index b3e46059e..8cf2c6012 100644 --- a/apps/mobile/src/lib/results.ts +++ b/apps/mobile/src/lib/results.ts @@ -6,6 +6,7 @@ import { type ResultWithSources, } from '@debatelab/core/ai/discovered-sources'; import { transformBrainstormResponse } from '@debatelab/core/ai/brainstorm-output'; +import type { LiveGeneration } from '@debatelab/core/ai/live'; import { factoidsRequestInput, selectVerifiedFactoids } from '@debatelab/core/ai/factoids-output'; import { expectedContentionCount, transformGenerateResponse } from '@debatelab/core/ai/generate-output'; import { AttackOutputSchema, type AttackInput, type AttackOutput } from '@debatelab/core/ai/schemas/attack'; @@ -15,7 +16,7 @@ import { GenerateOutputSchema, type GenerateInput, type GenerateOutput } from '@ import type { SourceAssessmentUpdate } from '@debatelab/core/ai/source-assessments'; import type { DebateContextRequest } from '@debatelab/core/contract/debate-context'; -import { AIError, functionOutput, postFunction, PROVIDER, responseExtras, type ResponseExtras } from './ai-request'; +import { AIError, postAI, PROVIDER, responseExtras, type ResponseExtras } from './ai-request'; import type { FeatureConfig } from './feature-config'; import { debateListScopes, deletedListScopes } from './debates'; import { afterWrite, cachedRead, readFailed, type CopyMark } from './offline-cache'; @@ -61,6 +62,8 @@ interface RequestOptions { /** Absent for one-off work. */ debateContext?: DebateContextRequest; signal?: AbortSignal; + /** Stream the answer, as the website's tools do, and show it as it is written. */ + live?: LiveGeneration; } function requestBody(feature: Feature, input: Record, options: RequestOptions) { @@ -78,8 +81,7 @@ function requestBody(feature: Feature, input: Record, options: /** A generated case, checked like the website checks it and stamped with the local time and motion. */ export function generateCase(input: GenerateInput, options: RequestOptions): Promise & ResponseExtras> { - return postFunction('ai', requestBody('generate', input, options), options.signal, async (response) => { - const { output, data } = await functionOutput(response); + return postAI(requestBody('generate', input, options), options, ({ output, data }) => { const parsed = GenerateOutputSchema.safeParse(transformGenerateResponse(output as Record)); if (!parsed.success) throw new AIError('failed', 'AI response did not match the expected generate schema.'); const result = parsed.data; @@ -93,8 +95,7 @@ export function generateCase(input: GenerateInput, options: RequestOptions): Pro } export function attackCase(input: AttackInput, options: RequestOptions): Promise & ResponseExtras> { - return postFunction('ai', requestBody('attack', input, options), options.signal, async (response) => { - const { output, data } = await functionOutput(response); + return postAI(requestBody('attack', input, options), options, ({ output, data }) => { const parsed = AttackOutputSchema.safeParse(output); if (!parsed.success) throw new AIError('failed', 'AI response did not match the expected attack schema.'); return { result: parsed.data, discoveredSources: parseDiscoveredSources(data.discoveredSources), ...responseExtras(data) }; @@ -102,8 +103,7 @@ export function attackCase(input: AttackInput, options: RequestOptions): Promise } export function brainstormIdeas(input: BrainstormInput, options: RequestOptions): Promise & ResponseExtras> { - return postFunction('ai', requestBody('brainstorm', input, options), options.signal, async (response) => { - const { output, data } = await functionOutput(response); + return postAI(requestBody('brainstorm', input, options), options, ({ output, data }) => { const result = readBrainstorm(output); if (!result) throw new AIError('failed', 'AI response did not match the expected brainstorm schema.'); return { @@ -116,8 +116,7 @@ export function brainstormIdeas(input: BrainstormInput, options: RequestOptions) /** Factoids, as the website asks for them: verified ones only, up to the number of sources chosen. */ export function findFactoids(input: FactoidsInput, options: RequestOptions): Promise & ResponseExtras> { - return postFunction('ai', requestBody('factoids', factoidsRequestInput(input), options), options.signal, async (response) => { - const { output, data } = await functionOutput(response); + return postAI(requestBody('factoids', factoidsRequestInput(input), options), options, ({ output, data }) => { const parsed = FactoidsOutputSchema.safeParse(output); if (!parsed.success) throw new AIError('failed', 'AI response did not match the expected factoids schema.'); return { From 9f93eed9e18144e96c1880857508e30b53f1c157 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 28 Oct 2026 12:10:11 +0000 Subject: [PATCH 03/14] Open a case or an attack in Crossfire, and build a case into a map The case and attack documents gain the website's "Open in Crossfire", which fills the next Crossfire form with the case, or with the attacked case and its cross-examination questions, and says where it came from. A case can be built into an unsaved Flowchart map in its own debate. flowchartFromCase moves to packages/core so the website and the app build the same map. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/app/attack/[id].tsx | 7 ++ apps/mobile/src/app/case/[id].tsx | 15 +++ apps/mobile/src/app/crossfire.tsx | 57 +++++++-- apps/mobile/src/app/flowchart/index.tsx | 8 +- apps/mobile/src/lib/crossfire.ts | 7 ++ apps/mobile/src/lib/result-links.ts | 40 +++++++ packages/core/src/flowchart.ts | 148 ++++++++++++++++++++++++ src/lib/flowchart.ts | 105 +---------------- 8 files changed, 279 insertions(+), 108 deletions(-) create mode 100644 apps/mobile/src/lib/result-links.ts diff --git a/apps/mobile/src/app/attack/[id].tsx b/apps/mobile/src/app/attack/[id].tsx index 038db2f7f..dac6b51bc 100644 --- a/apps/mobile/src/app/attack/[id].tsx +++ b/apps/mobile/src/app/attack/[id].tsx @@ -12,6 +12,7 @@ import { backLabel } from '@/lib/back-label'; import { formatWhen } from '@/lib/dates'; import { copyText, sharePdf, shareText } from '@/lib/export'; import { attackExportFileName, attackExportHtml } from '@/lib/export-html'; +import { crossfireFromAttack } from '@/lib/result-links'; import { attackToText, discussAttackMessage } from '@/lib/result-text'; import { isCompleteAttack } from '@/lib/results'; import { arrivalNoticeText, useResultDocument } from '@/lib/use-result-document'; @@ -59,7 +60,13 @@ export default function AttackResultScreen() { }; const discuss = () => router.push({ pathname: '/ask', params: { prompt: discussAttackMessage(output) } }); + const openCrossfire = () => { + crossfireFromAttack(typeof result.input.caseText === 'string' ? result.input.caseText : '', output); + router.push({ pathname: '/crossfire', params: { from: 'attack' } }); + }; + const actions: HeaderAction[] = [ + { key: 'crossfire', label: t('crossfire.openFromResult'), symbol: 'person.2.wave.2', icon: 'swords', onPress: openCrossfire }, ...(doc.autoSaved.length > 0 ? [{ key: 'undo-sources', label: t('autoSavedSources.undo'), symbol: 'arrow.uturn.backward' as const, icon: 'history' as const, onPress: () => void doc.undoAutoSaved() }] : []), diff --git a/apps/mobile/src/app/case/[id].tsx b/apps/mobile/src/app/case/[id].tsx index 805e979b3..0eb9bbc80 100644 --- a/apps/mobile/src/app/case/[id].tsx +++ b/apps/mobile/src/app/case/[id].tsx @@ -10,10 +10,12 @@ import { DocumentScreen, DocumentTitle } from '@/components/document'; import { DocumentHeader, type HeaderAction } from '@/components/document-header'; import { DocumentStatus } from '@/components/document-status'; import { backLabel } from '@/lib/back-label'; +import { useDebates } from '@/lib/debate-context'; import { formatWhen } from '@/lib/dates'; import { copyText, sharePdf, shareText } from '@/lib/export'; import { caseExportFileName, caseExportHtml } from '@/lib/export-html'; import { caseToText, discussCaseMessage } from '@/lib/result-text'; +import { buildFlowchartFromCase, crossfireFromCase } from '@/lib/result-links'; import { isCompleteCase } from '@/lib/results'; import { arrivalNoticeText, useResultDocument } from '@/lib/use-result-document'; @@ -23,6 +25,7 @@ export default function CaseScreen() { const router = useRouter(); const { id, from, notice: arrival, failed } = useLocalSearchParams<{ id: string; from?: string; notice?: string; failed?: string }>(); const doc = useResultDocument('case', id); + const { activeDebateId } = useDebates(); const [exportOpen, setExportOpen] = useState(false); const { setNotice } = doc; @@ -59,7 +62,19 @@ export default function CaseScreen() { }); }; + const openCrossfire = () => { + crossfireFromCase(typeof result.input.motion === 'string' ? result.input.motion : output.metadata?.motion ?? '', output); + router.push({ pathname: '/crossfire', params: { from: 'case' } }); + }; + const buildFlowchart = () => { + const outcome = buildFlowchartFromCase(output, result.debateId, activeDebateId, t); + if (outcome === 'built') router.push({ pathname: '/flowchart', params: { from: 'case', built: '1' } }); + else setNotice(`${t('generate.flowchartImportFailed')}. ${t(outcome === 'noData' ? 'generate.flowchartNoData' : 'generate.flowchartOtherDebate')}`); + }; + const actions: HeaderAction[] = [ + { key: 'crossfire', label: t('crossfire.openFromResult'), symbol: 'person.2.wave.2', icon: 'swords', onPress: openCrossfire }, + { key: 'flowchart', label: t('mobile.results.buildFlowchart'), symbol: 'point.3.connected.trianglepath.dotted', icon: 'branch', onPress: buildFlowchart }, { key: 'discuss', label: t('common.discuss'), symbol: 'bubble.left.and.bubble.right', icon: 'chat', onPress: () => router.push({ pathname: '/ask', params: { prompt: discussCaseMessage(output) } }) }, ...(result.savedId ? [] : [{ key: 'save', label: t('common.save'), symbol: 'square.and.arrow.down' as const, icon: 'download' as const, onPress: () => void doc.save() }]), ...(doc.autoSaved.length > 0 diff --git a/apps/mobile/src/app/crossfire.tsx b/apps/mobile/src/app/crossfire.tsx index 38947dccd..9eb602226 100644 --- a/apps/mobile/src/app/crossfire.tsx +++ b/apps/mobile/src/app/crossfire.tsx @@ -4,6 +4,7 @@ import { MAX_CROSSFIRE_ARGUMENT_CHARS, MAX_CROSSFIRE_RESOLUTION_CHARS, MAX_CROSSFIRE_TURN_CHARS, + type CrossfirePrefill, type CrossfireState, type CrossfireTurn, } from '@debatelab/core/crossfire/schemas'; @@ -20,6 +21,7 @@ import { DocumentHeader } from '@/components/document-header'; import { Field } from '@/components/field'; import { FormGroup, SwitchRow } from '@/components/form-rows'; import { Icon } from '@/components/icon'; +import { SessionCostNote } from '@/components/session-cost-note'; import { LiveTopBar, RoundIconButton } from '@/components/round-controls'; import { SegmentedControl } from '@/components/segmented-control'; import { Text } from '@/components/text'; @@ -34,6 +36,7 @@ import { crossfireContext, crossfireDebrief, crossfireDraft, + crossfirePrefill, CrossfireResponseError, crossfireSignal, crossfireTurn, @@ -42,6 +45,8 @@ import { } from '@/lib/crossfire'; import { useDebates } from '@/lib/debate-context'; import { fetchFeatureConfig } from '@/lib/feature-config'; +import { openRefusal } from '@/lib/refusal'; +import { sessionQuotaRefusal, useSessionQuota } from '@/lib/session-quota'; import { decideMotionTarget, shouldPrefillMotion } from '@/lib/motion'; import { chooseMotionTarget } from '@/lib/motion-gate'; import { keepSourceAssessments } from '@/lib/results'; @@ -57,8 +62,11 @@ type ErrorKind = 'timeout' | 'response' | 'request'; const PLAN_MESSAGES = new Set(['mobile.results.errors.trialExpired', 'mobile.results.errors.limitReached', 'mobile.results.errors.modelRestricted']); -/** The round left behind, as the website's restoreState: a request that was cut off becomes one to retry. */ -function restoredState(): CrossfireState { +/** + * The round left behind, as the website's restoreState: a request that was cut off becomes one to + * retry. A prefill from a case or an attack starts the form unless a round is still under way. + */ +function restoredState(prefill: CrossfirePrefill | null): CrossfireState { const draft = crossfireDraft.get()?.state; if (draft?.phase === 'active' && draft.request.status === 'pending') { return { ...draft, request: { status: 'failed', expectedReply: draft.request.expectedReply, errorKind: 'request', message: 'crossfire.interruptedRequest' } }; @@ -68,9 +76,9 @@ function restoredState(): CrossfireState { } if (draft?.phase === 'active' || draft?.phase === 'debriefing') return draft; // A form keeps its texts and goes back to the website's defaults for the rest. - return createCrossfireSetupState(draft?.phase === 'setup' + return createCrossfireSetupState(prefill ?? (draft?.phase === 'setup' ? { resolution: draft.config.resolution, argument: draft.config.argument, seedQuestions: draft.config.seedQuestions } - : undefined); + : undefined)); } /** Errors are stored as translation keys, so a restored round still reads in the current language. */ @@ -102,7 +110,14 @@ export default function CrossfireScreen() { const { debates, current, oneOff, create, switchTo, refresh } = useDebates(); const { outputLanguage } = useSettings(); const { colors } = useTheme(); - const [state, dispatch] = useReducer(crossfireReducer, undefined, restoredState); + // Taken once, so coming back later does not import the same case again. + const [imported] = useState(() => { + const prefill = crossfirePrefill.get(); + crossfirePrefill.set(null); + return prefill; + }); + const [state, dispatch] = useReducer(crossfireReducer, imported, restoredState); + const roundQuota = useSessionQuota('crossfire'); // The debate chosen when the round started; null for one-off rounds. const debateTarget = useRef(crossfireDraft.get()?.debateId ?? null); const [draftTurn, setDraftTurn] = useState(''); @@ -242,8 +257,10 @@ export default function CrossfireScreen() { if (cause instanceof AIError && cause.kind === 'debateMissing') void refresh(); const { kind, message } = classify(cause, false); dispatch({ type: 'request-failed', expectedReply, errorKind: kind, message }); + // Crossfire runs on Ask's model settings, so a model refusal switches Ask's. + openRefusal(router, cause, 'ask'); } - }, [afterResponse, outputLanguage, refresh, requestContext]); + }, [afterResponse, outputLanguage, refresh, requestContext, router]); const runDebrief = useCallback(async (snapshot: Debriefing, forUser: string) => { requestRef.current?.abort(); @@ -269,8 +286,9 @@ export default function CrossfireScreen() { if (cause instanceof AIError && cause.kind === 'debateMissing') void refresh(); const { kind, message } = classify(cause, true); dispatch({ type: 'debrief-failed', errorKind: kind, message }); + openRefusal(router, cause, 'ask'); } - }, [afterResponse, outputLanguage, refresh, requestContext]); + }, [afterResponse, outputLanguage, refresh, requestContext, router]); // A round that finishes here is recorded in its debate once, from its latest state, as CrossfirePage does; one-off rounds are not saved. useEffect(() => { @@ -320,6 +338,13 @@ export default function CrossfireScreen() { } setFormError(''); const forUser = userId; + // A round the day's uses cannot cover opens the limit sheet before its clock starts, as on the website. + const refusal = sessionQuotaRefusal(await roundQuota.check()); + if (!live.current) return; + if (refusal) { + openRefusal(router, refusal, 'ask'); + return; + } const motion = parsed.data.resolution; // Where the round belongs is settled before it starts, as the website's motion gate does. const decision = decideMotionTarget(motion, { debates, current, oneOff }); @@ -400,6 +425,19 @@ export default function CrossfireScreen() { actions={ void startSession()} />}> + {imported?.source ? ( + + + + + {imported.source === 'generate' ? t('crossfire.importFromGenerate') : t('crossfire.importFromAttack')} + + + {imported.source === 'generate' ? t('crossfire.importGenerateDesc') : t('crossfire.importAttackDesc')} + + + + ) : null} + {formError ? ( {formError} @@ -672,6 +711,10 @@ const styles = StyleSheet.create({ argument: { minHeight: 150, paddingTop: 13, paddingBottom: 13, textAlignVertical: 'top' }, duration: { gap: 7 }, groupLabel: { fontSize: 14, marginLeft: 4 }, + imported: { flexDirection: 'row', gap: 10, borderWidth: 2, borderRadius: radius.field, paddingHorizontal: 14, paddingVertical: 11 }, + importedText: { flex: 1, gap: 2 }, + importedTitle: { fontSize: 15.5, lineHeight: 21 }, + importedBody: { fontSize: 14.5, lineHeight: 20 }, seed: { fontSize: 15.5, lineHeight: 22, paddingHorizontal: 15, paddingVertical: 10 }, formError: { fontSize: 15, lineHeight: 21, marginLeft: 4 }, subtitle: { fontSize: 15.5, lineHeight: 22, marginTop: -6 }, diff --git a/apps/mobile/src/app/flowchart/index.tsx b/apps/mobile/src/app/flowchart/index.tsx index 82a957213..5903c5b22 100644 --- a/apps/mobile/src/app/flowchart/index.tsx +++ b/apps/mobile/src/app/flowchart/index.tsx @@ -56,7 +56,7 @@ export default function FlowchartScreen() { const router = useRouter(); const insets = useSafeAreaInsets(); const { colors } = useTheme(); - const params = useLocalSearchParams<{ from?: string; id?: string }>(); + const params = useLocalSearchParams<{ from?: string; id?: string; built?: string }>(); const { session } = useAuth(); const userId = session?.user.id ?? null; const { activeDebateId, oneOff } = useDebates(); @@ -100,6 +100,12 @@ export default function FlowchartScreen() { }, NOTICE_MS); }, []); + // A case built into a map says so once, as the website's toast does. + const built = params.built === '1'; + useEffect(() => { + if (built) setNotice(t('flowchart.builtFromCase')); + }, [built, setNotice, t]); + // Open the requested map, or the debate's newest saved map, or the starter map. A map // already held this session opens as it was left. Only the latest load may land. useEffect(() => { diff --git a/apps/mobile/src/lib/crossfire.ts b/apps/mobile/src/lib/crossfire.ts index 0d4b8ec44..7271e11db 100644 --- a/apps/mobile/src/lib/crossfire.ts +++ b/apps/mobile/src/lib/crossfire.ts @@ -7,6 +7,7 @@ import { type CrossfireAIInput, type CrossfireConfig, type CrossfireDebrief, + type CrossfirePrefill, type CrossfireReply, type CrossfireState, type CrossfireTurn, @@ -212,3 +213,9 @@ export async function countActiveSources(userId: string, debateId: string): Prom * debate is the one chosen when the round started; null for one-off rounds. */ export const crossfireDraft = sessionSlot<{ state: CrossfireState; debateId: string | null } | null>(() => null); + +/** + * What "Open in Crossfire" on a case or an attack hands the next Crossfire + * screen, as the website's route state: taken once, when the screen opens. + */ +export const crossfirePrefill = sessionSlot(() => null); diff --git a/apps/mobile/src/lib/result-links.ts b/apps/mobile/src/lib/result-links.ts new file mode 100644 index 000000000..8a60a8ef9 --- /dev/null +++ b/apps/mobile/src/lib/result-links.ts @@ -0,0 +1,40 @@ +import { prefillFromAttack, prefillFromGenerate } from '@debatelab/core/crossfire/prefill'; +import { caseHasFlowchart, flowchartFromCase } from '@debatelab/core/flowchart'; +import type { AttackOutput } from '@debatelab/core/ai/schemas/attack'; +import type { GenerateOutput } from '@debatelab/core/ai/schemas/generate'; +import type { TFunction } from 'i18next'; + +import { crossfirePrefill } from './crossfire'; +import { newDraft, scopeKey, showDraft } from './flowchart-drafts'; + +/** + * Where a case or an attack leads next, as the website's Generate and Attack + * pages send them: "Open in Crossfire" with the case or the attack's + * questions filled in, and the case built into a map. + */ + +/** The Crossfire form the next Crossfire screen opens with, from a case. */ +export function crossfireFromCase(motion: string, output: GenerateOutput) { + crossfirePrefill.set(prefillFromGenerate(motion || output.caseStructure.thesis, output)); +} + +/** The Crossfire form from an attack: the case it read, and its cross-examination questions. */ +export function crossfireFromAttack(caseText: string, output: AttackOutput) { + crossfirePrefill.set(prefillFromAttack(caseText, output)); +} + +export type FlowchartBuild = 'built' | 'noData' | 'otherDebate'; + +/** + * Puts the case on the Flowchart as a new, unsaved map, as the website's + * Flowcharts button does. The Flowchart files its map in the debate on + * screen, so only a case from that debate (or one-off work, in one-off mode) + * goes there. + */ +export function buildFlowchartFromCase(output: GenerateOutput, caseDebateId: string | null, activeDebateId: string | null, t: TFunction): FlowchartBuild { + if (!caseHasFlowchart(output)) return 'noData'; + if (caseDebateId !== activeDebateId) return 'otherDebate'; + const draft = newDraft(activeDebateId, flowchartFromCase(output, t)); + showDraft(scopeKey(activeDebateId), { ...draft, edited: true }, { replace: true }); + return 'built'; +} diff --git a/packages/core/src/flowchart.ts b/packages/core/src/flowchart.ts index c3c94743e..ff6aa5a69 100644 --- a/packages/core/src/flowchart.ts +++ b/packages/core/src/flowchart.ts @@ -341,3 +341,151 @@ export function mapRows(doc: FlowchartDocument): MapRow[] { for (const root of roots) visit(root, 0, null); return rows; } + +/** i18next's `t` with the one option the case import uses. */ +type TranslateNumbered = (key: string, options?: { n?: number }) => string; + +const EDGE_LABEL_KEYS: Record = { + support: 'flowchart.edgeSupport', + attack: 'flowchart.edgeAttack', + rebuttal: 'flowchart.edgeRebuttal', +}; + +/** An edge of one kind as the website writes it: its label, its kind, its stroke, and rebuttals dashed. */ +export function flowEdgeOfKind(kind: FlowEdgeKind, source: string, target: string, t: Translate): FlowEdge { + return { + id: `e-${source}-${target}`, + source, + target, + label: String(t(EDGE_LABEL_KEYS[kind])), + data: { kind }, + style: { stroke: FLOW_EDGE_STYLES[kind].stroke, strokeWidth: 2, ...(kind === 'rebuttal' ? { strokeDasharray: '6 4' } : {}) }, + markerEnd: { type: 'arrowclosed' }, + }; +} + +/** A node the app wrote: its label is kept as a key, so the website shows it in the reader's language. */ +function caseNode( + id: string, + position: { x: number; y: number }, + type: FlowNodeType, + t: TranslateNumbered, + text: { labelKey: string; labelNumber?: number; description: string; strength?: FlowStrength }, +): FlowNode { + return { + id, + type: 'custom', + position, + data: { + type, + label: String(t(text.labelKey, { n: text.labelNumber })), + labelKey: text.labelKey, + ...(text.labelNumber !== undefined ? { labelNumber: text.labelNumber } : {}), + description: text.description, + // The case's own text is the student's, not the app's. + descriptionKey: null, + ...(text.strength ? { strength: text.strength } : {}), + }, + }; +} + +/** The parts of a generated case a flowchart is built from. */ +export interface CaseForFlowchart { + caseStructure: { thesis: string }; + contentions: { claim: string; evidence?: string }[]; + predictedOpponentCase?: { likelyArguments?: string[] }; + counters?: { targetArgument?: string; counterpoint?: string; rebuttal?: string }[]; +} + +/** A generated case has what a flowchart needs: a thesis and at least one contention. */ +export const caseHasFlowchart = (result: CaseForFlowchart | null | undefined): boolean => + Boolean(result?.caseStructure?.thesis && result.contentions?.length); + +const NODE_GAP_X = 300; +const ROW_GAP_Y = 150; +const PER_ROW = 4; + +// Each tier wraps into rows of four, so a large case still fits a phone at a readable zoom. +function placeTier(count: number, top: number) { + const centerX = 400; + const position = (i: number) => { + const row = Math.floor(i / PER_ROW); + const inRow = Math.min(PER_ROW, count - row * PER_ROW); + return { x: centerX + ((i % PER_ROW) - (inRow - 1) / 2) * NODE_GAP_X, y: top + row * ROW_GAP_Y }; + }; + return { position, bottom: top + Math.max(1, Math.ceil(count / PER_ROW)) * ROW_GAP_Y + 50 }; +} + +const words = (text: string) => new Set(text.toLowerCase().match(/\p{L}{4,}/gu) ?? []); + +/** + * The predicted argument a counter answers: the one its target quotes, else the one + * sharing the most words with it, else the argument at the same position. + */ +function answeredAttack(target: string | undefined, attacks: string[], index: number): number { + const wanted = target?.trim().toLowerCase() ?? ''; + const candidates = attacks.map((argument, i) => ({ i, text: argument.trim().toLowerCase() })).filter(({ text }) => text); + if (wanted) { + const exact = candidates.find(({ text }) => text === wanted) + ?? candidates.find(({ text }) => wanted.length >= 12 && (text.includes(wanted) || (text.length >= 12 && wanted.includes(text)))); + if (exact) return exact.i; + const targetWords = words(wanted); + // Two shared words, so one filler word such as "their" decides nothing. + let best = { i: -1, shared: 1 }; + for (const { i, text } of candidates) { + const shared = [...words(text)].filter((word) => targetWords.has(word)).length; + if (shared > best.shared) best = { i, shared }; + } + if (best.i >= 0) return best.i; + } + return attacks[index]?.trim() ? index : -1; +} + +/** + * Maps a generated case onto a map, as the website's Flowcharts button does: + * the thesis, one node per contention with its claim and evidence, the + * predicted opposing arguments, and the counters. + */ +export function flowchartFromCase(result: CaseForFlowchart, t: TranslateNumbered): { nodes: FlowNode[]; edges: FlowEdge[] } { + const nodes: FlowNode[] = []; + const edges: FlowEdge[] = []; + + nodes.push(caseNode('thesis', { x: 400, y: 50 }, 'thesis', t, { labelKey: 'flowchart.fromCase.thesis', description: result.caseStructure.thesis })); + + const contentions = result.contentions; + const contentionTier = placeTier(contentions.length, 230); + contentions.forEach((contention, i) => { + const id = `c-${i}`; + nodes.push(caseNode(id, contentionTier.position(i), 'contention', t, { + labelKey: 'flowchart.fromCase.argument', + labelNumber: i + 1, + description: [contention.claim, contention.evidence].filter((part) => part?.trim()).join('\n'), + strength: 'strong', + })); + edges.push({ id: `e-thesis-${id}`, source: 'thesis', target: id, markerEnd: { type: 'arrowclosed' } }); + }); + + const attacks = result.predictedOpponentCase?.likelyArguments ?? []; + const attackTier = placeTier(attacks.length, contentionTier.bottom); + attacks.forEach((argument, i) => { + const id = `a-${i}`; + nodes.push(caseNode(id, attackTier.position(i), 'attack', t, { labelKey: 'flowchart.fromCase.attack', labelNumber: i + 1, description: argument })); + edges.push(flowEdgeOfKind('attack', contentions.length ? `c-${i % contentions.length}` : 'thesis', id, t)); + }); + + // A counter answers one of their arguments, so it hangs off that attack. + const counters = result.counters ?? []; + const counterTier = placeTier(counters.length, attackTier.bottom); + counters.forEach((counter, i) => { + const id = `r-${i}`; + const attackIndex = answeredAttack(counter.targetArgument, attacks, i); + nodes.push(caseNode(id, counterTier.position(i), 'rebuttal', t, { + labelKey: 'flowchart.fromCase.rebuttal', + labelNumber: i + 1, + description: counter.rebuttal || counter.counterpoint || '', + })); + edges.push(flowEdgeOfKind('rebuttal', attackIndex >= 0 ? `a-${attackIndex}` : 'thesis', id, t)); + }); + + return { nodes, edges }; +} diff --git a/src/lib/flowchart.ts b/src/lib/flowchart.ts index d9cede990..907a29e73 100644 --- a/src/lib/flowchart.ts +++ b/src/lib/flowchart.ts @@ -4,6 +4,7 @@ import type { TFunction } from 'i18next'; import i18n from '@/i18n'; import { APP_LANGUAGES, type AppLanguage } from '@/lib/languages'; import type { GenerateOutput } from '@/lib/ai/schemas/generate'; +import { caseHasFlowchart as coreCaseHasFlowchart, flowchartFromCase as coreFlowchartFromCase, flowEdgeOfKind } from '@debatelab/core/flowchart'; import type { FlowchartData } from '@/lib/stores/session-store'; export type NodeType = 'thesis' | 'contention' | 'attack' | 'rebuttal' | 'dropped'; @@ -135,16 +136,7 @@ const EDGE_STYLES: Record = { }; export function edgeOfKind(kind: EdgeKind, source: string, target: string, t: TFunction): Edge { - const style = EDGE_STYLES[kind]; - return { - id: `e-${source}-${target}`, - source, - target, - label: String(t(EDGE_LABEL_KEYS[kind])), - data: { kind }, - style: { stroke: style.stroke, strokeWidth: 2, ...(style.dash ? { strokeDasharray: style.dash } : {}) }, - markerEnd: { type: MarkerType.ArrowClosed }, - }; + return flowEdgeOfKind(kind, source, target, t) as Edge; } function appNode(id: string, x: number, y: number, type: NodeType, t: TFunction, text: { @@ -190,100 +182,13 @@ export function starterFlowchart(t: TFunction): FlowchartData { } /** A generated case has what a flowchart needs: a thesis and at least one contention. */ -export const caseHasFlowchart = (result: GenerateOutput | null | undefined): result is GenerateOutput => - Boolean(result?.caseStructure?.thesis && result.contentions?.length); - -const NODE_GAP_X = 300; -const ROW_GAP_Y = 150; -const PER_ROW = 4; - -// Each tier wraps into rows of four, so a large case still fits a phone at a readable zoom. -function placeTier(count: number, top: number) { - const centerX = 400; - const position = (i: number) => { - const row = Math.floor(i / PER_ROW); - const inRow = Math.min(PER_ROW, count - row * PER_ROW); - return { x: centerX + ((i % PER_ROW) - (inRow - 1) / 2) * NODE_GAP_X, y: top + row * ROW_GAP_Y }; - }; - return { position, bottom: top + Math.max(1, Math.ceil(count / PER_ROW)) * ROW_GAP_Y + 50 }; -} - -const words = (text: string) => new Set(text.toLowerCase().match(/\p{L}{4,}/gu) ?? []); - -/** - * The predicted argument a counter answers: the one its target quotes, else the one - * sharing the most words with it, else the argument at the same position. - */ -function answeredAttack(target: string | undefined, attacks: string[], index: number): number { - const wanted = target?.trim().toLowerCase() ?? ''; - const candidates = attacks.map((argument, i) => ({ i, text: argument.trim().toLowerCase() })).filter(({ text }) => text); - if (wanted) { - const exact = candidates.find(({ text }) => text === wanted) - ?? candidates.find(({ text }) => wanted.length >= 12 && (text.includes(wanted) || (text.length >= 12 && wanted.includes(text)))); - if (exact) return exact.i; - const targetWords = words(wanted); - // Two shared words, so one filler word such as "their" decides nothing. - let best = { i: -1, shared: 1 }; - for (const { i, text } of candidates) { - const shared = [...words(text)].filter((word) => targetWords.has(word)).length; - if (shared > best.shared) best = { i, shared }; - } - if (best.i >= 0) return best.i; - } - return attacks[index]?.trim() ? index : -1; -} +export const caseHasFlowchart = (result: GenerateOutput | null | undefined): result is GenerateOutput => coreCaseHasFlowchart(result); /** * Maps a generated case onto the canvas: the thesis, one node per contention with * its claim and evidence, the predicted opposing arguments, and the counters. + * Shared with the iOS app (packages/core/src/flowchart.ts). */ export function flowchartFromCase(result: GenerateOutput, t: TFunction): FlowchartData { - const nodes: Node[] = []; - const edges: Edge[] = []; - - nodes.push(appNode('thesis', 400, 50, 'thesis', t, { labelKey: 'flowchart.fromCase.thesis', description: result.caseStructure.thesis })); - - const contentions = result.contentions; - const contentionTier = placeTier(contentions.length, 230); - contentions.forEach((contention, i) => { - const id = `c-${i}`; - const { x, y } = contentionTier.position(i); - nodes.push(appNode(id, x, y, 'contention', t, { - labelKey: 'flowchart.fromCase.argument', - labelNumber: i + 1, - description: [contention.claim, contention.evidence].filter((part) => part?.trim()).join('\n'), - strength: 'strong', - })); - edges.push({ id: `e-thesis-${id}`, source: 'thesis', target: id, markerEnd: { type: MarkerType.ArrowClosed } }); - }); - - const attacks = result.predictedOpponentCase?.likelyArguments ?? []; - const attackTier = placeTier(attacks.length, contentionTier.bottom); - attacks.forEach((argument, i) => { - const id = `a-${i}`; - const { x, y } = attackTier.position(i); - nodes.push(appNode(id, x, y, 'attack', t, { - labelKey: 'flowchart.fromCase.attack', - labelNumber: i + 1, - description: argument, - })); - edges.push(edgeOfKind('attack', contentions.length ? `c-${i % contentions.length}` : 'thesis', id, t)); - }); - - // A counter answers one of their arguments, so it hangs off that attack. - const counters = result.counters ?? []; - const counterTier = placeTier(counters.length, attackTier.bottom); - counters.forEach((counter, i) => { - const id = `r-${i}`; - const attackIndex = answeredAttack(counter.targetArgument, attacks, i); - const { x, y } = counterTier.position(i); - nodes.push(appNode(id, x, y, 'rebuttal', t, { - labelKey: 'flowchart.fromCase.rebuttal', - labelNumber: i + 1, - description: counter.rebuttal || counter.counterpoint, - })); - edges.push(edgeOfKind('rebuttal', attackIndex >= 0 ? `a-${attackIndex}` : 'thesis', id, t)); - }); - - return { nodes, edges }; + return coreFlowchartFromCase(result, t) as unknown as FlowchartData; } From fa0c95ac188d07a1ea7f30107967b37b3ef7ba48 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 28 Oct 2026 12:10:23 +0000 Subject: [PATCH 04/14] Show today's AI uses, a round's cost, and the website's limit dialogs Each tool's header says how many AI uses are left today, read again after every request. Crossfire and Sparring say what a round costs before it starts and check it first, as the website does. A limit or model refusal opens the website's dialog as a sheet with its words: a paid plan's daily cap and the weekly allowance say when access returns, other limits lead to the plan in You (purchases wait for in-app purchase), and a model refusal can switch the tool to low reasoning. The screenshot script shoots both sheets and the cost notes, answers the quota check in its Crossfire and Sparring fakes, and sends a debrief that fits the current schema. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/AGENTS.md | 4 +- apps/mobile/scripts/screenshots.ts | 94 +++++++++++-- apps/mobile/src/app/_layout.tsx | 2 + apps/mobile/src/app/ask.tsx | 5 + apps/mobile/src/app/attack/index.tsx | 5 +- apps/mobile/src/app/brainstorm/index.tsx | 5 +- apps/mobile/src/app/factoids/index.tsx | 5 +- apps/mobile/src/app/generate.tsx | 5 +- apps/mobile/src/app/limit.tsx | 132 ++++++++++++++++++ apps/mobile/src/app/sparring/index.tsx | 22 ++- .../src/components/session-cost-note.tsx | 43 ++++++ apps/mobile/src/components/tool-form.tsx | 17 ++- apps/mobile/src/lib/ai-request.ts | 16 ++- apps/mobile/src/lib/refusal.ts | 78 +++++++++++ apps/mobile/src/lib/session-quota.ts | 89 ++++++++++++ apps/mobile/src/lib/usage.ts | 60 ++++++++ src/locales/en.json | 3 +- src/locales/es.json | 3 +- src/locales/hu.json | 3 +- 19 files changed, 568 insertions(+), 23 deletions(-) create mode 100644 apps/mobile/src/app/limit.tsx create mode 100644 apps/mobile/src/components/session-cost-note.tsx create mode 100644 apps/mobile/src/lib/refusal.ts create mode 100644 apps/mobile/src/lib/session-quota.ts create mode 100644 apps/mobile/src/lib/usage.ts diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 511d11f3d..a9a274f21 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -18,13 +18,15 @@ The iOS app DebateOwl (`@debatelab/mobile`; the bundle id is still `com.adam.deb ## Layout -- `src/app/`: routes. `sign-in.tsx`; `(tabs)/` with one route group per tab: Debate, Prepare, Practice, Library, You; the form sheets `debates.tsx` (switcher), `new-debate.tsx`, `delete-account.tsx`, `delete-data.tsx`, `add-link.tsx`, `new-note.tsx` and `source/[id].tsx`; and `ask.tsx`, `generate.tsx`, `case/[id].tsx`, `attack/`, `brainstorm/`, `factoids/` and `sparring/` (each a form `index.tsx` and a document `[id].tsx`), `crossfire.tsx` and `timer.tsx` (a full-screen modal), `flowchart/` (the screen `index.tsx` with its `card`, `claim` and `maps` form sheets), and `history.tsx`, full screen above the tabs. All are declared in the root `_layout.tsx`. Every Practice tool is in the app, so `(practice)/soon/[tool].tsx` is no longer reached from the Practice tab. +- `src/app/`: routes. `sign-in.tsx`; `(tabs)/` with one route group per tab: Debate, Prepare, Practice, Library, You; the form sheets `debates.tsx` (switcher), `new-debate.tsx`, `delete-account.tsx`, `delete-data.tsx`, `add-link.tsx`, `new-note.tsx`, `source/[id].tsx` and `limit.tsx` (the website's limit-reached and model-restricted dialogs); and `ask.tsx`, `generate.tsx`, `case/[id].tsx`, `attack/`, `brainstorm/`, `factoids/` and `sparring/` (each a form `index.tsx` and a document `[id].tsx`), `crossfire.tsx` and `timer.tsx` (a full-screen modal), `flowchart/` (the screen `index.tsx` with its `card`, `claim` and `maps` form sheets), and `history.tsx`, full screen above the tabs. All are declared in the root `_layout.tsx`. Every Practice tool is in the app, so `(practice)/soon/[tool].tsx` is no longer reached from the Practice tab. - `src/components/`: shared UI. `app-tabs.tsx` uses native tabs; `app-tabs.web.tsx` draws the mock's tab bar for the web preview. - `src/lib/`: auth (`auth.tsx`), `native-capabilities.ts` (what the running binary has beyond Expo Go), the Supabase client, session storage (AES key in SecureStore, ciphertext in AsyncStorage) and i18n. `debates.ts`, `settings.tsx`, `subscription.ts` and `account.ts` read and write the website's tables the way its stores do (`src/lib/stores/debate-store.ts`, `settings-store.ts`, `src/hooks/use-subscription.ts`, `src/lib/account.ts`), so the app and the website share one Current Debate and one set of settings. Change them together with the website code they mirror. - `sources.ts` mirrors the session store's `addSource` and `removeSource` and `src/lib/source-import.ts`. The Library always shows the Current Debate, also in one-off mode, as on the website. Adding a file (the website parses files in the browser) and Scan pages are not in the app yet. - Ask mirrors `src/lib/ai/streaming.ts` and `askChat` in `src/lib/ai/client.ts`: `ask.ts` sends the requests (plain questions stream from `ai-stream` through `expo/fetch`; with Citations, Web search, Pros/Cons or Argument map on, the whole answer comes from `ai`, because `ai-stream` refuses those options), `ai-request.ts` holds the shared POST with the website's headers, timeout and error kinds, `feature-config.ts` reads each tool's model from `user_settings.feature_configs` (the model picker, `components/model-picker.tsx`, writes it back through `settings.tsx`'s serial queue in the website's canonical shape; tools read it through the same queue, so they run with the model just picked), `context-request.ts` builds the Current Debate context with the website's default selection and pins, and `chats.ts` saves finished exchanges to `chats` and `messages`. The SSE parser is shared in `packages/core/src/ai/sse.ts`. `markdown.ts` reads answers, because react-markdown needs the DOM. - Generate and Attack mirror `generateCase` and `attackCase` in `src/lib/ai/client.ts` and the session store's `recordResult`: `results.ts` sends the requests through `ai-request.ts`'s `postAI`, which streams them as the website does (`stream: true` with a `resultKey`; the reader is `packages/core/src/ai/live.ts`, shared with the website's `src/lib/ai/live.ts`) and reads a dropped stream's answer back from `ai_run_results` (`packages/core/src/ai/run-recovery.ts`). The four tool screens show the draft with `components/live-run.tsx` (the website's `LiveToolRun`; the sections come from `packages/core/src/ai/live-sections.ts`). `results.ts` also checks the output with the website's transform (moved to `packages/core/src/ai/generate-output.ts`), keeps researched sources in the debate's Auto-saved folder (`packages/core/src/ai/discovered-sources.ts`), saves the server's source rankings onto the Library sources a result used (`packages/core/src/ai/source-assessments.ts`, also for Ask's whole answers), says when the server shortened the input, and saves rows to `cases` and `attacks`. `motion.ts` and `motion-gate.ts` are the website's motion gate. `open-results.ts` holds results made in this session, so one-off work and failed saves still open as documents. It and the last Generate and Attack forms live in `session-memory.ts`, which the auth provider clears on every account change, including sign-out. `components/document.tsx` draws results as documents (mock 03); Brainstorm and Factoids reuse it. Export renders `export-html.ts` to a PDF with expo-print and shares it (`export.ts`); the web preview cannot print, so `export.web.ts` keeps the HTML and copied text on `window` for the screenshot script. Word export and Google Docs are not in the app. - Brainstorm and Factoids mirror `brainstorm` and `factoids` in `src/lib/ai/client.ts` and their pages, through the same `results.ts`: the brainstorm transform and the verified-factoid selection live in `packages/core/src/ai/brainstorm-output.ts` and `factoids-output.ts`, rows go to `brainstorms` and `factoids`. Brainstorm has the motion gate; Factoids, like Attack, follows the Current Debate and saves with side `neutral`. A factoid's bookmark saves it to the Current Debate's `Factoids` folder, and a search suggestion reopens the Factoids form with that query. `prepareToolHref` in `components/prepare-tools.ts` is where both tabs open a tool. +- A case's and an attack's next steps are `result-links.ts`: "Open in Crossfire" fills the next Crossfire form through the one-shot `crossfirePrefill` slot (the website's route state; the builders are `packages/core/src/crossfire/prefill.ts`), and a case is built into an unsaved map with `flowchartFromCase` (`packages/core/src/flowchart.ts`, shared with the website), only in the debate the Flowchart is on, as on the website. +- Limits mirror the website's subscription dialogs: `refusal.ts` turns a 402 or 403 refusal (with the limit details `errorFrom` keeps) into the `limit` sheet's parameters and holds the website's messages (`limit-message.ts`); the tools open the sheet instead of an inline error, and Ask, Crossfire and Sparring open it beside theirs. Upgrading waits for in-app purchase, so the sheet's next step is the plan in You. `usage.ts` reads today's uses for the line under each tool's title (`DailyUsageLine`, the website's DailyUsage) and reads again after every AI request (`onAIRequestSettled`). `session-quota.ts` asks what a Crossfire or Sparring round costs (`quotaCheck`), shows it above Start (`SessionCostNote`) and opens the sheet before a round the day cannot cover. - Timer mirrors `src/pages/TimerPage.tsx`: `timer.ts` holds the formats and a clock made of start timestamps, never counted ticks, so it is right after the app returns from the background (`timer.test.ts`). `timer-signals.ts` plays the bundled beeps in `assets/sounds` (made by `scripts/render-timer-sounds.ts`) with expo-audio, taps with expo-haptics and keeps the screen awake while a speech runs; `timer-signals.web.ts` only records the signals for the screenshot script. Lock-screen timers wait for the development build (`TODO(live-activity)`). - Crossfire is the website's typed mode: `crossfire.ts` sends `crossfire` and its signal requests as `src/lib/ai/client.ts` does (Ask's model settings, 75 s and 5 s deadlines), with the round's id as `quotaSession` on turns and the debrief so the round is charged once, as the website does and saves a finished round to `crossfires` in its debate, as CrossfirePage's `recordResult` call; the reducer and schemas are `packages/core/src/crossfire`. The unfinished round is a session slot, so leaving the screen keeps it. Voice rounds need the development build, so the voice switch shows disabled. The website cannot reopen a saved round, so neither can the app. - Sparring is the website's typed mode: `sparring.ts` sends the opponent's turns and the judge as `generateSparringTurn` and `judgeSparringSession` in `src/lib/ai/client.ts` do (the Sparring model settings, a transcript-only debate context), and the round's moves (turn order, the deadline clock, judging, saved rows) are `packages/core/src/sparring/session.ts`, shared with the website's sparring store. The server saves the judged round to `sparring_sessions` when it scores it; the app then files it in its debate with `attach_to_debate` and the student's side, as the store does. The side is asked when the round starts. The unfinished round is a session slot (`sparringDraft`), so leaving the screen keeps it, and a request cut off by leaving is offered again. The flow button opens the Flowchart on the Current Debate's map, above the round, which keeps running. Practice lists the Current Debate's judged rounds and the Debate tab lists them by side; both open `sparring/[id]`, which can delete them. Voice rounds need the development build, so the voice switch shows disabled. diff --git a/apps/mobile/scripts/screenshots.ts b/apps/mobile/scripts/screenshots.ts index ab568d1f0..70c1ca86f 100644 --- a/apps/mobile/scripts/screenshots.ts +++ b/apps/mobile/scripts/screenshots.ts @@ -11,7 +11,7 @@ * ONLY=iphone-light-en limits a run to the combinations whose name starts with it; * separate several with commas (ONLY=iphone-light-en,ipad-dark-hu). * SCREENS=you,timer limits the signed-in screens to those groups: debate, - * library, prepare, practice, you, empty, ask, results, brainstorm, factoids, + * library, prepare, practice, you, empty, ask, results, brainstorm, factoids, limits, * timer, crossfire, sparring, flowchart, one-off, history, announcements, * account, model. Sign-in screens are always shot. * ONLY=offline runs only the offline reading check (see shootOfflineScreens). @@ -587,6 +587,25 @@ async function shootResultScreens(context: BrowserContext, prefix: string, langu await pdf.close(); console.log(`${prefix}: PDF file name ${exported.fileName}, copied ${exported.copied.length} characters`); + // The case's menu leads on: Open in Crossfire with the case filled in, and the case built into a map. + // Nothing is saved: the Crossfire form is not started and the map is not saved. + await page.getByTestId('document-more').click(); + await page.getByRole('menu').waitFor(); + await shoot(page, `${prefix}-case-menu`); + await page.getByRole('menu').getByRole('menuitem').first().click(); + await page.getByTestId('crossfire-imported').waitFor({ timeout: 15_000 }); + await settle(page); + await shoot(page, `${prefix}-crossfire-from-case`); + await page.goBack(); + await page.getByTestId('case-document').waitFor({ timeout: 15_000 }); + await page.getByTestId('document-more').click(); + await page.getByRole('menu').getByRole('menuitem').nth(1).click(); + await page.getByTestId('flowchart-screen').waitFor({ timeout: 15_000 }); + await page.waitForTimeout(500); + await shoot(page, `${prefix}-flowchart-from-case`); + await page.goBack(); + await page.getByTestId('case-document').waitFor({ timeout: 15_000 }); + await page.getByTestId('attack-this-case').click(); await page.getByTestId('attack-case-text').waitFor({ timeout: 15_000 }); await page.waitForFunction(() => (document.querySelector('[data-testid="attack-case-text"]') as HTMLTextAreaElement | null)?.value.startsWith('THESIS:')); @@ -595,6 +614,14 @@ async function shootResultScreens(context: BrowserContext, prefix: string, langu await page.getByTestId('attack-document').waitFor({ timeout: 30_000 }); await page.waitForTimeout(400); await shoot(page, `${prefix}-attack-result`); + // The case stays mounted under the attack, so its menu is looked up inside the attack. + await page.getByTestId('attack-document').getByTestId('document-more').click(); + await page.getByRole('menu').last().getByRole('menuitem').first().click(); + await page.getByTestId('crossfire-imported').waitFor({ timeout: 15_000 }); + // Not settle(): the attack's score meter (a progressbar) stays mounted under Crossfire. + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(400); + await shoot(page, `${prefix}-crossfire-from-attack`); await page.close(); } @@ -666,6 +693,39 @@ async function shootTool(context: BrowserContext, prefix: string, language: stri await page.close(); } +/** + * The limit and model sheets: Generate answered with the ai function's + * refusals (402 limit_reached with its counts, then 403 upgrade_required). + * Nothing is sent to a model; "Use low reasoning instead" is never pressed. + */ +async function shootLimitSheets(context: BrowserContext, prefix: string, language: string, problems: string[]) { + const refusals = [ + { name: 'limit-sheet', status: 402, json: { error: 'Daily limit reached', code: 'limit_reached', limit: 20, current: 20, units: 1 } }, + { name: 'model-sheet', status: 403, json: { error: 'This model requires a Pro subscription.', code: 'upgrade_required' } }, + ]; + for (const refusal of refusals) { + const page = await context.newPage(); + // The browser logs the refused request itself; only that error is expected. + const seen: string[] = []; + watchErrors(page, seen, `${prefix} ${refusal.name}`); + page.on('dialog', (dialog) => void dialog.dismiss()); + await pinSettings(page, language); + await page.route(`https://${STAGING_HOST}/functions/v1/ai`, (route) => + route.fulfill({ status: refusal.status, headers: { 'access-control-allow-origin': '*' }, json: refusal.json }), + ); + await page.goto(`${ORIGIN}/generate?from=prepare`); + await page.getByTestId('generate-motion').waitFor({ timeout: 30_000 }); + await page.waitForFunction(() => (document.querySelector('[data-testid="generate-motion"]') as HTMLTextAreaElement | null)?.value.trim()); + await page.getByRole('button', { name: /^(Generate debate case|Vitaeset generálása|Generar caso de debate)$/i }).click(); + await page.getByTestId('limit-sheet').waitFor({ timeout: 15_000 }); + await settle(page); + await page.waitForTimeout(500); + await shoot(page, `${prefix}-${refusal.name}`); + await page.close(); + problems.push(...seen.filter((problem) => !problem.includes(`status of ${refusal.status}`))); + } +} + // Made-up saved maps in the website's React Flow shape, after mock A. const FLOW_NODE = (id: string, x: number, y: number, label: string, type: string, strength?: string) => ({ id, type: 'custom', position: { x, y }, width: 220, height: 96, @@ -908,11 +968,15 @@ const CROSSFIRE_TEXT = { } as const; /** Fakes Crossfire's AI calls and its save for this page (see the header). Replies follow the request, as the client checks them. */ +// The round's cost, as the ai function answers a quotaCheck: one use, 16 of 20 left today. +const FAKE_QUOTA = { quota: { allowed: true, units: 1, limit: 20, current: 4 } }; + async function fakeCrossfire(page: Page, language: keyof typeof CROSSFIRE_TEXT) { const text = CROSSFIRE_TEXT[language]; await page.route(`https://${STAGING_HOST}/functions/v1/ai`, async (route) => { const body = route.request().postDataJSON() as { feature?: string; input?: { operation?: string; expectedReply?: 'question' | 'answer'; transcript?: { id: string }[] } } | null; if (body?.feature !== 'crossfire') return route.abort(); + if ((body as { quotaCheck?: boolean }).quotaCheck) return route.fulfill({ status: 200, headers: { 'access-control-allow-origin': '*' }, json: FAKE_QUOTA }); const input = body.input ?? {}; const turns = input.transcript ?? []; const exchange = Math.floor(turns.length / 2) % text.answers.length; @@ -927,8 +991,9 @@ async function fakeCrossfire(page: Page, language: keyof typeof CROSSFIRE_TEXT) } else { const d = text.debrief; output = { - concessionsSecured: [{ turnIds: ['T3', 'T4'], claim: d.concession }], - controlledQuestions: [{ turnIds: ['T1', 'T2'], claim: d.controlled }], + // Concessions and controlled questions name one turn each (SingleTurnReferenceSchema). + concessionsSecured: [{ turnIds: ['T4'], claim: d.concession }], + controlledQuestions: [{ turnIds: ['T1'], claim: d.controlled }], weakOrRepetitiveQuestions: [], evasiveOrIncompleteAnswers: [], missedFollowUps: [{ turnIds: ['T2'], claim: d.missed }], @@ -958,6 +1023,10 @@ async function shootCrossfireScreens(context: BrowserContext, prefix: string, la await page.getByTestId('crossfire-argument').fill(text.argument); await settle(page); await shoot(page, `${prefix}-crossfire-setup`); + // The round's cost sits above the start button, below the fold on a phone. + await page.getByTestId('session-cost').scrollIntoViewIfNeeded(); + await page.waitForTimeout(300); + await shoot(page, `${prefix}-crossfire-cost`); await page.getByRole('button', { name: /^(Start crossfire|Keresztkérdezés indítása|Iniciar interrogatorio)$/ }).click(); await page.getByTestId('crossfire-live').waitFor({ timeout: 15_000 }); @@ -1026,6 +1095,7 @@ async function fakeSparring(page: Page, language: keyof typeof SPARRING_TEXT) { await page.route(`https://${STAGING_HOST}/functions/v1/ai`, async (route) => { const body = route.request().postDataJSON() as { feature?: string; input?: { action?: 'respond' | 'score'; speeches?: { speaker: string }[] } } | null; if (body?.feature !== 'sparring') return route.abort(); + if ((body as { quotaCheck?: boolean }).quotaCheck) return route.fulfill({ status: 200, headers: { 'access-control-allow-origin': '*' }, json: FAKE_QUOTA }); const action = body.input?.action ?? 'respond'; if (holding[action]) await new Promise((resolve) => { held[action] = resolve; }); let output: unknown; @@ -1078,6 +1148,10 @@ async function shootSparringScreens(context: BrowserContext, prefix: string, lan await page.getByRole('menuitem', { name: 'Lincoln-Douglas' }).click(); await settle(page); await shoot(page, `${prefix}-sparring-setup`); + // The round's cost sits above the start button, below the fold on a phone. + await page.getByTestId('session-cost').scrollIntoViewIfNeeded(); + await page.waitForTimeout(300); + await shoot(page, `${prefix}-sparring-cost`); fake.hold('respond'); await page.getByRole('button', { name: /^(Start round|Forduló indítása|Iniciar ronda)$/ }).click(); @@ -1747,25 +1821,29 @@ async function main() { shots += 3; } if (want('results')) { - await shootResultScreens(signedIn, prefix, language, problems); - shots += 10; + await shootResultScreens(signedIn, prefix, language, problems).catch(failedIn(signedIn, prefix, 'results')); + shots += 14; } for (const tool of ['brainstorm', 'factoids'] as const) { if (!want(tool)) continue; await shootTool(signedIn, prefix, language, problems, tool); shots += 7; } + if (want('limits')) { + await shootLimitSheets(signedIn, prefix, language, problems).catch(failedIn(signedIn, prefix, 'limits')); + shots += 2; + } if (want('timer')) { await shootTimerScreens(signedIn, prefix, language, problems); shots += 3; } if (want('crossfire')) { - await shootCrossfireScreens(signedIn, prefix, language, problems); - shots += 3; + await shootCrossfireScreens(signedIn, prefix, language, problems).catch(failedIn(signedIn, prefix, 'crossfire')); + shots += 4; } if (want('sparring')) { await shootSparringScreens(signedIn, prefix, language, problems); - shots += 6; + shots += 7; } if (want('flowchart')) { await shootFlowchart(signedIn, prefix, language, problems); diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index d6d0dbe62..c8b2a0327 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -94,6 +94,8 @@ function Root() { + {/* The website's limit and model dialogs; short, so the sheet stops at half height. */} + {/* Full screen above the tabs, as in mock 07. */} {/* History, full screen above the tabs like the documents it opens. */} diff --git a/apps/mobile/src/app/ask.tsx b/apps/mobile/src/app/ask.tsx index e8dd27630..1a89a0274 100644 --- a/apps/mobile/src/app/ask.tsx +++ b/apps/mobile/src/app/ask.tsx @@ -15,6 +15,7 @@ import { FadingScrollRow } from '@/components/edge-fade'; import { Icon } from '@/components/icon'; import type { IconName } from '@/components/icon-paths'; import { OneOffNotice } from '@/components/one-off-notice'; +import { DailyUsageLine } from '@/components/tool-form'; import { Text } from '@/components/text'; import { AIError, isAbortError, type AIErrorKind, type ResponseExtras } from '@/lib/ai-request'; import { askOnce, canStream, isFinalAskError, isMessageWithinBudget, streamAsk, type AskOptions } from '@/lib/ask'; @@ -24,6 +25,7 @@ import { useAuth } from '@/lib/auth'; import { ChatSaveError, fetchChat, saveChat, type ChatMessage, type ChatRecord } from '@/lib/chats'; import { useDebates } from '@/lib/debate-context'; import { LIST_WIDTH, useLayoutClass } from '@/lib/layout'; +import { openRefusal } from '@/lib/refusal'; import { keepSourceAssessments } from '@/lib/results'; import { useSettings } from '@/lib/settings'; import { fetchSources, type Source } from '@/lib/sources'; @@ -294,6 +296,8 @@ export default function AskScreen() { // A missing debate was deleted elsewhere; reload the list so the app stops using it. if (kind === 'debateMissing') void refresh(); setError({ kind, question: message }); + // The question stays as not sent, and the website's dialog opens too. + openRefusal(router, cause, 'ask'); } finally { if (isCurrent()) { setLoading(false); @@ -407,6 +411,7 @@ export default function AskScreen() { {t('askChat.startDesc')} + ) : null} diff --git a/apps/mobile/src/app/attack/index.tsx b/apps/mobile/src/app/attack/index.tsx index e475041ea..4823fa75e 100644 --- a/apps/mobile/src/app/attack/index.tsx +++ b/apps/mobile/src/app/attack/index.tsx @@ -21,6 +21,7 @@ import { buildDebateContext } from '@/lib/context-request'; import { useDebates } from '@/lib/debate-context'; import { keepOpenResult, openResult } from '@/lib/open-results'; import { caseToText } from '@/lib/result-text'; +import { openRefusal } from '@/lib/refusal'; import { arrivalNotices, attackCase, autosaveSources, fetchResult, isCompleteCase, keepSourceAssessments, recordResult } from '@/lib/results'; import { sessionSlot } from '@/lib/session-memory'; import { useSettings } from '@/lib/settings'; @@ -165,8 +166,10 @@ export default function AttackScreen() { if (!(cause instanceof AIError)) console.warn('Attack failed:', cause instanceof Error ? cause.message : cause); if (kind === 'debateMissing') void refresh(); liveRun.end(); - setError(kind); setPhase('form'); + // A limit or model refusal opens the website's dialog as a sheet, not an inline error. + if (openRefusal(router, cause, 'attack')) return; + setError(kind); } finally { if (requests.current === request) abort.current = null; } diff --git a/apps/mobile/src/app/brainstorm/index.tsx b/apps/mobile/src/app/brainstorm/index.tsx index 3768ad1d3..7bc5a821f 100644 --- a/apps/mobile/src/app/brainstorm/index.tsx +++ b/apps/mobile/src/app/brainstorm/index.tsx @@ -22,6 +22,7 @@ import { useDebates } from '@/lib/debate-context'; import { decideMotionTarget, shouldPrefillMotion } from '@/lib/motion'; import { chooseMotionTarget } from '@/lib/motion-gate'; import { keepOpenResult } from '@/lib/open-results'; +import { openRefusal } from '@/lib/refusal'; import { arrivalNotices, autosaveSources, brainstormIdeas, keepSourceAssessments, recordResult } from '@/lib/results'; import { sessionSlot } from '@/lib/session-memory'; import { useSettings } from '@/lib/settings'; @@ -155,8 +156,10 @@ export default function BrainstormScreen() { // A missing debate was deleted elsewhere; reload the list so the app stops using it. if (kind === 'debateMissing') void refresh(); liveRun.end(); - setError(kind); setPhase('form'); + // A limit or model refusal opens the website's dialog as a sheet, not an inline error. + if (openRefusal(router, cause, 'brainstorm')) return; + setError(kind); } finally { if (requests.current === request) abort.current = null; } diff --git a/apps/mobile/src/app/factoids/index.tsx b/apps/mobile/src/app/factoids/index.tsx index cebdabac1..e2cc8c2a1 100644 --- a/apps/mobile/src/app/factoids/index.tsx +++ b/apps/mobile/src/app/factoids/index.tsx @@ -21,6 +21,7 @@ import { backLabel } from '@/lib/back-label'; import { buildDebateContext } from '@/lib/context-request'; import { useDebates } from '@/lib/debate-context'; import { keepOpenResult } from '@/lib/open-results'; +import { openRefusal } from '@/lib/refusal'; import { arrivalNotices, autosaveSources, findFactoids, keepSourceAssessments, recordResult } from '@/lib/results'; import { sessionSlot } from '@/lib/session-memory'; import { useSettings } from '@/lib/settings'; @@ -162,8 +163,10 @@ export default function FactoidsScreen() { if (!(cause instanceof AIError)) console.warn('Factoids failed:', cause instanceof Error ? cause.message : cause); if (kind === 'debateMissing') void refresh(); liveRun.end(); - setError(kind); setPhase('form'); + // A limit or model refusal opens the website's dialog as a sheet, not an inline error. + if (openRefusal(router, cause, 'factoids')) return; + setError(kind); } finally { if (requests.current === request) abort.current = null; } diff --git a/apps/mobile/src/app/generate.tsx b/apps/mobile/src/app/generate.tsx index 018120cb9..16b2bae00 100644 --- a/apps/mobile/src/app/generate.tsx +++ b/apps/mobile/src/app/generate.tsx @@ -23,6 +23,7 @@ import { useDebates } from '@/lib/debate-context'; import { decideMotionTarget, shouldPrefillMotion } from '@/lib/motion'; import { chooseMotionTarget } from '@/lib/motion-gate'; import { keepOpenResult } from '@/lib/open-results'; +import { openRefusal } from '@/lib/refusal'; import { arrivalNotices, autosaveSources, generateCase, keepSourceAssessments, recordResult } from '@/lib/results'; import { sessionSlot } from '@/lib/session-memory'; import { useSettings } from '@/lib/settings'; @@ -146,8 +147,10 @@ export default function GenerateScreen() { // A missing debate was deleted elsewhere; reload the list so the app stops using it. if (kind === 'debateMissing') void refresh(); liveRun.end(); - setError(kind); setPhase('form'); + // A limit or model refusal opens the website's dialog as a sheet, not an inline error. + if (openRefusal(router, cause, 'generate')) return; + setError(kind); } finally { if (requests.current === request) abort.current = null; } diff --git a/apps/mobile/src/app/limit.tsx b/apps/mobile/src/app/limit.tsx new file mode 100644 index 000000000..da3678816 --- /dev/null +++ b/apps/mobile/src/app/limit.tsx @@ -0,0 +1,132 @@ +import type { FeatureTab } from '@debatelab/core/ai/feature-configs'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { StyleSheet, View } from 'react-native'; + +import { PrimaryButton, TextButton } from '@/components/buttons'; +import { Owl } from '@/components/owl'; +import { SheetScrollView } from '@/components/sheet'; +import { Text } from '@/components/text'; +import { useAuth } from '@/lib/auth'; +import { formatDate } from '@/lib/dates'; +import { LIMIT_TITLES, limitMessage, limitVariant } from '@/lib/refusal'; +import { useSettings } from '@/lib/settings'; +import { fetchSubscription, type Subscription } from '@/lib/subscription'; + +const FEATURES = new Set(['generate', 'attack', 'brainstorm', 'factoids', 'ask', 'sparring']); +const number = (value: string | undefined) => (value !== undefined && value !== '' && Number.isFinite(Number(value)) ? Number(value) : undefined); + +/** + * The website's limit-reached and model-restricted dialogs as a sheet, with + * its words and next steps. Upgrading waits for in-app purchase, so where the + * website offers Pro, the sheet opens the plan in You instead; a paid plan's + * daily cap and the weekly allowance only say when access returns, as there. + */ +export default function LimitSheet() { + const { t } = useTranslation(); + const router = useRouter(); + const params = useLocalSearchParams<{ kind?: string; feature?: string; code?: string; limit?: string; current?: string; units?: string }>(); + const { session } = useAuth(); + const userId = session?.user.id ?? null; + const { setFeatureConfig } = useSettings(); + const [plan, setPlan] = useState(null); + const [switching, setSwitching] = useState(false); + + // The plan decides the next step (a paid daily cap is not sold Pro again) and the end date. + useEffect(() => { + if (!userId || params.kind === 'model') return; + let active = true; + fetchSubscription(userId) + .then((next) => { + if (active) setPlan(next); + }) + .catch((cause: unknown) => console.warn('Could not load the plan:', cause instanceof Error ? cause.message : cause)); + return () => { + active = false; + }; + }, [userId, params.kind]); + + const close = () => router.back(); + const seePlan = () => { + router.back(); + router.navigate('/you'); + }; + + if (params.kind === 'model') { + const feature = FEATURES.has(params.feature as FeatureTab) ? (params.feature as FeatureTab) : null; + // The website only closes its dialog; here the tool's reasoning is set to low, so the retry works. + const switchToLow = async () => { + if (!feature) return close(); + setSwitching(true); + try { + await setFeatureConfig(feature, { reasoningEffort: 'low' }); + } catch (cause) { + console.warn('Could not switch to low reasoning:', cause instanceof Error ? cause.message : cause); + } + close(); + }; + return ( + + + + + {t('modelModal.title')} + + + {`${t('modelModal.description')} ${t('modelModal.upgradeNote')}`} + + + void switchToLow()} testID="limit-primary" /> + + + + + ); + } + + const variant = limitVariant(params.code); + const details = { code: params.code, limit: number(params.limit), current: number(params.current), units: number(params.units) }; + const paidDailyCap = variant === 'limit_reached' && (plan?.billingState === 'pro' || plan?.billingState === 'pro_canceling' || plan?.billingState === 'payment_failed'); + const endedOn = variant === 'subscription_ended' && plan?.billingState === 'subscription_ended' && plan.billingDate ? formatDate(plan.billingDate) : null; + const description = endedOn ? t('limitModal.subscriptionEndedOnDesc', { date: endedOn }) : limitMessage(t, details); + const waitOnly = variant === 'weekly_budget_reached' || paidDailyCap; + const waiting = variant === 'limit_reached' || variant === 'weekly_budget_reached'; + + return ( + + + + + {t(LIMIT_TITLES[variant])} + + + {description} + + + {waitOnly ? ( + + ) : ( + <> + + + + + {variant !== 'payment_failed' ? ( + + {t('limitModal.upgradeNote')} + + ) : null} + + )} + + ); +} + +const styles = StyleSheet.create({ + head: { alignItems: 'center', gap: 8, paddingTop: 4 }, + title: { fontSize: 22, lineHeight: 28, textAlign: 'center', letterSpacing: -0.3 }, + body: { fontSize: 16, lineHeight: 23, textAlign: 'center' }, + secondary: { alignItems: 'center', marginTop: -6 }, + note: { fontSize: 13.5, lineHeight: 19, textAlign: 'center', marginTop: -8 }, +}); diff --git a/apps/mobile/src/app/sparring/index.tsx b/apps/mobile/src/app/sparring/index.tsx index b5faa4201..ebbfb32ad 100644 --- a/apps/mobile/src/app/sparring/index.tsx +++ b/apps/mobile/src/app/sparring/index.tsx @@ -29,6 +29,7 @@ import { FormGroup, SelectRow, SwitchRow } from '@/components/form-rows'; import { Icon } from '@/components/icon'; import { LiveTopBar, MainRoundControl, RoundIconButton, SideControl } from '@/components/round-controls'; import { SegmentedControl } from '@/components/segmented-control'; +import { SessionCostNote } from '@/components/session-cost-note'; import { SparringScorecardScreen } from '@/components/sparring-views'; import { Text } from '@/components/text'; import { TimerRing } from '@/components/timer-ring'; @@ -38,6 +39,8 @@ import { useAuth } from '@/lib/auth'; import { backLabel } from '@/lib/back-label'; import { useDebates } from '@/lib/debate-context'; import { fetchFeatureConfig } from '@/lib/feature-config'; +import { openRefusal } from '@/lib/refusal'; +import { sessionQuotaRefusal, useSessionQuota } from '@/lib/session-quota'; import { decideMotionTarget, shouldPrefillMotion } from '@/lib/motion'; import { chooseMotionTarget } from '@/lib/motion-gate'; import { useSettings } from '@/lib/settings'; @@ -110,6 +113,7 @@ export default function SparringScreen() { const owner = useRef(userId); // Each side choice gets a number; a slower earlier one never starts a round over a newer one. const startAttempt = useRef(0); + const roundQuota = useSessionQuota('sparring'); // Another account starts with no round: the session slot was already cleared, and this screen's copy goes too. useEffect(() => { @@ -174,8 +178,9 @@ export default function SparringScreen() { if (controller.signal.aborted || isAbortError(cause)) return; if (cause instanceof AIError && cause.kind === 'debateMissing') void refresh(); setRequest({ status: 'failed', failure: classify(cause, true) }); + openRefusal(router, cause, 'sparring'); } - }, [commit, outputLanguage, refresh, t]); + }, [commit, outputLanguage, refresh, router, t]); const runTurn = useCallback(async (snapshot: SparringSession, forUser: string) => { const turn = currentSparringTurn(snapshot); @@ -216,8 +221,9 @@ export default function SparringScreen() { if (controller.signal.aborted || isAbortError(cause)) return; if (cause instanceof AIError && cause.kind === 'debateMissing') void refresh(); setRequest({ status: 'failed', failure: classify(cause, false) }); + openRefusal(router, cause, 'sparring'); } - }, [commit, outputLanguage, refresh, runJudge]); + }, [commit, outputLanguage, refresh, router, runJudge]); // The clock counts down from its deadline; the tick only redraws it, and coming back from the background redraws at once. const timerRunning = session?.timerRunning ?? false; @@ -249,12 +255,19 @@ export default function SparringScreen() { else tapTurnStart(); }; - const start = () => { + const start = async () => { if (motion.trim().length < MIN_MOTION_CHARS) { setFormError(t('sparring.setup.motionTooShort')); return; } setFormError(''); + // A round the day's uses cannot cover opens the limit sheet before its clock starts, as on the website. + const refusal = sessionQuotaRefusal(await roundQuota.check()); + if (!live.current) return; + if (refusal) { + openRefusal(router, refusal, 'sparring'); + return; + } setSideMenu(true); }; @@ -338,7 +351,7 @@ export default function SparringScreen() { } - actions={}> + actions={ void start()} />}> + {formError ? ( {formError} diff --git a/apps/mobile/src/components/session-cost-note.tsx b/apps/mobile/src/components/session-cost-note.tsx new file mode 100644 index 000000000..169910d1a --- /dev/null +++ b/apps/mobile/src/components/session-cost-note.tsx @@ -0,0 +1,43 @@ +import { useTranslation } from 'react-i18next'; +import { StyleSheet, View } from 'react-native'; + +import { Icon } from './icon'; +import { Text } from './text'; + +import { limitMessage } from '@/lib/refusal'; +import { sessionQuotaRemaining, type SessionQuota } from '@/lib/session-quota'; +import { radius } from '@/theme/colors'; +import { useTheme } from '@/theme/use-theme'; + +/** + * The website's SessionCostNote: "This round costs 1 AI use, however many + * turns it takes. You have N left today." When the round would be refused, + * why, in the limit dialog's words. Nothing while the cost is unknown. + */ +export function SessionCostNote({ quota }: { quota: SessionQuota | null }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + if (!quota) return null; + if (!quota.allowed) { + const reason = quota.code === 'upgrade_required' ? t('modelModal.restrictedDesc') : limitMessage(t, quota); + return ( + + + {reason} + + ); + } + const remaining = sessionQuotaRemaining(quota); + return ( + + {t('sessionQuota.cost', { count: quota.units })} + {remaining !== null ? ` ${t('sessionQuota.remaining', { count: remaining })}` : ''} + + ); +} + +const styles = StyleSheet.create({ + note: { fontSize: 14, lineHeight: 20, marginHorizontal: 4 }, + blocked: { flexDirection: 'row', gap: 8, borderWidth: 2, borderRadius: radius.field, paddingHorizontal: 12, paddingVertical: 10 }, + blockedText: { flex: 1, fontSize: 14.5, lineHeight: 20 }, +}); diff --git a/apps/mobile/src/components/tool-form.tsx b/apps/mobile/src/components/tool-form.tsx index 6d020286c..46d98cb92 100644 --- a/apps/mobile/src/components/tool-form.tsx +++ b/apps/mobile/src/components/tool-form.tsx @@ -8,12 +8,13 @@ import { OneOffNotice } from './one-off-notice'; import { Text } from './text'; import type { AIErrorKind } from '@/lib/ai-request'; +import { useDailyUsage } from '@/lib/usage'; import { useDebates } from '@/lib/debate-context'; import { radius } from '@/theme/colors'; import { useTheme } from '@/theme/use-theme'; import { OwlLoader, type OwlPose } from '@/components/owl'; -/** The tool's large title and the line under it. */ +/** The tool's large title, the line under it, and today's AI uses left, as the website's PageHeader. */ export function ToolTitle({ title, subtitle }: { title: string; subtitle: string }) { return ( @@ -23,10 +24,23 @@ export function ToolTitle({ title, subtitle }: { title: string; subtitle: string {subtitle} + ); } +/** "3 of 5 AI uses left today"; nothing while the count is unknown or the plan has no daily uses. */ +export function DailyUsageLine() { + const { t } = useTranslation(); + const usage = useDailyUsage(); + if (!usage) return null; + return ( + + {t('dailyLimit.remaining', { count: usage.remaining, limit: usage.limit })} + + ); +} + /** Where new work will be saved, as the website's CurrentDebateStrip says it. In one-off mode it offers the way back to the Current Debate. */ export function WorkingIn() { const { t } = useTranslation(); @@ -124,6 +138,7 @@ const styles = StyleSheet.create({ titleBlock: { gap: 4, marginBottom: 2 }, title: { fontSize: 30, lineHeight: 36, letterSpacing: -0.8 }, subtitle: { fontSize: 16, lineHeight: 22 }, + usage: { fontSize: 14, lineHeight: 19, marginTop: 2 }, strip: { borderWidth: 2, borderRadius: radius.field, paddingHorizontal: 14, paddingVertical: 10 }, stripText: { fontSize: 14.5, lineHeight: 20 }, error: { borderWidth: 2, borderRadius: radius.card, padding: 14, gap: 4 }, diff --git a/apps/mobile/src/lib/ai-request.ts b/apps/mobile/src/lib/ai-request.ts index 9af7ebd77..4edb0b49e 100644 --- a/apps/mobile/src/lib/ai-request.ts +++ b/apps/mobile/src/lib/ai-request.ts @@ -84,6 +84,8 @@ async function accessToken(): Promise { return token; } +const REFUSAL_CODES = new Set(['limit_reached', 'weekly_budget_reached', 'trial_ended', 'trial_expired', 'payment_failed', 'subscription_ended']); + export function errorFrom(status: number | undefined, payload: Record | null, fallback: string): AIError { const message = typeof payload?.error === 'string' ? payload.error : typeof payload?.message === 'string' ? payload.message : fallback; const code = typeof payload?.code === 'string' ? payload.code : undefined; @@ -91,8 +93,9 @@ export function errorFrom(status: number | undefined, payload: Record Promise }): Pro export type FunctionResponse = Awaited>; +const settledListeners = new Set<() => void>(); + +/** Called after every AI request ends, however it ends, as the website refreshes its usage count (refreshAIUsage). */ +export function onAIRequestSettled(listener: () => void): () => void { + settledListeners.add(listener); + return () => settledListeners.delete(listener); +} + /** One POST with the website's headers, its timeout (200 s unless the feature sets its own) and the caller's abort. */ export async function postFunction( path: string, @@ -146,6 +157,7 @@ export async function postFunction( } finally { clearTimeout(timeout); signal?.removeEventListener('abort', abortForCaller); + settledListeners.forEach((listener) => listener()); } } diff --git a/apps/mobile/src/lib/refusal.ts b/apps/mobile/src/lib/refusal.ts new file mode 100644 index 000000000..f61ccc824 --- /dev/null +++ b/apps/mobile/src/lib/refusal.ts @@ -0,0 +1,78 @@ +import type { FeatureTab } from '@debatelab/core/ai/feature-configs'; +import type { TFunction } from 'i18next'; + +import { AIError, type AIErrorDetails } from './ai-request'; + +/** + * The website's limit-reached and model-restricted dialogs + * (LimitReachedModal, ModelRestrictedModal, limit-message.ts): which one a + * refusal opens, and its words. The app shows them as a sheet (app/limit.tsx). + */ + +/** The website's TRIAL_DAYS (src/config/site.json pricing.trialDays). */ +const TRIAL_DAYS = 3; + +/** trial_expired is what functions older than the other access codes sent; it reads as trial_ended. */ +export type LimitVariant = 'limit_reached' | 'trial_ended' | 'payment_failed' | 'subscription_ended' | 'weekly_budget_reached'; + +/** The dialog a refusal code opens, as the website's variantForCode. */ +export function limitVariant(code: string | undefined): LimitVariant { + if (code === 'trial_expired' || code === 'trial_ended') return 'trial_ended'; + if (code === 'payment_failed' || code === 'subscription_ended' || code === 'weekly_budget_reached') return code; + return 'limit_reached'; +} + +export const LIMIT_TITLES: Record = { + limit_reached: 'limitModal.limitReached', + trial_ended: 'limitModal.trialEnded', + payment_failed: 'limitModal.paymentFailed', + subscription_ended: 'limitModal.subscriptionEnded', + weekly_budget_reached: 'limitModal.weeklyReached', +}; + +/** The website's subscriptionLimitMessage: the same explanation in every tool. */ +export function limitMessage(t: TFunction, { code, limit, current, units }: AIErrorDetails): string { + const variant = limitVariant(code); + if (variant === 'trial_ended') return t('limitModal.trialEndedDesc', { days: TRIAL_DAYS }); + if (variant === 'payment_failed') return t('limitModal.paymentFailedDesc'); + if (variant === 'subscription_ended') return t('limitModal.subscriptionEndedDesc'); + if (variant === 'weekly_budget_reached') return t('limitModal.weeklyReachedDesc'); + // An admin can set an account's limit to zero; "0 of 0 used" would explain nothing. + if (limit === 0) return t('limitModal.zeroLimitDesc'); + if (typeof limit !== 'number') return t('limitModal.limitReachedGenericDesc'); + const weight = units ?? 1; + // A model heavier than the whole daily limit never fits, so waiting does not help. + if (weight > 1 && weight > limit) return t('limitModal.weightedTooLargeDesc', { units: weight, limit }); + const remaining = Math.max(0, limit - (current || 0)); + // A weighted model can be refused while lighter requests still fit today. + if (weight > 1 && remaining > 0) return t('limitModal.weightedDesc', { units: weight, remaining }); + // A limit lowered during the day must not read "7 of 5 used". + return t('limitModal.limitReachedDesc', { current: Math.min(current ?? limit, limit), limit }); +} + +/** The route parameters of the limit sheet for a refusal, or null when the error is not one. */ +export function refusalParams(error: unknown, feature: FeatureTab): Record | null { + if (!(error instanceof AIError)) return null; + const { details } = error; + if (error.kind === 'modelRestricted') return { kind: 'model', feature }; + if (error.kind !== 'limitReached' && error.kind !== 'trialExpired') return null; + return { + kind: 'limit', + feature, + code: details.code ?? (error.kind === 'trialExpired' ? 'trial_ended' : 'limit_reached'), + ...(details.limit !== undefined ? { limit: String(details.limit) } : {}), + ...(details.current !== undefined ? { current: String(details.current) } : {}), + ...(details.units !== undefined ? { units: String(details.units) } : {}), + }; +} + +/** + * Opens the website's dialog for a refusal as the limit sheet, instead of an + * inline error. Returns false when the error is not a refusal. + */ +export function openRefusal(router: { push: (href: { pathname: '/limit'; params: Record }) => void }, error: unknown, feature: FeatureTab): boolean { + const params = refusalParams(error, feature); + if (!params) return false; + router.push({ pathname: '/limit', params }); + return true; +} diff --git a/apps/mobile/src/lib/session-quota.ts b/apps/mobile/src/lib/session-quota.ts new file mode 100644 index 000000000..a636a6c3f --- /dev/null +++ b/apps/mobile/src/lib/session-quota.ts @@ -0,0 +1,89 @@ +import { useFocusEffect } from 'expo-router'; +import { useCallback, useRef, useState } from 'react'; + +import { AIError, payloadOf, postFunction } from './ai-request'; +import { useAuth } from './auth'; +import { fetchFeatureConfig } from './feature-config'; + +/** + * What a new Crossfire or Sparring round costs and whether today's uses + * cover it, asked the way the website's checkSessionQuota asks: the ai + * function with `quotaCheck`, which reserves nothing. Crossfire runs on + * Ask's model settings, Sparring on its own. + */ + +export interface SessionQuota { + allowed: boolean; + /** Why the server would refuse: a limit or access code, or upgrade_required. */ + code?: string; + limit?: number; + current?: number; + /** Uses the whole round costs: one, weighted by the model. */ + units: number; +} + +type RoundFeature = 'crossfire' | 'sparring'; + +/** How long Start waits for the check before going ahead without it, as on the website. */ +const QUOTA_CHECK_TIMEOUT_MS = 8_000; + +const numberOrUndefined = (value: unknown) => (typeof value === 'number' && Number.isFinite(value) ? value : undefined); + +/** Null when the server cannot tell (offline, or a server from before round charging); the round's own requests are still checked. */ +export async function checkSessionQuota(userId: string, feature: RoundFeature): Promise { + try { + const config = await fetchFeatureConfig(userId, feature === 'crossfire' ? 'ask' : 'sparring'); + return await postFunction('ai', { feature, model: config.model, quotaCheck: true }, undefined, async (response) => { + const data = await payloadOf(response); + const quota = data?.quota && typeof data.quota === 'object' ? (data.quota as Record) : null; + if (!quota || typeof quota.allowed !== 'boolean') return null; + return { + allowed: quota.allowed, + code: typeof quota.code === 'string' ? quota.code : undefined, + limit: numberOrUndefined(quota.limit), + current: numberOrUndefined(quota.current), + units: numberOrUndefined(quota.units) ?? 1, + }; + }, QUOTA_CHECK_TIMEOUT_MS); + } catch { + return null; + } +} + +/** Uses left today by this check, or null when unknown. */ +export function sessionQuotaRemaining(quota: SessionQuota | null): number | null { + if (!quota || quota.limit === undefined || quota.current === undefined) return null; + return Math.max(0, quota.limit - quota.current); +} + +/** The refusal a round would meet, as the error the limit sheet reads; null when it fits or is unknown. */ +export function sessionQuotaRefusal(quota: SessionQuota | null): AIError | null { + if (!quota || quota.allowed) return null; + if (quota.code === 'upgrade_required') return new AIError('modelRestricted', 'This model requires a Pro subscription.'); + const trial = quota.code === 'trial_ended' || quota.code === 'trial_expired'; + return new AIError(trial ? 'trialExpired' : 'limitReached', 'Not enough AI uses today', { code: quota.code, limit: quota.limit, current: quota.current, units: quota.units }); +} + +/** The round's cost while its setup is on screen, read again whenever the screen comes back into view. */ +export function useSessionQuota(feature: RoundFeature) { + const { session } = useAuth(); + const userId = session?.user.id ?? null; + const [quota, setQuota] = useState(null); + const reads = useRef(0); + + const check = useCallback(async (): Promise => { + const read = ++reads.current; + if (!userId) return null; + const next = await checkSessionQuota(userId, feature); + if (read === reads.current) setQuota(next); + return next; + }, [feature, userId]); + + useFocusEffect( + useCallback(() => { + void check(); + }, [check]), + ); + + return { quota, check }; +} diff --git a/apps/mobile/src/lib/usage.ts b/apps/mobile/src/lib/usage.ts new file mode 100644 index 000000000..1d31562c7 --- /dev/null +++ b/apps/mobile/src/lib/usage.ts @@ -0,0 +1,60 @@ +import { useEffect, useSyncExternalStore } from 'react'; + +import { onAIRequestSettled } from './ai-request'; +import { useAuth } from './auth'; +import { fetchSubscription } from './subscription'; + +/** + * Today's AI uses left, as the website's DailyUsage reads them: the server's + * own weighted counter against the plan's limit. Read when a tool opens and + * again after every AI request, so every tool's line agrees with You. + */ + +export interface DailyUsage { + remaining: number; + limit: number; +} + +let state: { userId: string; usage: DailyUsage | null } | null = null; +let readFor: string | null = null; +let reads = 0; +const listeners = new Set<() => void>(); + +function emit() { + listeners.forEach((listener) => listener()); +} + +/** Reads the count for this account; a later read, or another account's, wins. */ +function refresh(userId: string) { + const read = ++reads; + readFor = userId; + fetchSubscription(userId) + .then((plan) => { + if (read !== reads) return; + const usage = plan.dailyUsed !== null && plan.dailyLimit > 0 ? { remaining: Math.max(0, plan.dailyLimit - plan.dailyUsed), limit: plan.dailyLimit } : null; + state = { userId, usage }; + emit(); + }) + .catch((cause: unknown) => console.warn('Could not read today\'s AI uses:', cause instanceof Error ? cause.message : cause)); +} + +// Only while a line is on screen; a request then changes the count. +onAIRequestSettled(() => { + if (listeners.size > 0 && readFor) refresh(readFor); +}); + +function subscribe(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); +} + +/** The signed-in account's uses left today, or null while unknown or when the plan has none. */ +export function useDailyUsage(): DailyUsage | null { + const { session } = useAuth(); + const userId = session?.user.id ?? null; + const current = useSyncExternalStore(subscribe, () => state); + useEffect(() => { + if (userId) refresh(userId); + }, [userId]); + return userId && current?.userId === userId ? current.usage : null; +} diff --git a/src/locales/en.json b/src/locales/en.json index ba2f2b2b5..5d4799759 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2816,7 +2816,8 @@ "deleteBrainstormTitle": "Delete brainstorm?", "deleteBrainstormDesc": "This removes the brainstorm from your history.", "deleteFactoidsTitle": "Delete factoids?", - "deleteFactoidsDesc": "This removes the factoids from your history." + "deleteFactoidsDesc": "This removes the factoids from your history.", + "buildFlowchart": "Build flowchart from case" }, "attack": { "caseRequired": "Paste or write the case you want attacked." diff --git a/src/locales/es.json b/src/locales/es.json index dd3b5fc5c..a5786134d 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -2816,7 +2816,8 @@ "deleteBrainstormTitle": "¿Eliminar la lluvia de ideas?", "deleteBrainstormDesc": "Esto elimina la lluvia de ideas de tu historial.", "deleteFactoidsTitle": "¿Eliminar los datos?", - "deleteFactoidsDesc": "Esto elimina los datos de tu historial." + "deleteFactoidsDesc": "Esto elimina los datos de tu historial.", + "buildFlowchart": "Crear diagrama desde el caso" }, "attack": { "caseRequired": "Pega o escribe el caso que quieres atacar." diff --git a/src/locales/hu.json b/src/locales/hu.json index b01b54fad..f6ed29686 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -2816,7 +2816,8 @@ "deleteBrainstormTitle": "Törlöd az ötletelést?", "deleteBrainstormDesc": "Ez eltávolítja az ötletelést az előzményeid közül.", "deleteFactoidsTitle": "Törlöd a tényeket?", - "deleteFactoidsDesc": "Ez eltávolítja a tényeket az előzményeid közül." + "deleteFactoidsDesc": "Ez eltávolítja a tényeket az előzményeid közül.", + "buildFlowchart": "Folyamatábra az esetből" }, "attack": { "caseRequired": "Illeszd be vagy írd le a megtámadandó esetet." From df51519607904fb4cf64e847486b1d87b664680a Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 28 Oct 2026 12:23:54 +0000 Subject: [PATCH 05/14] Regenerate one part of a case, undo it, and save a case with a title A case's menu can regenerate one part (the opening, the arguments, the case structure, the predicted opposition or the counters) from a new generation, as the website's section cards do, with the website's merge now shared through packages/core. Undo goes back to the version before, and writes it back to the saved row so History matches the screen. Save opens a sheet with the website's title prompt, and a saved case is confirmed in mint with a check mark. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/scripts/screenshots.ts | 115 +++++++++++++- apps/mobile/src/app/_layout.tsx | 1 + apps/mobile/src/app/case/[id].tsx | 32 ++++ apps/mobile/src/app/save-case.tsx | 86 +++++++++++ apps/mobile/src/components/document.tsx | 56 ++++--- .../src/components/regenerating-strip.tsx | 28 ++++ apps/mobile/src/lib/case-edits.ts | 146 ++++++++++++++++++ apps/mobile/src/lib/open-results.ts | 14 +- apps/mobile/src/lib/results.ts | 11 +- apps/mobile/src/lib/use-result-document.ts | 62 ++++---- packages/core/src/ai/generate-output.ts | 19 +++ src/locales/en.json | 7 +- src/locales/es.json | 7 +- src/locales/hu.json | 7 +- src/pages/GeneratePage.tsx | 11 +- 15 files changed, 529 insertions(+), 73 deletions(-) create mode 100644 apps/mobile/src/app/save-case.tsx create mode 100644 apps/mobile/src/components/regenerating-strip.tsx create mode 100644 apps/mobile/src/lib/case-edits.ts diff --git a/apps/mobile/scripts/screenshots.ts b/apps/mobile/scripts/screenshots.ts index 70c1ca86f..1d1c71718 100644 --- a/apps/mobile/scripts/screenshots.ts +++ b/apps/mobile/scripts/screenshots.ts @@ -11,7 +11,7 @@ * ONLY=iphone-light-en limits a run to the combinations whose name starts with it; * separate several with commas (ONLY=iphone-light-en,ipad-dark-hu). * SCREENS=you,timer limits the signed-in screens to those groups: debate, - * library, prepare, practice, you, empty, ask, results, brainstorm, factoids, limits, + * library, prepare, practice, you, empty, ask, results, brainstorm, factoids, case-edits, limits, * timer, crossfire, sparring, flowchart, one-off, history, announcements, * account, model. Sign-in screens are always shot. * ONLY=offline runs only the offline reading check (see shootOfflineScreens). @@ -693,6 +693,83 @@ async function shootTool(context: BrowserContext, prefix: string, language: stri await page.close(); } +const MENU = { + regenerate: /^(Regenerate a section|Szakasz újragenerálása|Regenerar una sección)$/, + arguments: /^(Regenerate Arguments|Érvek újragenerálása|Regenerar Argumentos)$/, + save: /^(Save|Mentés|Guardar)$/, +}; + +/** + * Regenerate, Undo and Save on a case, from the fixed case: the regenerate + * menu, a regeneration held open, the case after it, the Save case sheet and + * the saved confirmation. The case's first save to its debate is answered + * with an error, so the case stays unsaved and offers Save; the Save sheet's + * write is answered locally. Nothing reaches a model or the database. + */ +async function shootCaseEdits(context: BrowserContext, prefix: string, language: string, problems: string[]) { + const open = async (holdRegeneration: boolean) => { + const page = await context.newPage(); + const seen: string[] = []; + watchErrors(page, seen, `${prefix} case edits`); + page.on('dialog', (dialog) => void dialog.dismiss()); + await pinSettings(page, language); + await fakeResults(page, null); + let generations = 0; + await page.route(`https://${STAGING_HOST}/functions/v1/ai`, async (route) => { + generations += 1; + if (holdRegeneration && generations > 1) return; // Never answered: the strip shows. + return route.fulfill({ status: 200, headers: { 'access-control-allow-origin': '*' }, json: FAKE_OUTPUTS.generate }); + }); + let caseWrites = 0; + await page.route(`${REST}/cases*`, (route) => { + if (route.request().method() !== 'POST') return route.continue(); + caseWrites += 1; + return caseWrites === 1 + ? route.fulfill({ status: 500, headers: { 'access-control-allow-origin': '*' }, json: { message: 'screenshot: not recorded' } }) + : route.fulfill({ status: 201, json: FAKE_ROW('cases') }); + }); + await page.goto(`${ORIGIN}/generate?from=prepare`); + await page.getByTestId('generate-motion').waitFor({ timeout: 30_000 }); + await page.waitForFunction(() => (document.querySelector('[data-testid="generate-motion"]') as HTMLTextAreaElement | null)?.value.trim()); + await page.getByRole('button', { name: /^(Generate debate case|Vitaeset generálása|Generar caso de debate)$/i }).click(); + await page.getByTestId('case-document').waitFor({ timeout: 30_000 }); + await page.waitForTimeout(3800); // The arrival notice fades. + return { page, seen }; + }; + const regenerateArguments = async (page: Page, shotMenu: boolean) => { + await page.getByTestId('document-more').click(); + await page.getByRole('menuitem', { name: MENU.regenerate }).click(); + await page.getByTestId('regenerate-menu').waitFor(); + if (shotMenu) await shoot(page, `${prefix}-regenerate-menu`); + await page.getByTestId('regenerate-menu').getByRole('menuitem', { name: MENU.arguments }).click(); + }; + const finish = (seen: string[]) => problems.push(...seen.filter((problem) => !problem.includes('status of 500'))); + + const held = await open(true); + await regenerateArguments(held.page, true); + await held.page.getByTestId('regenerating').waitFor({ timeout: 15_000 }); + await shoot(held.page, `${prefix}-case-regenerating`); + await held.page.close(); + finish(held.seen); + + const { page, seen } = await open(false); + await regenerateArguments(page, false); + await page.getByTestId('document-notice').waitFor({ timeout: 15_000 }); + await shoot(page, `${prefix}-case-regenerated`); + await page.waitForTimeout(3800); + await page.getByTestId('document-more').click(); + await page.getByRole('menuitem', { name: MENU.save }).click(); + await page.getByTestId('save-case-sheet').waitFor({ timeout: 15_000 }); + await page.waitForTimeout(500); + await shoot(page, `${prefix}-save-case`); + await page.getByTestId('save-case-confirm').click(); + await page.getByTestId('save-case-sheet').waitFor({ state: 'detached', timeout: 15_000 }); + await page.getByTestId('document-notice').waitFor({ timeout: 15_000 }); + await shoot(page, `${prefix}-case-saved`); + await page.close(); + finish(seen); +} + /** * The limit and model sheets: Generate answered with the ai function's * refusals (402 limit_reached with its counts, then 403 upgrade_required). @@ -928,7 +1005,35 @@ async function shootAskScreens(context: BrowserContext, prefix: string, language await streaming.getByTestId('ask-streaming').waitFor({ timeout: 15_000 }); await streaming.waitForTimeout(STREAM_HOLD_WORDS * 15 + 600); await shoot(streaming, `${prefix}-ask-streaming`); + // Stop keeps the answer so far, marked as stopped; the chat write is answered locally. + await streaming.getByTestId('ask-stop').click(); + await streaming.getByTestId('ask-answer').waitFor({ timeout: 15_000 }); + await streaming.waitForTimeout(300); + await shoot(streaming, `${prefix}-ask-stopped`); await streaming.close(); + + // Clarify mode with a text file attached, then the answer saved to the Library's Ask folder. + // The file is read in the page; the source write is answered locally. + const clarify = await ask(false); + for (const table of ['sources', 'source_folders']) { + await clarify.route(`${REST}/${table}*`, (route) => + route.request().method() === 'GET' ? route.continue() : route.fulfill({ status: 201, json: FAKE_ROW(table) }), + ); + } + await clarify.getByTestId('ask-mode-clarify').click(); + const chooser = clarify.waitForEvent('filechooser'); + await clarify.getByTestId('ask-attach').click(); + await (await chooser).setFiles({ name: 'rebuttal-notes.md', mimeType: 'text/markdown', buffer: Buffer.from('# Notes\n\nNight buses: routes added in year one.\n') }); + await clarify.getByTestId('ask-attachments').waitFor({ timeout: 15_000 }); + await clarify.getByTestId('ask-input').fill(ASK_QUESTION); + await shoot(clarify, `${prefix}-ask-attached`); + await clarify.getByTestId('ask-send').click(); + await clarify.getByTestId('ask-answer').waitFor({ timeout: 15_000 }); + await clarify.getByTestId('ask-save').click(); + await clarify.getByTestId('ask-saved').waitFor({ timeout: 15_000 }); + await clarify.waitForTimeout(300); + await shoot(clarify, `${prefix}-ask-saved`); + await clarify.close(); } // A made-up Crossfire exchange and debrief, in English and Hungarian, so the Hungarian shots carry no English model text. @@ -1817,8 +1922,8 @@ async function main() { shots += 2; } if (want('ask')) { - await shootAskScreens(signedIn, prefix, language, problems); - shots += 3; + await shootAskScreens(signedIn, prefix, language, problems).catch(failedIn(signedIn, prefix, 'ask')); + shots += 6; } if (want('results')) { await shootResultScreens(signedIn, prefix, language, problems).catch(failedIn(signedIn, prefix, 'results')); @@ -1829,6 +1934,10 @@ async function main() { await shootTool(signedIn, prefix, language, problems, tool); shots += 7; } + if (want('case-edits')) { + await shootCaseEdits(signedIn, prefix, language, problems).catch(failedIn(signedIn, prefix, 'case-edits')); + shots += 5; + } if (want('limits')) { await shootLimitSheets(signedIn, prefix, language, problems).catch(failedIn(signedIn, prefix, 'limits')); shots += 2; diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index c8b2a0327..7e55828c0 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -96,6 +96,7 @@ function Root() { {/* The website's limit and model dialogs; short, so the sheet stops at half height. */} + {/* Full screen above the tabs, as in mock 07. */} {/* History, full screen above the tabs like the documents it opens. */} diff --git a/apps/mobile/src/app/case/[id].tsx b/apps/mobile/src/app/case/[id].tsx index 0eb9bbc80..52f3d5131 100644 --- a/apps/mobile/src/app/case/[id].tsx +++ b/apps/mobile/src/app/case/[id].tsx @@ -1,3 +1,4 @@ +import type { CaseSection } from '@debatelab/core/ai/generate-output'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -9,7 +10,9 @@ import { openCitation } from '@/components/citation-target'; import { DocumentScreen, DocumentTitle } from '@/components/document'; import { DocumentHeader, type HeaderAction } from '@/components/document-header'; import { DocumentStatus } from '@/components/document-status'; +import { RegeneratingStrip } from '@/components/regenerating-strip'; import { backLabel } from '@/lib/back-label'; +import { useCaseEdits } from '@/lib/case-edits'; import { useDebates } from '@/lib/debate-context'; import { formatWhen } from '@/lib/dates'; import { copyText, sharePdf, shareText } from '@/lib/export'; @@ -27,7 +30,9 @@ export default function CaseScreen() { const doc = useResultDocument('case', id); const { activeDebateId } = useDebates(); const [exportOpen, setExportOpen] = useState(false); + const [regenerateOpen, setRegenerateOpen] = useState(false); const { setNotice } = doc; + const edits = useCaseEdits(doc.state.status === 'ready' ? doc.state.key : null, setNotice); // Problems from saving a fresh result are told once, on arrival. useEffect(() => { @@ -72,7 +77,23 @@ export default function CaseScreen() { else setNotice(`${t('generate.flowchartImportFailed')}. ${t(outcome === 'noData' ? 'generate.flowchartNoData' : 'generate.flowchartOtherDebate')}`); }; + // The website's section cards, in their order; the opening shows only when the case has one. + const sections: { key: CaseSection; title: string }[] = [ + ...(output.exordio && (output.exordio.hook || output.exordio.transition) ? [{ key: 'exordio' as const, title: t('generateResults.exordio') }] : []), + { key: 'contentions', title: t('generateResults.arguments') }, + { key: 'structure', title: t('generateResults.caseStructure') }, + { key: 'opponent', title: t('generateResults.predictedOpponent') }, + { key: 'counters', title: t('generateResults.preparedCounters') }, + ]; + const regeneratingTitle = sections.find((section) => section.key === edits.regenerating)?.title; + const actions: HeaderAction[] = [ + ...(edits.regenerating + ? [] + : [{ key: 'regenerate', label: t('mobile.results.regenerateSection'), symbol: 'arrow.clockwise' as const, icon: 'reset' as const, onPress: () => setRegenerateOpen(true) }]), + ...(edits.canUndo && !edits.regenerating + ? [{ key: 'undo', label: t('generateResults.undo'), symbol: 'arrow.uturn.backward' as const, icon: 'history' as const, onPress: () => void edits.undo() }] + : []), { key: 'crossfire', label: t('crossfire.openFromResult'), symbol: 'person.2.wave.2', icon: 'swords', onPress: openCrossfire }, { key: 'flowchart', label: t('mobile.results.buildFlowchart'), symbol: 'point.3.connected.trianglepath.dotted', icon: 'branch', onPress: buildFlowchart }, { key: 'discuss', label: t('common.discuss'), symbol: 'bubble.left.and.bubble.right', icon: 'chat', onPress: () => router.push({ pathname: '/ask', params: { prompt: discussCaseMessage(output) } }) }, @@ -88,6 +109,7 @@ export default function CaseScreen() { testID="case-document" outline notice={doc.notice} + noticeTone={doc.noticeTone} header={ run(() => shareText(title, caseToText(output)))} actions={actions} />} // "Attack this case" is the longer label, so it gets the wider button. actionWidths={[1.3, 1]} @@ -104,7 +126,17 @@ export default function CaseScreen() { }> + {regeneratingTitle ? : null} openCitation(router, target)} /> + ({ key: section.key, label: t('common.regenerate', { title: section.title }), icon: 'reset' as const }))} + cancelLabel={t('common.cancel')} + onClose={() => setRegenerateOpen(false)} + onSelect={(choice) => void edits.regenerate(choice as CaseSection)} + /> (); + const { session } = useAuth(); + const held = key ? openResult(key, 'case') : undefined; + const motion = typeof held?.input.motion === 'string' ? held.input.motion : ''; + const [title, setTitle] = useState(() => motion || held?.output.caseStructure.thesis.slice(0, 50) || held?.title || ''); + const [saving, setSaving] = useState(false); + const [failed, setFailed] = useState(false); + + const save = async () => { + const userId = session?.user.id; + const name = title.trim(); + // The case on screen when Save is pressed is the one saved, even if a regeneration lands meanwhile. + const current = key ? openResult(key, 'case') : undefined; + if (!userId || !name || !current || saving) return; + setSaving(true); + setFailed(false); + try { + const savedId = await saveCase(userId, name, current.output); + updateOpenResult(key, { savedId, title: name }); + router.back(); + } catch (cause) { + console.warn('Could not save the case:', cause instanceof Error ? cause.message : cause); + setSaving(false); + setFailed(true); + } + }; + + return ( + + + + {t('generate.saveCase')} + + router.back()} disabled={saving} /> + + + {t('generate.savePrompt')} + + void save()} + editable={!saving} + testID="save-case-title" + /> + {failed ? ( + + {`${t('ask.saveFailed')}. ${t('generate.saveFailedDesc')}`} + + ) : null} + void save()} testID="save-case-confirm" /> + + ); +} + +const styles = StyleSheet.create({ + header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }, + title: { fontSize: 21, lineHeight: 26, letterSpacing: -0.3, flexShrink: 1 }, + body: { fontSize: 15, lineHeight: 21 }, +}); diff --git a/apps/mobile/src/components/document.tsx b/apps/mobile/src/components/document.tsx index 435a800f2..d810837c0 100644 --- a/apps/mobile/src/components/document.tsx +++ b/apps/mobile/src/components/document.tsx @@ -63,6 +63,31 @@ function PinnedBar({ ); } +export type NoticeTone = 'info' | 'success'; + +/** A short notice above the pinned buttons: dark, or mint with a check mark when it confirms something done. */ +function DocumentNotice({ text, tone = 'info', bottom }: { text: string; tone?: NoticeTone; bottom: number }) { + const { colors } = useTheme(); + const success = tone === 'success'; + return ( + + {/* As the website's success toast: the mint check carries the meaning, the text stays readable. */} + {success ? ( + + + + ) : null} + + {text} + + + ); +} + /** The scrolling page under the header, leaving room for the pinned actions. */ export function DocumentScreen({ header, @@ -70,6 +95,7 @@ export function DocumentScreen({ actions, actionWidths, notice, + noticeTone, testID, outline = false, }: { @@ -79,6 +105,8 @@ export function DocumentScreen({ /** How the pinned buttons share the width, such as [1, 1.4]; equally when absent. */ actionWidths?: number[]; notice?: string; + /** A notice that confirms something done, such as a save: mint with a check mark. */ + noticeTone?: NoticeTone; testID?: string; /** A saved document: on a wide iPad window its headed parts are listed in a rail beside it (iPad mock A). */ outline?: boolean; @@ -92,7 +120,7 @@ export function DocumentScreen({ const [barHeight, setBarHeight] = useState(PINNED_FADE + PINNED_HEIGHT + bottom); if (outline && layout !== 'phone' && width >= RAIL_MIN_WIDTH) { return ( - + {children} ); @@ -106,15 +134,7 @@ export function DocumentScreen({ {children} - {notice ? ( - - {notice} - - ) : null} + {notice ? : null} {actions ? : null} ); @@ -127,6 +147,7 @@ function RailDocument({ actions, actionWidths, notice, + noticeTone, testID, }: { header: ReactNode; @@ -134,6 +155,7 @@ function RailDocument({ actions?: ReactNode; actionWidths?: number[]; notice?: string; + noticeTone?: NoticeTone; testID?: string; }) { const { colors } = useTheme(); @@ -162,15 +184,7 @@ function RailDocument({ {children} - {notice ? ( - - {notice} - - ) : null} + {notice ? : null} {actions ? : null} @@ -450,7 +464,9 @@ const styles = StyleSheet.create({ pinnedRowRail: { paddingLeft: 16, paddingRight: 24 }, pressed: { opacity: 0.7 }, notice: { position: 'absolute', left: 24, right: 24, borderRadius: 14, paddingHorizontal: 16, paddingVertical: 11, alignSelf: 'center', maxWidth: 560 }, - noticeText: { fontSize: 15, lineHeight: 20, textAlign: 'center' }, + noticeRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 10, borderWidth: 2 }, + noticeCheck: { width: 24, height: 24, borderRadius: 12, alignItems: 'center', justifyContent: 'center' }, + noticeText: { fontSize: 15, lineHeight: 20, textAlign: 'center', flexShrink: 1 }, titleBlock: { gap: 6, marginBottom: 4 }, title: { fontSize: 30, lineHeight: 36, letterSpacing: -0.8 }, meta: { fontSize: 15, lineHeight: 20 }, diff --git a/apps/mobile/src/components/regenerating-strip.tsx b/apps/mobile/src/components/regenerating-strip.tsx new file mode 100644 index 000000000..929d4f047 --- /dev/null +++ b/apps/mobile/src/components/regenerating-strip.tsx @@ -0,0 +1,28 @@ +import { useTranslation } from 'react-i18next'; +import { ActivityIndicator, StyleSheet, View } from 'react-native'; + +import { OwlAvatar } from './owl'; +import { Text } from './text'; + +import { radius } from '@/theme/colors'; +import { useTheme } from '@/theme/use-theme'; + +/** The website's "Regenerating Arguments…" overlay, as a strip at the top of the case while one part is written again. */ +export function RegeneratingStrip({ title }: { title: string }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + return ( + + + + {t('common.regenerating', { title })} + + + + ); +} + +const styles = StyleSheet.create({ + strip: { flexDirection: 'row', alignItems: 'center', gap: 12, borderWidth: 2, borderRadius: radius.field, paddingHorizontal: 12, paddingVertical: 9 }, + text: { flex: 1, fontSize: 15, lineHeight: 20 }, +}); diff --git a/apps/mobile/src/lib/case-edits.ts b/apps/mobile/src/lib/case-edits.ts new file mode 100644 index 000000000..78937ad79 --- /dev/null +++ b/apps/mobile/src/lib/case-edits.ts @@ -0,0 +1,146 @@ +import { mergeRegeneratedSection, type CaseSection } from '@debatelab/core/ai/generate-output'; +import type { GenerateInput, GenerateOutput } from '@debatelab/core/ai/schemas/generate'; +import { useRouter } from 'expo-router'; +import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { AIError, isAbortError } from './ai-request'; +import { useAuth } from './auth'; +import { buildDebateContext } from './context-request'; +import { openResult, updateOpenResult, type OpenResult } from './open-results'; +import { openRefusal } from './refusal'; +import { autosaveSources, generateCase, keepSourceAssessments, updateResult } from './results'; +import { sessionSlot } from './session-memory'; +import { useSettings } from './settings'; + +/** + * Generate's Regenerate and Undo, as the website's GeneratePage: one part of + * a case is written again from a whole new generation, and Undo goes back to + * the version before. The earlier versions are kept for the session, per + * case. Unlike the website, which leaves the saved row on the newer version, + * Undo writes the restored version back, so History shows what is on screen. + */ + +// The website keeps the last three generations. +const MAX_VERSIONS = 3; +const versions = sessionSlot(() => new Map()); +const listeners = new Set<() => void>(); +let changes = 0; + +function changed() { + changes += 1; + listeners.forEach((listener) => listener()); +} + +function subscribe(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); +} + +/** The case's earlier versions this session, newest last. */ +function earlier(key: string): GenerateOutput[] { + return versions.get().get(key) ?? []; +} + +/** The request a regeneration repeats: the case's own form, as the website falls back to its defaults. */ +function regenerationInput(result: OpenResult<'case'>): GenerateInput | null { + const input = result.input as Partial; + const motion = result.output.metadata?.motion?.trim() || input.motion?.trim(); + if (!motion) return null; + return { + motion, + side: result.output.metadata?.side || input.side || 'pro', + format: input.format || 'custom', + role: input.role || 'r1', + length: input.length || 'standard', + audience: input.audience || 'intermediate', + tone: input.tone || 'formal', + }; +} + +export function useCaseEdits(key: string | null, setNotice: (text: string) => void) { + const { t } = useTranslation(); + const router = useRouter(); + const { session } = useAuth(); + const userId = session?.user.id ?? null; + const { outputLanguage, readFeatureConfig } = useSettings(); + const [regenerating, setRegenerating] = useState(null); + const abort = useRef(null); + useSyncExternalStore(subscribe, () => changes); + + // Leaving the case cancels a regeneration still on its way. + useEffect(() => () => abort.current?.abort(), []); + + const saveOutput = useCallback(async (forUser: string, savedId: string | null, output: GenerateOutput) => { + if (!savedId) return; + try { + await updateResult(forUser, 'case', savedId, output); + } catch (cause) { + console.warn('Could not save the changed case:', cause instanceof Error ? cause.message : cause); + setNotice(t('debate.recordFailed')); + } + }, [setNotice, t]); + + const regenerate = useCallback(async (section: CaseSection) => { + if (!key || !userId || regenerating) return; + const base = openResult(key, 'case'); + const input = base ? regenerationInput(base) : null; + if (!base || !input) { + setNotice(`${t('generate.regenerateUnavailable')}. ${t('generate.regenerateUnavailableDesc')}`); + return; + } + const forUser = userId; + const controller = new AbortController(); + abort.current = controller; + setRegenerating(section); + try { + // Against the case's own debate, never whichever is current now, as on the website. + const [config, debateContext] = await Promise.all([ + readFeatureConfig(forUser, 'generate'), + base.debateId ? buildDebateContext(forUser, base.debateId, 'generate', input.side) : Promise.resolve(undefined), + ]); + const { result: next, discoveredSources, assessments } = await generateCase(input, { config, language: outputLanguage, debateContext, signal: controller.signal }); + if (controller.signal.aborted) return; + // An undo or another change replaced the case meanwhile; merging now would revert it. + const latest = openResult(key, 'case'); + if (!latest || latest.output !== base.output) return; + const merged = mergeRegeneratedSection(base.output, next, section); + versions.get().set(key, [...earlier(key), base.output].slice(-MAX_VERSIONS)); + updateOpenResult<'case'>(key, { output: merged, sources: discoveredSources }); + changed(); + setNotice(`${t('generate.regenerated')}. ${t('generate.regeneratedDesc')}`); + await saveOutput(forUser, base.savedId, merged); + await keepSourceAssessments(forUser, base.debateId, assessments, controller.signal); + await autosaveSources(forUser, discoveredSources, 'generate', base.debateId, controller.signal); + } catch (cause) { + if (controller.signal.aborted || isAbortError(cause)) return; + if (openRefusal(router, cause, 'generate')) return; + const kind = cause instanceof AIError ? cause.kind : 'failed'; + if (!(cause instanceof AIError)) console.warn('Could not regenerate the section:', cause instanceof Error ? cause.message : cause); + setNotice(`${t('generate.failed')}. ${t(`mobile.results.errors.${kind}`)}`); + } finally { + if (abort.current === controller) { + abort.current = null; + setRegenerating(null); + } + } + }, [key, outputLanguage, readFeatureConfig, regenerating, router, saveOutput, setNotice, t, userId]); + + const undo = useCallback(async () => { + if (!key || !userId || regenerating) return; + const held = openResult(key, 'case'); + const previous = earlier(key).at(-1); + if (!held || !previous) { + setNotice(t('generate.noUndo')); + return; + } + versions.get().set(key, earlier(key).slice(0, -1)); + // Earlier versions do not carry their researched sources, as on the website. + updateOpenResult<'case'>(key, { output: previous, sources: [] }); + changed(); + setNotice(`${t('generate.undone')}. ${t('generate.undoneDesc')}`); + await saveOutput(userId, held.savedId, previous); + }, [key, regenerating, saveOutput, setNotice, t, userId]); + + return { regenerating, regenerate, canUndo: key !== null && earlier(key).length > 0, undo }; +} diff --git a/apps/mobile/src/lib/open-results.ts b/apps/mobile/src/lib/open-results.ts index a9a85321f..fcb3281dd 100644 --- a/apps/mobile/src/lib/open-results.ts +++ b/apps/mobile/src/lib/open-results.ts @@ -37,10 +37,20 @@ export function openResult(key: string, kind: K): OpenResu return result?.kind === kind ? (result as OpenResult) : undefined; } -export function updateOpenResult(key: string, patch: Partial>) { +const listeners = new Set<() => void>(); + +/** Called whenever a held result changes, so a document open on it shows the change. */ +export function subscribeOpenResults(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function updateOpenResult(key: string, patch: Partial, 'savedId' | 'title' | 'output' | 'sources'>>) { const results = store.get(); const result = results.get(key); - if (result) results.set(key, { ...result, ...patch }); + if (!result) return; + results.set(key, { ...result, ...patch } as OpenResult); + listeners.forEach((listener) => listener()); } export function forgetOpenResult(key: string) { diff --git a/apps/mobile/src/lib/results.ts b/apps/mobile/src/lib/results.ts index 8cf2c6012..e1a759d73 100644 --- a/apps/mobile/src/lib/results.ts +++ b/apps/mobile/src/lib/results.ts @@ -19,7 +19,7 @@ import type { DebateContextRequest } from '@debatelab/core/contract/debate-conte import { AIError, postAI, PROVIDER, responseExtras, type ResponseExtras } from './ai-request'; import type { FeatureConfig } from './feature-config'; import { debateListScopes, deletedListScopes } from './debates'; -import { afterWrite, cachedRead, readFailed, type CopyMark } from './offline-cache'; +import { afterWrite, cachedRead, readFailed, updateCopy, type CopyMark } from './offline-cache'; import { saveResearchedSource, applySourceAssessments, fetchSourceIdsInFolder, removeSource } from './sources'; import { supabase } from './supabase'; @@ -240,6 +240,15 @@ export async function recordResult( return (data as { id: string }).id; } +/** Replaces a saved result's output, as the website's updateRecordedResult after a regenerated section. */ +export async function updateResult(userId: string, kind: K, id: string, output: OutputFor[K]): Promise { + const { data, error } = await supabase.from(TABLES[kind]).update({ output }).eq('user_id', userId).eq('id', id).select('id'); + if (error) throw new Error(error.message); + if (!data || data.length === 0) throw new Error(`The ${kind} is no longer saved`); + // The saved copy reads the new output offline too. + updateCopy | null>(userId, `result:${kind}`, id, (copy) => (copy ? { ...copy, output } : copy)); +} + /** Saves a case outside any debate, as the website's Save button does for unrecorded work. */ export async function saveCase(userId: string, title: string, output: GenerateOutput): Promise { const { data, error } = await supabase diff --git a/apps/mobile/src/lib/use-result-document.ts b/apps/mobile/src/lib/use-result-document.ts index 4143bd8ff..0a2eb5c9c 100644 --- a/apps/mobile/src/lib/use-result-document.ts +++ b/apps/mobile/src/lib/use-result-document.ts @@ -3,11 +3,13 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import type { TFunction } from 'i18next'; import { useTranslation } from 'react-i18next'; +import type { NoticeTone } from '@/components/document'; + import { useAuth } from './auth'; -import { confirmDestructive, promptText } from './confirm'; +import { confirmDestructive } from './confirm'; import { useDebates } from './debate-context'; -import { forgetOpenResult, keepOpenResult, openResult, updateOpenResult, type OpenResult } from './open-results'; -import { deleteResult, fetchAutoSavedSourceIds, fetchResult, removeSources, saveCase, type ArrivalNotice, type ResultKind } from './results'; +import { forgetOpenResult, keepOpenResult, openResult, subscribeOpenResults, type OpenResult } from './open-results'; +import { deleteResult, fetchAutoSavedSourceIds, fetchResult, removeSources, type ArrivalNotice, type ResultKind } from './results'; import { fetchSources, subscribeSourcesChanged, type Source } from './sources'; import { useCopyHolder } from './use-copy-holder'; @@ -62,6 +64,7 @@ export function useResultDocument(kind: K, id: string) { // The ids belong to the debate they were read for; another debate never sees or deletes them. const [autoSavedFor, setAutoSavedFor] = useState<{ debateId: string; ids: string[] } | null>(null); const [notice, setNoticeState] = useState(''); + const [noticeTone, setNoticeTone] = useState('info'); const [busy, setBusy] = useState(false); const live = useRef(true); const owner = useRef(userId); @@ -75,9 +78,10 @@ export function useResultDocument(kind: K, id: string) { if (noticeTimer.current) clearTimeout(noticeTimer.current); }, []); - const setNotice = useCallback((text: string) => { + const setNotice = useCallback((text: string, tone: NoticeTone = 'info') => { if (!live.current) return; setNoticeState(text); + setNoticeTone(tone); if (noticeTimer.current) clearTimeout(noticeTimer.current); noticeTimer.current = setTimeout(() => { if (live.current) setNoticeState(''); @@ -112,6 +116,22 @@ export function useResultDocument(kind: K, id: string) { }; }, [userId, kind, id, resultCopies]); + // A held result changed elsewhere (a regenerated section, an undo, a save from its sheet) shows here. + const readyKey = state.status === 'ready' ? state.key : null; + useEffect(() => { + if (!readyKey) return; + return subscribeOpenResults(() => { + const held = openResult(readyKey, kind); + if (!held || !live.current) return; + setState((previous) => { + if (previous.status !== 'ready' || previous.key !== readyKey || previous.result === held) return previous; + // Saved from the Save case sheet: the website's "Case saved" confirmation. + if (!previous.result.savedId && held.savedId) setNotice(`${t('generate.saved')}. ${t('generate.savedDesc')}`, 'success'); + return { status: 'ready', key: readyKey, result: held }; + }); + }); + }, [kind, readyKey, setNotice, t]); + const result = state.status === 'ready' ? state.result : null; const resultDebateId = result?.debateId ?? null; @@ -187,33 +207,11 @@ export function useResultDocument(kind: K, id: string) { } }, [kind, result, router, setNotice, state, t, userId]); - /** Saves an unsaved case outside any debate, as the website's Save button. */ - const save = useCallback(async () => { - if (state.status !== 'ready' || state.result.savedId || state.result.kind !== 'case' || !userId) return; - const forUser = userId; - const { key, result: open } = state; - const title = await promptText({ - title: t('generate.saveCase'), - message: t('generate.savePrompt'), - defaultValue: open.title, - confirm: t('common.save'), - cancel: t('common.cancel'), - }); - if (!title?.trim() || !live.current || owner.current !== forUser) return; - setBusy(true); - try { - const savedId = await saveCase(forUser, title.trim(), open.output as OpenResult<'case'>['output']); - updateOpenResult(key, { savedId, title: title.trim() }); - if (!live.current) return; - setState({ status: 'ready', key, result: { ...open, savedId, title: title.trim() } }); - setNotice(t('generate.saved')); - } catch (cause) { - console.warn('Could not save the case:', cause instanceof Error ? cause.message : cause); - setNotice(t('generate.saveFailedDesc')); - } finally { - if (live.current) setBusy(false); - } - }, [setNotice, state, t, userId]); + /** Saves an unsaved case outside any debate with a title, as the website's Save button and dialog. */ + const save = useCallback(() => { + if (state.status !== 'ready' || state.result.savedId || state.result.kind !== 'case') return; + router.push({ pathname: '/save-case', params: { key: state.key } }); + }, [router, state]); const undoAutoSaved = useCallback(async () => { if (!userId || !autoSavedFor || autoSavedFor.ids.length === 0) return; @@ -237,5 +235,5 @@ export function useResultDocument(kind: K, id: string) { } }, [autoSavedFor, loadAutoSaved, setNotice, t, userId]); - return { state, library, autoSaved, notice, setNotice, busy, remove, save, undoAutoSaved }; + return { state, library, autoSaved, notice, noticeTone, setNotice, busy, remove, save, undoAutoSaved }; } diff --git a/packages/core/src/ai/generate-output.ts b/packages/core/src/ai/generate-output.ts index aa45be2fd..68190b9e7 100644 --- a/packages/core/src/ai/generate-output.ts +++ b/packages/core/src/ai/generate-output.ts @@ -126,3 +126,22 @@ export function expectedContentionCount(length: GenerateInput['length']): number return 2; } } + +/** The parts of a case Regenerate can replace one at a time, as the website's section cards name them. */ +export type CaseSection = 'exordio' | 'structure' | 'contentions' | 'opponent' | 'counters'; + +/** + * A case with one part taken from a newly generated one, as the website's + * Regenerate merges it. The metadata comes from the new run. + */ +export function mergeRegeneratedSection(base: GenerateOutput, next: GenerateOutput, section: CaseSection): GenerateOutput { + return { + ...base, + ...(section === 'structure' ? { caseStructure: next.caseStructure } : {}), + ...(section === 'contentions' ? { contentions: next.contentions } : {}), + ...(section === 'opponent' ? { predictedOpponentCase: next.predictedOpponentCase } : {}), + ...(section === 'counters' ? { counters: next.counters } : {}), + ...(section === 'exordio' ? { exordio: next.exordio } : {}), + metadata: next.metadata, + }; +} diff --git a/src/locales/en.json b/src/locales/en.json index 5d4799759..464aefa22 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2780,7 +2780,9 @@ "tooLarge": "Your question is too long. Shorten it and send it again.", "debateMissing": "This debate no longer exists. Pick another debate and ask again.", "failed": "The coach could not answer. Try again." - } + }, + "fileTooLarge": "{{name}} is larger than 10 MB.", + "fileType": "{{name}} is not a text or code file. Attach text, Markdown, CSV, JSON or code files." }, "prepare": { "saved": "Saved in this debate" @@ -2817,7 +2819,8 @@ "deleteBrainstormDesc": "This removes the brainstorm from your history.", "deleteFactoidsTitle": "Delete factoids?", "deleteFactoidsDesc": "This removes the factoids from your history.", - "buildFlowchart": "Build flowchart from case" + "buildFlowchart": "Build flowchart from case", + "regenerateSection": "Regenerate a section" }, "attack": { "caseRequired": "Paste or write the case you want attacked." diff --git a/src/locales/es.json b/src/locales/es.json index a5786134d..6fc311277 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -2780,7 +2780,9 @@ "tooLarge": "Tu pregunta es demasiado larga. Acórtala y envíala de nuevo.", "debateMissing": "Este debate ya no existe. Elige otro debate y vuelve a preguntar.", "failed": "El coach no pudo responder. Inténtalo de nuevo." - } + }, + "fileTooLarge": "{{name}} supera los 10 MB.", + "fileType": "{{name}} no es un archivo de texto o código. Adjunta archivos de texto, Markdown, CSV, JSON o código." }, "prepare": { "saved": "Guardado en este debate" @@ -2817,7 +2819,8 @@ "deleteBrainstormDesc": "Esto elimina la lluvia de ideas de tu historial.", "deleteFactoidsTitle": "¿Eliminar los datos?", "deleteFactoidsDesc": "Esto elimina los datos de tu historial.", - "buildFlowchart": "Crear diagrama desde el caso" + "buildFlowchart": "Crear diagrama desde el caso", + "regenerateSection": "Regenerar una sección" }, "attack": { "caseRequired": "Pega o escribe el caso que quieres atacar." diff --git a/src/locales/hu.json b/src/locales/hu.json index f6ed29686..b216d267c 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -2780,7 +2780,9 @@ "tooLarge": "Túl hosszú a kérdésed. Rövidítsd le, és küldd el újra.", "debateMissing": "Ez a vita már nem létezik. Válassz másik vitát, és kérdezz újra.", "failed": "Az edző nem tudott válaszolni. Próbáld újra." - } + }, + "fileTooLarge": "{{name}} nagyobb 10 MB-nál.", + "fileType": "{{name}} nem szöveges vagy kódfájl. Csatolj szöveget, Markdownt, CSV-t, JSON-t vagy kódfájlt." }, "prepare": { "saved": "Ebben a vitában mentve" @@ -2817,7 +2819,8 @@ "deleteBrainstormDesc": "Ez eltávolítja az ötletelést az előzményeid közül.", "deleteFactoidsTitle": "Törlöd a tényeket?", "deleteFactoidsDesc": "Ez eltávolítja a tényeket az előzményeid közül.", - "buildFlowchart": "Folyamatábra az esetből" + "buildFlowchart": "Folyamatábra az esetből", + "regenerateSection": "Szakasz újragenerálása" }, "attack": { "caseRequired": "Illeszd be vagy írd le a megtámadandó esetet." diff --git a/src/pages/GeneratePage.tsx b/src/pages/GeneratePage.tsx index 765958ab8..36c60fe07 100644 --- a/src/pages/GeneratePage.tsx +++ b/src/pages/GeneratePage.tsx @@ -29,6 +29,7 @@ import { generateDraftSections } from '@/components/features/tools/live-sections import { generateDiscussPrompt, openDiscussion } from '@/components/features/tools/discuss'; import { markResultSaved, useResultSaved } from '@/components/features/tools/result-actions'; import { prefillFromGenerate } from '@/lib/crossfire/prefill'; +import { mergeRegeneratedSection, type CaseSection } from '@debatelab/core/ai/generate-output'; import { WorkspaceToolScope, useWorkspaceToolScope } from '@/components/features/workspace/WorkspaceToolScope'; import { workspaceRouteState } from '@/lib/workspace/tool-context'; import { activeDebateId, sameMotion, selectCurrentDebate, useDebateStore } from '@/lib/stores/debate-store'; @@ -183,15 +184,7 @@ export default function GeneratePage() { .then(async ({ result: next, discoveredSources: sources }) => { // Undo or a new generation replaced the case meanwhile; merging now would revert it. if (useSessionStore.getState().generateResult !== base) return; - const regenerated = { - ...base, - ...(section === 'structure' ? { caseStructure: next.caseStructure } : {}), - ...(section === 'contentions' ? { contentions: next.contentions } : {}), - ...(section === 'opponent' ? { predictedOpponentCase: next.predictedOpponentCase } : {}), - ...(section === 'counters' ? { counters: next.counters } : {}), - ...(section === 'exordio' ? { exordio: next.exordio } : {}), - metadata: next.metadata, - }; + const regenerated = mergeRegeneratedSection(base, next, section as CaseSection); setGenerateResult(regenerated); setDiscoveredSources(sources); await workspace.attach({ ...data, regeneratedSection: section }, regenerated); From 2d8534e3ebb28a685d3b7cfe43613ef4867b295b Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 28 Oct 2026 12:23:54 +0000 Subject: [PATCH 06/14] Ask: Stop, General and Clarify, Save to Library, and text attachments Ask gains the website's Stop, which keeps the answer so far marked as stopped, and its General and Clarify modes. Answers the stream cannot serve now stream live from the ai function, as on the website. Each answer can be saved to the Current Debate's Ask folder in the Library. Up to five text or code files can be attached with expo-document-picker (in Expo Go); they are read on the phone and sent with the question in the website's format, now shared through packages/core. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/package.json | 1 + apps/mobile/src/app/ask.tsx | 218 +++++++++++++++++----- apps/mobile/src/components/ask-answer.tsx | 63 ++++++- apps/mobile/src/components/chip.tsx | 11 +- apps/mobile/src/components/icon-paths.ts | 3 + apps/mobile/src/lib/ask-attachments.ts | 52 ++++++ apps/mobile/src/lib/ask.test.ts | 1 + apps/mobile/src/lib/ask.ts | 36 ++-- apps/mobile/src/lib/read-file.ts | 6 + apps/mobile/src/lib/read-file.web.ts | 4 + package-lock.json | 10 + packages/core/src/ai/ask-files.ts | 40 ++++ src/pages/AskPage.tsx | 49 ++--- 13 files changed, 392 insertions(+), 102 deletions(-) create mode 100644 apps/mobile/src/lib/ask-attachments.ts create mode 100644 apps/mobile/src/lib/read-file.ts create mode 100644 apps/mobile/src/lib/read-file.web.ts create mode 100644 packages/core/src/ai/ask-files.ts diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 12ee988ef..ecec559d3 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -23,6 +23,7 @@ "expo-constants": "~57.0.21", "expo-crypto": "~57.0.3", "expo-dev-client": "~57.0.20", + "expo-document-picker": "~57.0.3", "expo-file-system": "~57.0.7", "expo-font": "~57.0.4", "expo-haptics": "~57.0.3", diff --git a/apps/mobile/src/app/ask.tsx b/apps/mobile/src/app/ask.tsx index 1a89a0274..870cc0e7d 100644 --- a/apps/mobile/src/app/ask.tsx +++ b/apps/mobile/src/app/ask.tsx @@ -1,3 +1,4 @@ +import { askDisplayContent, askMessageWithFiles } from '@debatelab/core/ai/ask-files'; import type { AskOutput } from '@debatelab/core/ai/schemas/ask'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { useCallback, useEffect, useRef, useState } from 'react'; @@ -18,7 +19,8 @@ import { OneOffNotice } from '@/components/one-off-notice'; import { DailyUsageLine } from '@/components/tool-form'; import { Text } from '@/components/text'; import { AIError, isAbortError, type AIErrorKind, type ResponseExtras } from '@/lib/ai-request'; -import { askOnce, canStream, isFinalAskError, isMessageWithinBudget, streamAsk, type AskOptions } from '@/lib/ask'; +import { askOnce, canStream, isFinalAskError, isMessageWithinBudget, streamAsk, type AskMode, type AskOptions } from '@/lib/ask'; +import { pickAskFiles, readAskFiles, type AskAttachment } from '@/lib/ask-attachments'; import { buildDebateContext } from '@/lib/context-request'; import type { FeatureConfig } from '@/lib/feature-config'; import { useAuth } from '@/lib/auth'; @@ -28,7 +30,7 @@ import { LIST_WIDTH, useLayoutClass } from '@/lib/layout'; import { openRefusal } from '@/lib/refusal'; import { keepSourceAssessments } from '@/lib/results'; import { useSettings } from '@/lib/settings'; -import { fetchSources, type Source } from '@/lib/sources'; +import { addSource, fetchSources, type Source } from '@/lib/sources'; import { useCopyHolder } from '@/lib/use-copy-holder'; import { radius } from '@/theme/colors'; import { fontFamily } from '@/theme/fonts'; @@ -46,6 +48,12 @@ const OPTION_CHIPS: { key: keyof AskOptions; label: string; icon: IconName }[] = { key: 'argumentMapMode', label: 'askChat.modeArgMap', icon: 'map' }, ]; +// The website's mode switch (askChat.modeOptions), shown first in the chip row. +const MODES: { key: AskMode; label: string }[] = [ + { key: 'general', label: 'askChat.modeGeneral' }, + { key: 'clarify', label: 'askChat.modeClarify' }, +]; + // The website's one progress line while the coach answers. const THINKING = ['askChat.working']; @@ -77,13 +85,17 @@ export default function AskScreen() { const [opening, setOpening] = useState(false); const [record, setRecordState] = useState(NEW_CHAT); const [options, setOptions] = useState(NO_OPTIONS); + const [mode, setMode] = useState('general'); + const [attachments, setAttachments] = useState([]); const [input, setInput] = useState(''); - const [error, setError] = useState<{ kind: AIErrorKind; question: string } | null>(null); + const [error, setError] = useState<{ kind: AIErrorKind; question: string; files?: AskAttachment[] } | null>(null); const [notice, setNotice] = useState(''); const [historyOpen, setHistoryOpen] = useState(false); const [library, setLibrary] = useState([]); const scroll = useRef(null); + // The answer's text so far, kept by Stop as the website keeps it. + const partial = useRef(''); const requests = useRef(0); const abort = useRef(null); const config = useRef | null>(null); @@ -182,21 +194,27 @@ export default function AskScreen() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [loading, lastRole, messages, userId]); - const send = async (text: string, history: ChatMessage[]) => { - const message = text.trim(); - if (!message || !userId) return; + const send = async (text: string, history: ChatMessage[], files: AskAttachment[] = []) => { + const question = text.trim(); + if (!question || !userId) return; abortActive(); const request = requests.current; const isCurrent = () => requests.current === request; + // Attached files are read here and sent with the question; the chat keeps only their names. + const message = files.length > 0 ? askMessageWithFiles(question, await readAskFiles(files)) : question; + if (!isCurrent()) return; if (!isMessageWithinBudget(message)) { - setError({ kind: 'tooLarge', question: message }); + setError({ kind: 'tooLarge', question, files }); return; } const requestOptions = options; + const requestMode = mode; const controller = new AbortController(); abort.current = controller; - setMessages([...history, { id: nextId(), role: 'user', content: message, timestamp: Date.now() }]); + partial.current = ''; + setMessages([...history, { id: nextId(), role: 'user', content: askDisplayContent(question, files.map((file) => file.name)), timestamp: Date.now() }]); setInput(''); + setAttachments([]); setError(null); setNotice(''); setLoading(true); @@ -213,6 +231,7 @@ export default function AskScreen() { if (!isCurrent()) return; const askRequest = { message, + mode: requestMode, history: history.map((item) => ({ role: item.role, content: item.content })), options: requestOptions, config: askSettings, @@ -225,7 +244,13 @@ export default function AskScreen() { const keepExtras = (extras: ResponseExtras) => { whole.extras = extras; }; - if (canStream(requestOptions)) { + // Whole answers stream live from the ai function, as on the website; Stop keeps what arrived. + const showText = (text: string) => { + if (!isCurrent()) return; + partial.current = text; + setStreaming(text); + }; + if (canStream(requestOptions, requestMode)) { let received = ''; let pending = ''; let flush: ReturnType | null = null; @@ -236,6 +261,7 @@ export default function AskScreen() { onDelta: (delta) => { if (!isCurrent()) return; received += delta; + partial.current = received; pending += delta; // Tokens arrive faster than the screen needs; paint every 30 ms like the website. flush ??= setTimeout(() => { @@ -258,13 +284,14 @@ export default function AskScreen() { } else { // Nothing usable arrived: ask once more without streaming, as the website does. setStreaming(null); - result = await askOnce(askRequest, controller.signal, keepExtras); + result = await askOnce(askRequest, controller.signal, keepExtras, showText); } } finally { if (flush) clearTimeout(flush); } } else { - result = await askOnce(askRequest, controller.signal, keepExtras); + setStreaming(''); + result = await askOnce(askRequest, controller.signal, keepExtras, showText); } if (!isCurrent()) return; @@ -287,6 +314,7 @@ export default function AskScreen() { generatedAt: result.generatedAt || new Date().toISOString(), }; setStreaming(null); + partial.current = ''; setMessages((previous) => [...previous, { id: nextId(), role: 'assistant', content: result.response, timestamp: Date.now(), output }]); } catch (cause) { if (!isCurrent() || isAbortError(cause)) return; @@ -295,7 +323,7 @@ export default function AskScreen() { if (!(cause instanceof AIError)) console.warn('Ask failed:', cause instanceof Error ? cause.message : cause); // A missing debate was deleted elsewhere; reload the list so the app stops using it. if (kind === 'debateMissing') void refresh(); - setError({ kind, question: message }); + setError({ kind, question, files }); // The question stays as not sent, and the website's dialog opens too. openRefusal(router, cause, 'ask'); } finally { @@ -318,9 +346,53 @@ export default function AskScreen() { }, [prompt, userId]); // Retrying replaces the unanswered question instead of asking it twice. - const retry = (question: string) => { + const retry = (question: string, files?: AskAttachment[]) => { const last = messages[messages.length - 1]; - void send(question, last?.role === 'user' ? messages.slice(0, -1) : messages); + void send(question, last?.role === 'user' ? messages.slice(0, -1) : messages, files); + }; + + /** The website's Stop: the answer so far stays, marked as stopped, and is saved like any answer. */ + const stop = () => { + const kept = partial.current.trim(); + abortActive(); + setLoading(false); + setStreaming(null); + if (!kept) return; + setMessages((previous) => [ + ...previous, + { id: nextId(), role: 'assistant', content: kept, timestamp: Date.now(), output: { response: kept, followUpQuestions: [], uncertainties: [t('ask.stopped')] } }, + ]); + }; + + /** The website's Save to Library: the answer becomes a text source in the Current Debate's Ask folder. */ + const saveAnswer = async (content: string): Promise => { + const debateId = activeDebateRef.current; + if (!userId) return false; + if (!debateId) { + setNotice(`${t('ask.saveFailed')}. ${t('debate.sourceNeedsDebate')}`); + return false; + } + try { + await addSource(userId, { title: content.length > 30 ? `${content.slice(0, 30)}...` : content, content, type: 'text', folder: 'Ask', debateId }); + setNotice(`${t('ask.saved')}. ${t('ask.savedDesc')}`); + return true; + } catch (cause) { + console.warn('Could not save the answer to the Library:', cause instanceof Error ? cause.message : cause); + setNotice(`${t('ask.saveFailed')}. ${t('ask.sourceSaveFailedDesc')}`); + return false; + } + }; + + const attach = async () => { + try { + const picked = await pickAskFiles(attachments); + if (!picked) return; + setAttachments(picked.files); + if (picked.problem?.kind === 'tooMany') setNotice(`${t('askChat.tooManyFiles')}. ${t('askChat.tooManyFilesDesc', { max: 5 })}`); + else if (picked.problem) setNotice(`${t('askChat.fileValidationFailed')}. ${t(picked.problem.kind === 'tooLarge' ? 'mobile.ask.fileTooLarge' : 'mobile.ask.fileType', { name: picked.problem.name })}`); + } catch (cause) { + console.warn('Could not pick files:', cause instanceof Error ? cause.message : cause); + } }; const openChat = async (chatId: string) => { @@ -358,6 +430,34 @@ export default function AskScreen() { const question = messages.find((message) => message.role === 'user')?.content; const title = split ? question ?? t('askChat.newChat') : t('nav.ask'); + const chips = ( + <> + {MODES.map((item) => ( + setMode(item.key)} + testID={`ask-mode-${item.key}`} + /> + ))} + + {OPTION_CHIPS.map((chip) => ( + setOptions((previous) => ({ ...previous, [chip.key]: !previous[chip.key] }))} + /> + ))} + {/* The next question reads the model again, after this change is saved. */} + (config.current = null)} /> + + ); + const chat = ( <> @@ -423,7 +523,7 @@ export default function AskScreen() { ) : ( - + saveAnswer(message.content)} /> ), )} @@ -446,7 +546,7 @@ export default function AskScreen() { {t(`mobile.ask.errors.${error.kind}`)} - {RETRYABLE_ERRORS.has(error.kind) ? retry(error.question)} /> : null} + {RETRYABLE_ERRORS.has(error.kind) ? retry(error.question, error.files)} /> : null} {PLAN_ERRORS.has(error.kind) ? router.navigate('/you')} /> : null} @@ -458,37 +558,39 @@ export default function AskScreen() { {split ? ( // iPad: every option shows, wrapping onto a second line when the column is narrow. - {OPTION_CHIPS.map((chip) => ( - setOptions((previous) => ({ ...previous, [chip.key]: !previous[chip.key] }))} - /> - ))} - {/* The next question reads the model again, after this change is saved. */} - (config.current = null)} /> + {chips} ) : ( // iPhone: one sideways row, faded at the edge to show it scrolls. - {OPTION_CHIPS.map((chip) => ( - setOptions((previous) => ({ ...previous, [chip.key]: !previous[chip.key] }))} - /> - ))} - {/* The next question reads the model again, after this change is saved. */} - (config.current = null)} /> + {chips} )} + {attachments.length > 0 ? ( + + {attachments.map((file, index) => ( + } + onPress={() => setAttachments((previous) => previous.filter((_, at) => at !== index))} + /> + ))} + + ) : null} + void attach()} + style={[styles.attach, busy && styles.sendDisabled]} + testID="ask-attach"> + + - void send(input, messages)} - style={[styles.send, { backgroundColor: colors.primaryFill }, (busy || !input.trim()) && styles.sendDisabled]} - testID="ask-send"> - {busy ? : } - + {loading ? ( + // The website's Stop: the answer so far stays in the chat. + + + + ) : ( + void send(input, messages, attachments)} + style={[styles.send, { backgroundColor: colors.primaryFill }, (busy || !input.trim()) && styles.sendDisabled]} + testID="ask-send"> + {busy ? : } + + )} @@ -616,5 +730,9 @@ const styles = StyleSheet.create({ }, input: { flex: 1, fontSize: 17, fontFamily: fontFamily.regular, paddingTop: 7, paddingBottom: 7, maxHeight: 120 }, send: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center' }, + stop: { borderWidth: 2 }, + attach: { width: 32, height: 36, alignItems: 'center', justifyContent: 'center', marginLeft: -8, marginRight: 2 }, + attachments: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 }, + chipDivider: { width: 1.5, height: 24, alignSelf: 'center', marginHorizontal: 2 }, sendDisabled: { opacity: 0.45 }, }); diff --git a/apps/mobile/src/components/ask-answer.tsx b/apps/mobile/src/components/ask-answer.tsx index 6a6c50032..b791679e7 100644 --- a/apps/mobile/src/components/ask-answer.tsx +++ b/apps/mobile/src/components/ask-answer.tsx @@ -1,13 +1,14 @@ import type { AskOutput } from '@debatelab/core/ai/schemas/ask'; import { useRouter } from 'expo-router'; import * as WebBrowser from 'expo-web-browser'; -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { StyleSheet, View } from 'react-native'; +import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native'; import { Chip, CiteBadge } from './chip'; import { FadingScrollRow } from './edge-fade'; import { firstUrl, hostOf, openCitation, shorten, type CitationTarget } from './citation-target'; +import { Icon } from './icon'; import { MarkdownView } from './markdown-view'; import { OwlAvatar } from './owl'; import { Text } from './text'; @@ -35,7 +36,20 @@ function citationTargets(output: AskOutput | undefined, library: Source[]): Cita } /** One coach answer, as in mock 07, with the website's extra sections when the answer has them. */ -export function AskAnswer({ content, output, streaming, library }: { content: string; output?: AskOutput; streaming?: boolean; library: Source[] }) { +export function AskAnswer({ + content, + output, + streaming, + library, + onSave, +}: { + content: string; + output?: AskOutput; + streaming?: boolean; + library: Source[]; + /** Saves the answer to the Library's Ask folder; resolves whether it was saved. */ + onSave?: () => Promise; +}) { const { t } = useTranslation(); const { colors } = useTheme(); const router = useRouter(); @@ -86,10 +100,48 @@ export function AskAnswer({ content, output, streaming, library }: { content: st {output?.followUpQuestions.length ?
: null} {output?.uncertainties.length ?
: null} + {onSave && !streaming ? : null} ); } +/** The website's bookmark on an answer: saved, it turns into the mint check. */ +function SaveToLibrary({ onSave }: { onSave: () => Promise }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const [state, setState] = useState<'idle' | 'saving' | 'saved'>('idle'); + if (state === 'saved') { + return ( + + + + + + {t('ask.saved')} + + + ); + } + return ( + { + setState('saving'); + void onSave().then((saved) => setState(saved ? 'saved' : 'idle')); + }} + style={({ pressed }) => [styles.save, pressed && styles.pressed]} + testID="ask-save"> + {state === 'saving' ? : } + + {t('askChat.saveToLibrary')} + + + ); +} + function Section({ title, items, marker = '•' }: { title: string; items: string[]; marker?: string }) { if (items.length === 0) return null; return ( @@ -114,4 +166,9 @@ const styles = StyleSheet.create({ sectionBody: { gap: 3 }, sectionTitle: { fontSize: 14 }, sectionItem: { fontSize: 15, lineHeight: 21 }, + save: { flexDirection: 'row', alignItems: 'center', gap: 6, alignSelf: 'flex-start', minHeight: 32 }, + saved: { flexDirection: 'row', alignItems: 'center', gap: 7, alignSelf: 'flex-start', minHeight: 32 }, + savedCheck: { width: 20, height: 20, borderRadius: 10, alignItems: 'center', justifyContent: 'center' }, + saveText: { fontSize: 14.5 }, + pressed: { opacity: 0.6 }, }); diff --git a/apps/mobile/src/components/chip.tsx b/apps/mobile/src/components/chip.tsx index 6680dcb3e..f8771b32e 100644 --- a/apps/mobile/src/components/chip.tsx +++ b/apps/mobile/src/components/chip.tsx @@ -14,16 +14,21 @@ export function Chip({ selected, icon, leading, + trailing, onPress, accessibilityRole = 'button', + accessibilityLabel, testID, }: { label: string; selected?: boolean; icon?: IconName; leading?: ReactNode; + /** After the label, such as a remove mark. */ + trailing?: ReactNode; onPress: () => void; - accessibilityRole?: 'button' | 'switch' | 'tab'; + accessibilityRole?: 'button' | 'switch' | 'tab' | 'radio'; + accessibilityLabel?: string; testID?: string; }) { const { colors } = useTheme(); @@ -31,7 +36,8 @@ export function Chip({ return ( [ @@ -46,6 +52,7 @@ export function Chip({ {label} + {trailing} ); } diff --git a/apps/mobile/src/components/icon-paths.ts b/apps/mobile/src/components/icon-paths.ts index 9ad11d6df..40dbe70e1 100644 --- a/apps/mobile/src/components/icon-paths.ts +++ b/apps/mobile/src/components/icon-paths.ts @@ -54,6 +54,9 @@ export const iconPaths = { alert: '', cloudOff: '', + stop: '', + paperclip: '', + bookmark: '', } as const; export type IconName = keyof typeof iconPaths; diff --git a/apps/mobile/src/lib/ask-attachments.ts b/apps/mobile/src/lib/ask-attachments.ts new file mode 100644 index 000000000..9ea6c75bc --- /dev/null +++ b/apps/mobile/src/lib/ask-attachments.ts @@ -0,0 +1,52 @@ +import { isAskTextFile, type AskFileContent } from '@debatelab/core/ai/ask-files'; +import { MAX_FILE_COUNT, MAX_FILE_SIZE, validateSingleFile } from '@debatelab/core/validation/file'; +import * as DocumentPicker from 'expo-document-picker'; + +import { readPickedText } from './read-file'; + +/** + * Ask's attachments, as the website's: up to five text or code files of + * 10 MB each, read on the device and sent as part of the question + * (packages/core/src/ai/ask-files.ts). Nothing is uploaded. + */ + +export interface AskAttachment { + name: string; + size: number; + type: string; + uri: string; + file?: Blob; +} + +export type AttachmentProblem = { kind: 'tooMany' } | { kind: 'tooLarge'; name: string } | { kind: 'type'; name: string }; + +/** Lets the student pick files to add to `current`; the website's limits decide which are kept. */ +export async function pickAskFiles(current: AskAttachment[]): Promise<{ files: AskAttachment[]; problem?: AttachmentProblem } | null> { + const picked = await DocumentPicker.getDocumentAsync({ multiple: true, copyToCacheDirectory: true, type: ['text/*', 'application/json', 'application/xml', 'public.source-code', 'public.text'] }); + if (picked.canceled) return null; + const added = picked.assets.map((asset): AskAttachment => ({ + name: asset.name, + size: asset.size ?? 0, + type: asset.mimeType ?? '', + uri: asset.uri, + ...(asset.file ? { file: asset.file } : {}), + })); + if (current.length + added.length > MAX_FILE_COUNT) return { files: current, problem: { kind: 'tooMany' } }; + for (const file of added) { + if (validateSingleFile(file).valid) continue; + return { files: current, problem: file.size > MAX_FILE_SIZE ? { kind: 'tooLarge', name: file.name } : { kind: 'type', name: file.name } }; + } + return { files: [...current, ...added] }; +} + +/** Each attachment's text for the message, as the website reads them. */ +export function readAskFiles(files: AskAttachment[]): Promise { + return Promise.all(files.map(async (file): Promise => { + if (!isAskTextFile(file)) return { name: file.name, skipped: file.type }; + try { + return { name: file.name, text: await readPickedText(file) }; + } catch { + return { name: file.name, unreadable: true }; + } + })); +} diff --git a/apps/mobile/src/lib/ask.test.ts b/apps/mobile/src/lib/ask.test.ts index f336eca63..84d37c495 100644 --- a/apps/mobile/src/lib/ask.test.ts +++ b/apps/mobile/src/lib/ask.test.ts @@ -15,6 +15,7 @@ describe('askOnce', () => { const controller = new AbortController(); const request = askOnce({ message: 'Why ban homework?', + mode: 'general', history: [], options: { citationMode: false, webSearch: false, prosConsMode: false, argumentMapMode: false }, config: { model: 'test-model', reasoningEffort: 'low' }, diff --git a/apps/mobile/src/lib/ask.ts b/apps/mobile/src/lib/ask.ts index 43c67c799..b6eecb380 100644 --- a/apps/mobile/src/lib/ask.ts +++ b/apps/mobile/src/lib/ask.ts @@ -1,8 +1,9 @@ +import type { LiveGeneration } from '@debatelab/core/ai/live'; import { AskOutputSchema, type AskOutput } from '@debatelab/core/ai/schemas/ask'; import { consumeSSEChunk, type ParsedSSEEvent } from '@debatelab/core/ai/sse'; import type { DebateContextRequest } from '@debatelab/core/contract/debate-context'; -import { abortError, AIError, errorFrom, functionOutput, postFunction, PROVIDER, responseExtras, utf8Length, type ResponseExtras } from './ai-request'; +import { abortError, AIError, errorFrom, postAI, postFunction, PROVIDER, responseExtras, utf8Length, type ResponseExtras } from './ai-request'; import type { FeatureConfig } from './feature-config'; /** @@ -30,8 +31,12 @@ export interface HistoryMessage { content: string; } +/** The website's two Ask modes: a general answer, or one that defines terms and asks back. */ +export type AskMode = 'general' | 'clarify'; + export interface AskRequest { message: string; + mode: AskMode; history: HistoryMessage[]; options: AskOptions; config: FeatureConfig; @@ -45,9 +50,9 @@ export function isFinalAskError(error: unknown): boolean { return error instanceof AIError && error.kind !== 'failed' && error.kind !== 'network'; } -/** Streaming serves plain questions only, as on the website (isAskStreamingSupported). */ -export function canStream(options: AskOptions): boolean { - return !(options.citationMode || options.webSearch || options.prosConsMode || options.argumentMapMode); +/** Streaming serves plain questions in General mode only, as on the website (isAskStreamingSupported). */ +export function canStream(options: AskOptions, mode: AskMode): boolean { + return mode === 'general' && !(options.citationMode || options.webSearch || options.prosConsMode || options.argumentMapMode); } /** Cuts at a whole character, like the website's truncateUtf8. */ @@ -83,7 +88,7 @@ function requestInput(request: AskRequest) { return { message: request.message, history: boundHistory(request.history), - mode: 'general', + mode: request.mode, citationMode: request.options.citationMode, webSearch: request.options.webSearch, prosConsMode: request.options.prosConsMode, @@ -160,11 +165,18 @@ export function streamAsk(request: AskRequest, handlers: { onDelta: (text: strin } /** - * Asks the ai Edge Function for a whole answer, with the options the stream - * cannot serve. onExtras receives what the answer says besides its output, - * which the website acts on for whole answers only. + * Asks the ai Edge Function for a whole answer, with the options and modes + * the stream cannot serve. With `onText`, the ai function streams it live, as + * the website's Ask does, and onText receives the answer's text so far. + * onExtras receives what the answer says besides its output, which the + * website acts on for whole answers only. */ -export function askOnce(request: AskRequest, signal?: AbortSignal, onExtras?: (extras: ResponseExtras) => void): Promise { +export function askOnce( + request: AskRequest, + signal?: AbortSignal, + onExtras?: (extras: ResponseExtras) => void, + onText?: (text: string) => void, +): Promise { const body = { feature: 'ask', input: requestInput(request), @@ -175,8 +187,10 @@ export function askOnce(request: AskRequest, signal?: AbortSignal, onExtras?: (e sourceIds: request.debateContext?.sourceIds ?? [], ...(request.debateContext ? { debateContext: request.debateContext } : {}), }; - return postFunction('ai', body, signal, async (response) => { - const { output, data } = await functionOutput(response); + const live: LiveGeneration | undefined = onText + ? { onDraft: (draft) => onText(typeof draft.response === 'string' ? draft.response : '') } + : undefined; + return postAI(body, { signal, live }, ({ output, data }) => { const result = parseOutput(output); onExtras?.(responseExtras(data)); return result; diff --git a/apps/mobile/src/lib/read-file.ts b/apps/mobile/src/lib/read-file.ts new file mode 100644 index 000000000..ef47fc2a9 --- /dev/null +++ b/apps/mobile/src/lib/read-file.ts @@ -0,0 +1,6 @@ +import { File } from 'expo-file-system'; + +/** A picked file's text, read from the copy the picker left in the app's cache. */ +export function readPickedText(asset: { uri: string }): Promise { + return new File(asset.uri).text(); +} diff --git a/apps/mobile/src/lib/read-file.web.ts b/apps/mobile/src/lib/read-file.web.ts new file mode 100644 index 000000000..569ba7a2d --- /dev/null +++ b/apps/mobile/src/lib/read-file.web.ts @@ -0,0 +1,4 @@ +/** The web preview reads the browser File the picker hands back. */ +export function readPickedText(asset: { uri: string; file?: Blob }): Promise { + return asset.file ? asset.file.text() : fetch(asset.uri).then((response) => response.text()); +} diff --git a/package-lock.json b/package-lock.json index 6a0742052..4e33be797 100644 --- a/package-lock.json +++ b/package-lock.json @@ -135,6 +135,7 @@ "expo-constants": "~57.0.21", "expo-crypto": "~57.0.3", "expo-dev-client": "~57.0.20", + "expo-document-picker": "~57.0.3", "expo-file-system": "~57.0.7", "expo-font": "~57.0.4", "expo-haptics": "~57.0.3", @@ -672,6 +673,15 @@ "expo": "*" } }, + "apps/mobile/node_modules/expo-document-picker": { + "version": "57.0.3", + "resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-57.0.3.tgz", + "integrity": "sha512-XlxdZ0zzv44JRD8O15DISgIO8FTaZ581oc4kQp6MJoR7vdahjcY+2qCuDt5k1J8/lc0YR+bXon/v81norciuxw==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "apps/mobile/node_modules/expo-file-system": { "version": "57.0.7", "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-57.0.7.tgz", diff --git a/packages/core/src/ai/ask-files.ts b/packages/core/src/ai/ask-files.ts new file mode 100644 index 000000000..af3e08a36 --- /dev/null +++ b/packages/core/src/ai/ask-files.ts @@ -0,0 +1,40 @@ +// Text files attached to an Ask question, read on the device and sent as +// part of the message, as the website's AskPage does. Shared by the website +// and the iOS app. + +/** File types whose text is read and sent; any other attached file is named but skipped. */ +const TEXT_EXTENSIONS = new Set([ + 'txt', 'md', 'markdown', 'csv', 'json', 'yaml', 'yml', 'xml', + 'html', 'css', 'scss', 'js', 'jsx', 'ts', 'tsx', 'py', 'java', + 'c', 'cpp', 'h', 'hpp', +]); + +export function isAskTextFile(file: { name: string; type?: string | null }): boolean { + if (file.type?.startsWith('text/')) return true; + if (file.type === 'application/json') return true; + const ext = file.name.split('.').pop()?.toLowerCase(); + return ext ? TEXT_EXTENSIONS.has(ext) : false; +} + +/** An attached file as the message describes it: its text, or why it has none. */ +export type AskFileContent = + | { name: string; text: string } + | { name: string; skipped: string } + | { name: string; unreadable: true }; + +function fileBlock(file: AskFileContent): string { + if ('text' in file) return `FILE: ${file.name}\nCONTENT:\n${file.text}\n\n`; + if ('skipped' in file) return `FILE: ${file.name} - binary skipped (type: ${file.skipped || 'unknown'})\n`; + return `[Error reading ${file.name}]\n`; +} + +/** The message the model receives: the files' text, then the question. */ +export function askMessageWithFiles(message: string, files: AskFileContent[]): string { + if (files.length === 0) return message; + return `CONTEXT:\n${files.map(fileBlock).join('\n')}\n\nQUESTION: ${message}`; +} + +/** The question as the chat shows and saves it: the files are named, their text is not kept. */ +export function askDisplayContent(message: string, fileNames: string[]): string { + return fileNames.length > 0 ? `${message}\n\n[Attachments: ${fileNames.join(', ')}]` : message; +} diff --git a/src/pages/AskPage.tsx b/src/pages/AskPage.tsx index cccc303e3..6533a7f25 100644 --- a/src/pages/AskPage.tsx +++ b/src/pages/AskPage.tsx @@ -1,5 +1,6 @@ import { useLocation, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; +import { askDisplayContent, askMessageWithFiles, isAskTextFile, type AskFileContent } from '@debatelab/core/ai/ask-files'; import { useSessionStore } from '@/lib/stores/session-store'; import { useAuth } from '@/lib/supabase/AuthProvider'; import { useSettingsStore } from '@/lib/stores/settings-store'; @@ -171,48 +172,24 @@ export default function AskPage() { const requestId = requestIdRef.current; const isCurrentRequest = () => requestIdRef.current === requestId; - let displayContent = message; - if (files && files.length > 0) { - displayContent += `\n\n[Attachments: ${files.map(f => f.name).join(', ')}]`; - } + const displayContent = askDisplayContent(message, (files ?? []).map((file) => file.name)); try { - let fileContext = ""; - if (files && files.length > 0) { - const textExtensions = new Set([ - 'txt', 'md', 'markdown', 'csv', 'json', 'yaml', 'yml', 'xml', - 'html', 'css', 'scss', 'js', 'jsx', 'ts', 'tsx', 'py', 'java', - 'c', 'cpp', 'h', 'hpp' - ]); - const isTextFile = (file: File) => { - if (file.type?.startsWith('text/')) return true; - if (file.type === 'application/json') return true; - const ext = file.name.split('.').pop()?.toLowerCase(); - return ext ? textExtensions.has(ext) : false; - }; - - const contents = await Promise.all(files.map(file => { - if (!isTextFile(file)) { - const typeLabel = file.type || 'unknown'; - return Promise.resolve(`FILE: ${file.name} - binary skipped (type: ${typeLabel})\n`); - } - return new Promise((resolve) => { - const reader = new FileReader(); - reader.onload = (e) => { - const text = typeof e.target?.result === 'string' ? e.target.result : ''; - resolve(`FILE: ${file.name}\nCONTENT:\n${text}\n\n`); - }; - reader.onerror = () => resolve(`[Error reading ${file.name}]\n`); - reader.readAsText(file); - }); - })); - fileContext = contents.join('\n'); - } + // The message assembly is shared with the iOS app (packages/core/src/ai/ask-files.ts). + const contents = await Promise.all((files ?? []).map((file): Promise => { + if (!isAskTextFile(file)) return Promise.resolve({ name: file.name, skipped: file.type }); + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = (e) => resolve({ name: file.name, text: typeof e.target?.result === 'string' ? e.target.result : '' }); + reader.onerror = () => resolve({ name: file.name, unreadable: true }); + reader.readAsText(file); + }); + })); // A retry's question is already in the chat; it is the message, not part of the history. const priorMessages = retry && askMessages[askMessages.length - 1]?.role === 'user' ? askMessages.slice(0, -1) : askMessages; const history = workspace.context ? [] : priorMessages.map(m => ({ role: m.role, content: m.content })); - const fullMessage = fileContext ? `CONTEXT:\n${fileContext}\n\nQUESTION: ${message}` : message; + const fullMessage = askMessageWithFiles(message, contents); if (fullMessage.length > MAX_AI_INPUT_LENGTH || !isAskMessageWithinBudget(fullMessage)) { setChatError({ title: t('ask.inputTooLargeTitle', 'Message is too large'), From e895e77bbf1c98312427f564ef50c4a64c75771d Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 28 Oct 2026 12:33:46 +0000 Subject: [PATCH 07/14] Say what each request uses from the debate, with Review and pins Every tool form, Crossfire, Sparring and Ask now show the website's line: "Using 3 sources and 2 saved results", with the results named. Review opens a sheet that turns sources and saved results off for the next request only (for the whole round in Crossfire), and pins up to three results for every request in the debate, in debate_context_pins as on the website. The line, the sheet and the request read one plan: the website's planner, moved to packages/core. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/AGENTS.md | 6 +- apps/mobile/scripts/screenshots.ts | 43 +++- apps/mobile/src/app/_layout.tsx | 1 + apps/mobile/src/app/ask.tsx | 7 + apps/mobile/src/app/attack/index.tsx | 5 + apps/mobile/src/app/brainstorm/index.tsx | 6 + apps/mobile/src/app/context-review.tsx | 256 ++++++++++++++++++++ apps/mobile/src/app/crossfire.tsx | 5 + apps/mobile/src/app/factoids/index.tsx | 6 + apps/mobile/src/app/generate.tsx | 5 + apps/mobile/src/app/sparring/index.tsx | 2 + apps/mobile/src/components/context-line.tsx | 110 +++++++++ apps/mobile/src/components/icon-paths.ts | 1 + apps/mobile/src/lib/context-plan.ts | 232 ++++++++++++++++++ apps/mobile/src/lib/context-request.ts | 120 +-------- packages/core/src/debate/context-plan.ts | 254 +++++++++++++++++++ src/lib/debate/context.ts | 221 +---------------- 17 files changed, 953 insertions(+), 327 deletions(-) create mode 100644 apps/mobile/src/app/context-review.tsx create mode 100644 apps/mobile/src/components/context-line.tsx create mode 100644 apps/mobile/src/lib/context-plan.ts create mode 100644 packages/core/src/debate/context-plan.ts diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index a9a274f21..319fcbb63 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -18,13 +18,15 @@ The iOS app DebateOwl (`@debatelab/mobile`; the bundle id is still `com.adam.deb ## Layout -- `src/app/`: routes. `sign-in.tsx`; `(tabs)/` with one route group per tab: Debate, Prepare, Practice, Library, You; the form sheets `debates.tsx` (switcher), `new-debate.tsx`, `delete-account.tsx`, `delete-data.tsx`, `add-link.tsx`, `new-note.tsx`, `source/[id].tsx` and `limit.tsx` (the website's limit-reached and model-restricted dialogs); and `ask.tsx`, `generate.tsx`, `case/[id].tsx`, `attack/`, `brainstorm/`, `factoids/` and `sparring/` (each a form `index.tsx` and a document `[id].tsx`), `crossfire.tsx` and `timer.tsx` (a full-screen modal), `flowchart/` (the screen `index.tsx` with its `card`, `claim` and `maps` form sheets), and `history.tsx`, full screen above the tabs. All are declared in the root `_layout.tsx`. Every Practice tool is in the app, so `(practice)/soon/[tool].tsx` is no longer reached from the Practice tab. +- `src/app/`: routes. `sign-in.tsx`; `(tabs)/` with one route group per tab: Debate, Prepare, Practice, Library, You; the form sheets `debates.tsx` (switcher), `new-debate.tsx`, `delete-account.tsx`, `delete-data.tsx`, `add-link.tsx`, `new-note.tsx`, `source/[id].tsx`, `limit.tsx` (the website's limit-reached and model-restricted dialogs), `save-case.tsx` (Save case with a title) and `context-review.tsx` (the debate context Review); and `ask.tsx`, `generate.tsx`, `case/[id].tsx`, `attack/`, `brainstorm/`, `factoids/` and `sparring/` (each a form `index.tsx` and a document `[id].tsx`), `crossfire.tsx` and `timer.tsx` (a full-screen modal), `flowchart/` (the screen `index.tsx` with its `card`, `claim` and `maps` form sheets), and `history.tsx`, full screen above the tabs. All are declared in the root `_layout.tsx`. Every Practice tool is in the app, so `(practice)/soon/[tool].tsx` is no longer reached from the Practice tab. - `src/components/`: shared UI. `app-tabs.tsx` uses native tabs; `app-tabs.web.tsx` draws the mock's tab bar for the web preview. - `src/lib/`: auth (`auth.tsx`), `native-capabilities.ts` (what the running binary has beyond Expo Go), the Supabase client, session storage (AES key in SecureStore, ciphertext in AsyncStorage) and i18n. `debates.ts`, `settings.tsx`, `subscription.ts` and `account.ts` read and write the website's tables the way its stores do (`src/lib/stores/debate-store.ts`, `settings-store.ts`, `src/hooks/use-subscription.ts`, `src/lib/account.ts`), so the app and the website share one Current Debate and one set of settings. Change them together with the website code they mirror. - `sources.ts` mirrors the session store's `addSource` and `removeSource` and `src/lib/source-import.ts`. The Library always shows the Current Debate, also in one-off mode, as on the website. Adding a file (the website parses files in the browser) and Scan pages are not in the app yet. -- Ask mirrors `src/lib/ai/streaming.ts` and `askChat` in `src/lib/ai/client.ts`: `ask.ts` sends the requests (plain questions stream from `ai-stream` through `expo/fetch`; with Citations, Web search, Pros/Cons or Argument map on, the whole answer comes from `ai`, because `ai-stream` refuses those options), `ai-request.ts` holds the shared POST with the website's headers, timeout and error kinds, `feature-config.ts` reads each tool's model from `user_settings.feature_configs` (the model picker, `components/model-picker.tsx`, writes it back through `settings.tsx`'s serial queue in the website's canonical shape; tools read it through the same queue, so they run with the model just picked), `context-request.ts` builds the Current Debate context with the website's default selection and pins, and `chats.ts` saves finished exchanges to `chats` and `messages`. The SSE parser is shared in `packages/core/src/ai/sse.ts`. `markdown.ts` reads answers, because react-markdown needs the DOM. +- Ask mirrors `src/lib/ai/streaming.ts` and `askChat` in `src/lib/ai/client.ts`: `ask.ts` sends the requests (plain questions stream from `ai-stream` through `expo/fetch`; with Citations, Web search, Pros/Cons or Argument map on, the whole answer comes from `ai`, because `ai-stream` refuses those options), `ai-request.ts` holds the shared POST with the website's headers, timeout and error kinds, `feature-config.ts` reads each tool's model from `user_settings.feature_configs` (the model picker, `components/model-picker.tsx`, writes it back through `settings.tsx`'s serial queue in the website's canonical shape; tools read it through the same queue, so they run with the model just picked), `context-request.ts` builds the Current Debate context from `context-plan.ts`, which reads a debate's material once and plans it with the website's planner (`packages/core/src/debate/context-plan.ts`), with the Review choices for one request (Crossfire keeps them for its round) and the pins in `debate_context_pins` (three at most); the line on each tool form, Crossfire, Sparring and Ask (`components/context-line.tsx`) and its Review sheet read the same plan, and `chats.ts` saves finished exchanges to `chats` and `messages`. The SSE parser is shared in `packages/core/src/ai/sse.ts`. `markdown.ts` reads answers, because react-markdown needs the DOM. - Generate and Attack mirror `generateCase` and `attackCase` in `src/lib/ai/client.ts` and the session store's `recordResult`: `results.ts` sends the requests through `ai-request.ts`'s `postAI`, which streams them as the website does (`stream: true` with a `resultKey`; the reader is `packages/core/src/ai/live.ts`, shared with the website's `src/lib/ai/live.ts`) and reads a dropped stream's answer back from `ai_run_results` (`packages/core/src/ai/run-recovery.ts`). The four tool screens show the draft with `components/live-run.tsx` (the website's `LiveToolRun`; the sections come from `packages/core/src/ai/live-sections.ts`). `results.ts` also checks the output with the website's transform (moved to `packages/core/src/ai/generate-output.ts`), keeps researched sources in the debate's Auto-saved folder (`packages/core/src/ai/discovered-sources.ts`), saves the server's source rankings onto the Library sources a result used (`packages/core/src/ai/source-assessments.ts`, also for Ask's whole answers), says when the server shortened the input, and saves rows to `cases` and `attacks`. `motion.ts` and `motion-gate.ts` are the website's motion gate. `open-results.ts` holds results made in this session, so one-off work and failed saves still open as documents. It and the last Generate and Attack forms live in `session-memory.ts`, which the auth provider clears on every account change, including sign-out. `components/document.tsx` draws results as documents (mock 03); Brainstorm and Factoids reuse it. Export renders `export-html.ts` to a PDF with expo-print and shares it (`export.ts`); the web preview cannot print, so `export.web.ts` keeps the HTML and copied text on `window` for the screenshot script. Word export and Google Docs are not in the app. - Brainstorm and Factoids mirror `brainstorm` and `factoids` in `src/lib/ai/client.ts` and their pages, through the same `results.ts`: the brainstorm transform and the verified-factoid selection live in `packages/core/src/ai/brainstorm-output.ts` and `factoids-output.ts`, rows go to `brainstorms` and `factoids`. Brainstorm has the motion gate; Factoids, like Attack, follows the Current Debate and saves with side `neutral`. A factoid's bookmark saves it to the Current Debate's `Factoids` folder, and a search suggestion reopens the Factoids form with that query. `prepareToolHref` in `components/prepare-tools.ts` is where both tabs open a tool. +- A case's Regenerate and Undo are `case-edits.ts`: one part is replaced from a new generation with the website's merge (`mergeRegeneratedSection` in `packages/core/src/ai/generate-output.ts`), earlier versions are kept for the session, and Undo writes the restored version back to the saved row (`updateResult`). Held results are observable (`subscribeOpenResults`), so the document shows a change made from its menu or its sheets. +- Ask's Stop keeps the answer so far, marked as stopped. Its General and Clarify modes go in the request; Clarify and the options `ai-stream` refuses stream live from the `ai` function through `postAI`. An answer saves to the Current Debate's `Ask` folder. Attachments (`ask-attachments.ts`, expo-document-picker, in Expo Go) are read on the phone (`read-file.ts`) and sent in the website's format (`packages/core/src/ai/ask-files.ts`). - A case's and an attack's next steps are `result-links.ts`: "Open in Crossfire" fills the next Crossfire form through the one-shot `crossfirePrefill` slot (the website's route state; the builders are `packages/core/src/crossfire/prefill.ts`), and a case is built into an unsaved map with `flowchartFromCase` (`packages/core/src/flowchart.ts`, shared with the website), only in the debate the Flowchart is on, as on the website. - Limits mirror the website's subscription dialogs: `refusal.ts` turns a 402 or 403 refusal (with the limit details `errorFrom` keeps) into the `limit` sheet's parameters and holds the website's messages (`limit-message.ts`); the tools open the sheet instead of an inline error, and Ask, Crossfire and Sparring open it beside theirs. Upgrading waits for in-app purchase, so the sheet's next step is the plan in You. `usage.ts` reads today's uses for the line under each tool's title (`DailyUsageLine`, the website's DailyUsage) and reads again after every AI request (`onAIRequestSettled`). `session-quota.ts` asks what a Crossfire or Sparring round costs (`quotaCheck`), shows it above Start (`SessionCostNote`) and opens the sheet before a round the day cannot cover. - Timer mirrors `src/pages/TimerPage.tsx`: `timer.ts` holds the formats and a clock made of start timestamps, never counted ticks, so it is right after the app returns from the background (`timer.test.ts`). `timer-signals.ts` plays the bundled beeps in `assets/sounds` (made by `scripts/render-timer-sounds.ts`) with expo-audio, taps with expo-haptics and keeps the screen awake while a speech runs; `timer-signals.web.ts` only records the signals for the screenshot script. Lock-screen timers wait for the development build (`TODO(live-activity)`). diff --git a/apps/mobile/scripts/screenshots.ts b/apps/mobile/scripts/screenshots.ts index 1d1c71718..d164b66e7 100644 --- a/apps/mobile/scripts/screenshots.ts +++ b/apps/mobile/scripts/screenshots.ts @@ -11,7 +11,7 @@ * ONLY=iphone-light-en limits a run to the combinations whose name starts with it; * separate several with commas (ONLY=iphone-light-en,ipad-dark-hu). * SCREENS=you,timer limits the signed-in screens to those groups: debate, - * library, prepare, practice, you, empty, ask, results, brainstorm, factoids, case-edits, limits, + * library, prepare, practice, you, empty, ask, results, brainstorm, factoids, case-edits, context, limits, * timer, crossfire, sparring, flowchart, one-off, history, announcements, * account, model. Sign-in screens are always shot. * ONLY=offline runs only the offline reading check (see shootOfflineScreens). @@ -770,6 +770,43 @@ async function shootCaseEdits(context: BrowserContext, prefix: string, language: finish(seen); } +/** + * The debate context line on Generate and its Review sheet, from the + * account's real Current Debate. Turning an item off lives in memory only; + * pin writes are answered locally, so nothing is saved. + */ +async function shootContextReview(context: BrowserContext, prefix: string, language: string, problems: string[]) { + const page = await context.newPage(); + watchErrors(page, problems, `${prefix} context`); + await pinSettings(page, language); + await page.route(`${REST}/debate_context_pins*`, (route) => + route.request().method() === 'GET' ? route.continue() : route.fulfill({ status: 201, headers: { 'access-control-allow-origin': '*' }, body: '' }), + ); + await page.goto(`${ORIGIN}/generate?from=prepare`); + await page.getByTestId('generate-motion').waitFor({ timeout: 30_000 }); + const line = page.getByTestId('debate-context-line'); + await line.waitFor({ timeout: 30_000 }); + await line.scrollIntoViewIfNeeded(); + await settle(page); + await shoot(page, `${prefix}-context-line`); + await line.getByRole('button').click(); + const sheet = page.getByTestId('context-review-sheet'); + await sheet.waitFor({ timeout: 15_000 }); + await page.waitForFunction(() => document.querySelectorAll('[role="checkbox"]').length > 0, undefined, { timeout: 15_000 }); + await page.waitForTimeout(500); + await shoot(page, `${prefix}-context-review`); + // Leave the first source out of the next request, and pin the first saved result. + await sheet.getByRole('checkbox').first().click(); + const pin = sheet.getByTestId('debate-context-pin').first(); + if (await pin.count()) { + await pin.click(); + await page.waitForTimeout(400); + } + await page.getByTestId('debate-context-reset').waitFor({ timeout: 15_000 }); + await shoot(page, `${prefix}-context-review-changed`); + await page.close(); +} + /** * The limit and model sheets: Generate answered with the ai function's * refusals (402 limit_reached with its counts, then 403 upgrade_required). @@ -1938,6 +1975,10 @@ async function main() { await shootCaseEdits(signedIn, prefix, language, problems).catch(failedIn(signedIn, prefix, 'case-edits')); shots += 5; } + if (want('context')) { + await shootContextReview(signedIn, prefix, language, problems).catch(failedIn(signedIn, prefix, 'context')); + shots += 3; + } if (want('limits')) { await shootLimitSheets(signedIn, prefix, language, problems).catch(failedIn(signedIn, prefix, 'limits')); shots += 2; diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 7e55828c0..724620593 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -97,6 +97,7 @@ function Root() { {/* The website's limit and model dialogs; short, so the sheet stops at half height. */} + {/* Full screen above the tabs, as in mock 07. */} {/* History, full screen above the tabs like the documents it opens. */} diff --git a/apps/mobile/src/app/ask.tsx b/apps/mobile/src/app/ask.tsx index 870cc0e7d..e9d67e5ed 100644 --- a/apps/mobile/src/app/ask.tsx +++ b/apps/mobile/src/app/ask.tsx @@ -16,11 +16,13 @@ import { FadingScrollRow } from '@/components/edge-fade'; import { Icon } from '@/components/icon'; import type { IconName } from '@/components/icon-paths'; import { OneOffNotice } from '@/components/one-off-notice'; +import { ContextLine } from '@/components/context-line'; import { DailyUsageLine } from '@/components/tool-form'; import { Text } from '@/components/text'; import { AIError, isAbortError, type AIErrorKind, type ResponseExtras } from '@/lib/ai-request'; import { askOnce, canStream, isFinalAskError, isMessageWithinBudget, streamAsk, type AskMode, type AskOptions } from '@/lib/ask'; import { pickAskFiles, readAskFiles, type AskAttachment } from '@/lib/ask-attachments'; +import { resetContextChoices } from '@/lib/context-plan'; import { buildDebateContext } from '@/lib/context-request'; import type { FeatureConfig } from '@/lib/feature-config'; import { useAuth } from '@/lib/auth'; @@ -295,6 +297,8 @@ export default function AskScreen() { } if (!isCurrent()) return; + // Review choices cover one question, as on the website. + resetContextChoices('ask'); // A whole answer ranks the Library sources it used and says when the question was shortened, as on the website. if (whole.extras) { const { truncated, assessments } = whole.extras; @@ -531,6 +535,9 @@ export default function AskScreen() { {loading && !streaming ? : null} {opening ? : null} + {/* What the next question sends, below the conversation as the website shows it above its input. */} + {!busy ? : null} + {notice ? ( {notice} diff --git a/apps/mobile/src/app/attack/index.tsx b/apps/mobile/src/app/attack/index.tsx index 4823fa75e..355ca57f6 100644 --- a/apps/mobile/src/app/attack/index.tsx +++ b/apps/mobile/src/app/attack/index.tsx @@ -12,11 +12,13 @@ import { Field } from '@/components/field'; import { FormGroup, SelectRow, SwitchRow } from '@/components/form-rows'; import { ModelPicker } from '@/components/model-picker'; import { Text } from '@/components/text'; +import { ContextLine } from '@/components/context-line'; import { LiveToolRun, useLiveRun } from '@/components/live-run'; import { ToolError, ToolTitle, WorkingIn } from '@/components/tool-form'; import { AIError, isAbortError, type AIErrorKind } from '@/lib/ai-request'; import { useAuth } from '@/lib/auth'; import { backLabel } from '@/lib/back-label'; +import { resetContextChoices } from '@/lib/context-plan'; import { buildDebateContext } from '@/lib/context-request'; import { useDebates } from '@/lib/debate-context'; import { keepOpenResult, openResult } from '@/lib/open-results'; @@ -132,6 +134,8 @@ export default function AttackScreen() { signal: controller.signal, live: liveRun.begin(), }); + // Review choices cover one request, as on the website. + resetContextChoices('attack'); if (!isCurrent()) return; const assessed = await keepSourceAssessments(forUser, debateId, assessments, controller.signal); if (!isCurrent()) return; @@ -251,6 +255,7 @@ export default function AttackScreen() { /> + {error ? void submit()} /> : null} )} diff --git a/apps/mobile/src/app/brainstorm/index.tsx b/apps/mobile/src/app/brainstorm/index.tsx index 7bc5a821f..42f58cf0d 100644 --- a/apps/mobile/src/app/brainstorm/index.tsx +++ b/apps/mobile/src/app/brainstorm/index.tsx @@ -1,3 +1,4 @@ +import { requestSide } from '@debatelab/core/contract/debate-context'; import { brainstormDraftSections } from '@debatelab/core/ai/live-sections'; import type { BrainstormInput } from '@debatelab/core/ai/schemas/brainstorm'; import { useLocalSearchParams, useRouter } from 'expo-router'; @@ -12,11 +13,13 @@ import { Field } from '@/components/field'; import { FormGroup, SelectRow } from '@/components/form-rows'; import { ModelPicker } from '@/components/model-picker'; import { Text } from '@/components/text'; +import { ContextLine } from '@/components/context-line'; import { LiveToolRun, useLiveRun } from '@/components/live-run'; import { ToolError, ToolTitle, WorkingIn } from '@/components/tool-form'; import { AIError, isAbortError, type AIErrorKind } from '@/lib/ai-request'; import { useAuth } from '@/lib/auth'; import { backLabel } from '@/lib/back-label'; +import { resetContextChoices } from '@/lib/context-plan'; import { buildDebateContext } from '@/lib/context-request'; import { useDebates } from '@/lib/debate-context'; import { decideMotionTarget, shouldPrefillMotion } from '@/lib/motion'; @@ -112,6 +115,8 @@ export default function BrainstormScreen() { signal: controller.signal, live: liveRun.begin(), }); + // Review choices cover one request, as on the website. + resetContextChoices('brainstorm'); if (!isCurrent()) return; const assessed = await keepSourceAssessments(forUser, debateId, assessments, controller.signal); if (!isCurrent()) return; @@ -282,6 +287,7 @@ export default function BrainstormScreen() { style={styles.context} testID="brainstorm-context" /> + {error ? void submit()} /> : null} )} diff --git a/apps/mobile/src/app/context-review.tsx b/apps/mobile/src/app/context-review.tsx new file mode 100644 index 000000000..b95134113 --- /dev/null +++ b/apps/mobile/src/app/context-review.tsx @@ -0,0 +1,256 @@ +import type { DebateContextFeature, DebateContextSide } from '@debatelab/core/contract/debate-context'; +import type { ContextOutputItem, ContextSourceItem } from '@debatelab/core/debate/context-plan'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import type { TFunction } from 'i18next'; +import { useState, type ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native'; + +import { SecondaryButton, TextButton } from '@/components/buttons'; +import { Icon } from '@/components/icon'; +import { SheetScrollView } from '@/components/sheet'; +import { Text } from '@/components/text'; +import { useAuth } from '@/lib/auth'; +import { hasContextChoices, PinLimitError, resetContextChoices, setOutputChoice, setSourceChoice, togglePin, useContextPlan } from '@/lib/context-plan'; +import { useDebates } from '@/lib/debate-context'; +import { radius } from '@/theme/colors'; +import { useTheme } from '@/theme/use-theme'; + +const FEATURES = new Set(['generate', 'attack', 'brainstorm', 'factoids', 'ask', 'crossfire', 'sparring']); +const SIDES = new Set(['pro', 'con', 'both', 'neutral']); + +function statusNote(item: { overLimit: boolean; omittedForSize: boolean; shortened?: boolean; empty?: boolean }, t: TFunction): string | null { + if (item.overLimit) return t('debateContext.overLimit'); + if (item.omittedForSize) return t('debateContext.tooLarge'); + if (item.empty) return t('debateContext.empty'); + if (item.shortened) return t('debateContext.shortened'); + return null; +} + +/** + * The website's Review sheet (DebateContextReview): what the next request + * uses, with each source and saved result switched on or off for that request + * only, and up to three results pinned for every request in the debate. + */ +export default function ContextReviewSheet() { + const { t } = useTranslation(); + const router = useRouter(); + const { colors } = useTheme(); + const params = useLocalSearchParams<{ feature?: string; side?: string; debate?: string; exclude?: string; sources?: string }>(); + const feature = FEATURES.has(params.feature as DebateContextFeature) ? (params.feature as DebateContextFeature) : 'generate'; + const side = SIDES.has(params.side as DebateContextSide) ? (params.side as DebateContextSide) : 'both'; + const debateId = params.debate ?? null; + const { session } = useAuth(); + const userId = session?.user.id ?? null; + const { debates } = useDebates(); + const plan = useContextPlan(feature, side, debateId, { excludeChatId: params.exclude ?? null, includeSources: params.sources !== 'off' }); + const [pinning, setPinning] = useState(null); + const [pinError, setPinError] = useState(''); + const motion = debates.find((debate) => debate.id === debateId)?.motion; + + const pin = async (output: ContextOutputItem) => { + if (!userId || !plan) return; + const key = `${output.ref.kind}:${output.ref.id}`; + setPinning(key); + setPinError(''); + try { + await togglePin(userId, plan.debateId, output.ref, plan.resultKeys); + } catch (cause) { + if (!(cause instanceof PinLimitError)) console.warn('Could not save the pin:', cause instanceof Error ? cause.message : cause); + setPinError(cause instanceof PinLimitError ? t('debateContext.pinLimit') : t('debateContext.pinFailed')); + } finally { + setPinning(null); + } + }; + + return ( + + + + {t('debateContext.reviewTitle')} + + router.back()} /> + + + {t('debateContext.reviewDesc')} + + + {!plan ? ( + + ) : ( + <> + + + {t('debateContext.debateHeading')} + + {motion ? {motion} : null} + + {plan.policy.instructions ? t('debateContext.instructionsUsed') : t('debateContext.instructionsNotUsed')} + + + + + + {t('debateContext.sourcesHeading')} + + {!plan.policy.sources ? ( + + {plan.feature === 'sparring' ? t('debateContext.sparringNoSources') : t('debateContext.sourcesOff')} + + ) : plan.sources.length === 0 ? ( + + {t('debateContext.noSources')} + + ) : ( + + {plan.sources.map((source, index) => ( + setSourceChoice(plan.feature, plan.debateId, source.id, include)} /> + ))} + + )} + + + + + {t('debateContext.resultsHeading')} + + {!plan.policy.outputs ? ( + + {t('debateContext.sparringNoResults')} + + ) : plan.outputs.length === 0 ? ( + + {t('debateContext.noResults')} + + ) : ( + + {plan.outputs.map((output, index) => ( + setOutputChoice(plan.feature, plan.debateId, output.ref, include)} + onPin={() => void pin(output)} + /> + ))} + + )} + {pinError ? ( + + {pinError} + + ) : null} + + + {hasContextChoices(plan.feature) ? ( + resetContextChoices(plan.feature)} testID="debate-context-reset" /> + ) : null} + + )} + + ); +} + +/** A checkbox row: the box is the website's checkbox, the whole row toggles it. */ +function CheckRow({ checked, label, meta, note, first, onChange, trailing }: { + checked: boolean; + label: string; + meta?: string; + note?: string | null; + first: boolean; + onChange: (checked: boolean) => void; + trailing?: ReactNode; +}) { + const { colors } = useTheme(); + return ( + + onChange(!checked)} + style={({ pressed }) => [styles.rowMain, pressed && styles.pressed]}> + + {checked ? : null} + + + {label} + {meta ? ( + + {meta} + + ) : null} + {note ? ( + + {note} + + ) : null} + + + {trailing} + + ); +} + +function SourceRow({ source, first, onChange }: { source: ContextSourceItem; first: boolean; onChange: (include: boolean) => void }) { + const { t } = useTranslation(); + const note = source.included ? statusNote(source, t) : !source.active ? t('debateContext.inactive') : null; + return ; +} + +function OutputRow({ output, first, pinning, onChange, onPin }: { + output: ContextOutputItem; + first: boolean; + pinning: boolean; + onChange: (include: boolean) => void; + onPin: () => void; +}) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const kind = t(`debate.kinds.${output.ref.kind}`); + const title = output.title || kind; + const meta = `${output.side ? t('debateContext.resultMeta', { kind, side: t(`debate.side.${output.side}`) }) : kind}${output.pinned ? `, ${t('debateContext.pinned')}` : ''}`; + return ( + [styles.pin, output.pinned && { backgroundColor: colors.tile }, pressed && styles.pressed]} + testID="debate-context-pin"> + {pinning ? : } + + } + /> + ); +} + +const styles = StyleSheet.create({ + header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }, + title: { fontSize: 21, lineHeight: 26, letterSpacing: -0.3, flexShrink: 1 }, + body: { fontSize: 15, lineHeight: 21 }, + loading: { marginTop: 24 }, + section: { gap: 8 }, + heading: { fontSize: 16 }, + note: { fontSize: 14.5, lineHeight: 20 }, + group: { borderWidth: 2, borderRadius: radius.card, overflow: 'hidden' }, + row: { flexDirection: 'row', alignItems: 'center', paddingRight: 8 }, + rowMain: { flex: 1, flexDirection: 'row', alignItems: 'flex-start', gap: 12, paddingHorizontal: 14, paddingVertical: 12, minHeight: 48 }, + box: { width: 22, height: 22, borderRadius: 6, borderWidth: 2, alignItems: 'center', justifyContent: 'center', marginTop: 1 }, + rowText: { flex: 1, gap: 2 }, + rowLabel: { fontSize: 15.5, lineHeight: 21 }, + rowMeta: { fontSize: 13.5, lineHeight: 18 }, + pin: { width: 40, height: 40, borderRadius: 20, alignItems: 'center', justifyContent: 'center' }, + pressed: { opacity: 0.6 }, +}); diff --git a/apps/mobile/src/app/crossfire.tsx b/apps/mobile/src/app/crossfire.tsx index 9eb602226..2d24849d7 100644 --- a/apps/mobile/src/app/crossfire.tsx +++ b/apps/mobile/src/app/crossfire.tsx @@ -21,6 +21,7 @@ import { DocumentHeader } from '@/components/document-header'; import { Field } from '@/components/field'; import { FormGroup, SwitchRow } from '@/components/form-rows'; import { Icon } from '@/components/icon'; +import { ContextLine } from '@/components/context-line'; import { SessionCostNote } from '@/components/session-cost-note'; import { LiveTopBar, RoundIconButton } from '@/components/round-controls'; import { SegmentedControl } from '@/components/segmented-control'; @@ -30,6 +31,7 @@ import { ToolTitle, WorkingIn } from '@/components/tool-form'; import { abortError, AIError, isAbortError, type ResponseExtras, untilAborted } from '@/lib/ai-request'; import { useAuth } from '@/lib/auth'; import { backLabel } from '@/lib/back-label'; +import { resetContextChoices } from '@/lib/context-plan'; import { buildDebateContext } from '@/lib/context-request'; import { countActiveSources, @@ -280,6 +282,8 @@ export default function CrossfireScreen() { // The effect below saves the finished round from the state it reaches, with any signal that came in meanwhile. if (debateId) pendingRecord.current = { sessionId: snapshot.sessionId, forUser, debateId }; dispatch({ type: 'debrief-received', debrief }); + // Review choices cover the round; its last request clears them, as on the website. + resetContextChoices('crossfire'); afterResponse(forUser, debateId, extras); } catch (cause) { if (controller.signal.aborted || isAbortError(cause)) return; @@ -521,6 +525,7 @@ export default function CrossfireScreen() { disabled /> + {formError ? ( diff --git a/apps/mobile/src/app/factoids/index.tsx b/apps/mobile/src/app/factoids/index.tsx index e2cc8c2a1..014e544e4 100644 --- a/apps/mobile/src/app/factoids/index.tsx +++ b/apps/mobile/src/app/factoids/index.tsx @@ -1,3 +1,4 @@ +import { requestSide } from '@debatelab/core/contract/debate-context'; import { factoidsDraftSections } from '@debatelab/core/ai/live-sections'; import type { FactoidsInput } from '@debatelab/core/ai/schemas/factoids'; import { useLocalSearchParams, useRouter } from 'expo-router'; @@ -13,11 +14,13 @@ import { FormGroup, SelectRow } from '@/components/form-rows'; import { ModelPicker } from '@/components/model-picker'; import { SegmentedControl } from '@/components/segmented-control'; import { Text } from '@/components/text'; +import { ContextLine } from '@/components/context-line'; import { LiveToolRun, useLiveRun } from '@/components/live-run'; import { ToolError, ToolTitle, WorkingIn } from '@/components/tool-form'; import { AIError, isAbortError, type AIErrorKind } from '@/lib/ai-request'; import { useAuth } from '@/lib/auth'; import { backLabel } from '@/lib/back-label'; +import { resetContextChoices } from '@/lib/context-plan'; import { buildDebateContext } from '@/lib/context-request'; import { useDebates } from '@/lib/debate-context'; import { keepOpenResult } from '@/lib/open-results'; @@ -120,6 +123,8 @@ export default function FactoidsScreen() { signal: controller.signal, live: liveRun.begin(), }); + // Review choices cover one request, as on the website. + resetContextChoices('factoids'); if (!isCurrent()) return; const assessed = await keepSourceAssessments(forUser, debateId, assessments, controller.signal); if (!isCurrent()) return; @@ -276,6 +281,7 @@ export default function FactoidsScreen() { placeholder={t('factoidsForm.timeframePlaceholder')} testID="factoids-timeframe" /> + {error ? void submit({ ...form, claim: form.claim.trim() })} /> : null} )} diff --git a/apps/mobile/src/app/generate.tsx b/apps/mobile/src/app/generate.tsx index 16b2bae00..e84aad068 100644 --- a/apps/mobile/src/app/generate.tsx +++ b/apps/mobile/src/app/generate.tsx @@ -13,11 +13,13 @@ import { FormGroup, SelectRow } from '@/components/form-rows'; import { ModelPicker } from '@/components/model-picker'; import { SegmentedControl } from '@/components/segmented-control'; import { Text } from '@/components/text'; +import { ContextLine } from '@/components/context-line'; import { LiveToolRun, useLiveRun } from '@/components/live-run'; import { ToolError, ToolTitle, WorkingIn } from '@/components/tool-form'; import { AIError, isAbortError, type AIErrorKind } from '@/lib/ai-request'; import { useAuth } from '@/lib/auth'; import { backLabel } from '@/lib/back-label'; +import { resetContextChoices } from '@/lib/context-plan'; import { buildDebateContext } from '@/lib/context-request'; import { useDebates } from '@/lib/debate-context'; import { decideMotionTarget, shouldPrefillMotion } from '@/lib/motion'; @@ -108,6 +110,8 @@ export default function GenerateScreen() { signal: controller.signal, live: liveRun.begin(), }); + // Review choices cover one request, as on the website. + resetContextChoices('generate'); if (!isCurrent()) return; const assessed = await keepSourceAssessments(forUser, debateId, assessments, controller.signal); if (!isCurrent()) return; @@ -299,6 +303,7 @@ export default function GenerateScreen() { ]} /> + {error ? void submit()} /> : null} )} diff --git a/apps/mobile/src/app/sparring/index.tsx b/apps/mobile/src/app/sparring/index.tsx index ebbfb32ad..0b5f82840 100644 --- a/apps/mobile/src/app/sparring/index.tsx +++ b/apps/mobile/src/app/sparring/index.tsx @@ -29,6 +29,7 @@ import { FormGroup, SelectRow, SwitchRow } from '@/components/form-rows'; import { Icon } from '@/components/icon'; import { LiveTopBar, MainRoundControl, RoundIconButton, SideControl } from '@/components/round-controls'; import { SegmentedControl } from '@/components/segmented-control'; +import { ContextLine } from '@/components/context-line'; import { SessionCostNote } from '@/components/session-cost-note'; import { SparringScorecardScreen } from '@/components/sparring-views'; import { Text } from '@/components/text'; @@ -411,6 +412,7 @@ export default function SparringScreen() { disabled /> + {formError ? ( diff --git a/apps/mobile/src/components/context-line.tsx b/apps/mobile/src/components/context-line.tsx new file mode 100644 index 000000000..58e3442b3 --- /dev/null +++ b/apps/mobile/src/components/context-line.tsx @@ -0,0 +1,110 @@ +import type { DebateContextFeature, DebateContextSide } from '@debatelab/core/contract/debate-context'; +import type { DebateContextPlan } from '@debatelab/core/debate/context-plan'; +import { useRouter } from 'expo-router'; +import type { TFunction } from 'i18next'; +import { useTranslation } from 'react-i18next'; +import { StyleSheet, View } from 'react-native'; + +import { TextButton } from './buttons'; +import { Text } from './text'; + +import { useContextPlan } from '@/lib/context-plan'; +import { useDebates } from '@/lib/debate-context'; +import { radius } from '@/theme/colors'; +import { useTheme } from '@/theme/use-theme'; + +function listFormat(language: string, parts: string[]): string { + try { + return new Intl.ListFormat(language, { style: 'long', type: 'conjunction' }).format(parts); + } catch { + return parts.join(', '); + } +} + +/** The website's summary: "Using 3 sources and 2 results: Case (In favor), Attack". */ +export function contextSummary(plan: DebateContextPlan, t: TFunction, language: string): { text: string; names: string } { + // "Sent" means it reaches the model; anything chosen but over the limit or too large did not fit. + const sent = (item: { included: boolean; overLimit: boolean; omittedForSize: boolean }) => item.included && !item.overLimit && !item.omittedForSize; + const chosenSources = plan.sources.filter((source) => source.included).length; + const sentSources = plan.sources.filter(sent).length; + const chosenOutputs = plan.outputs.filter((output) => output.included && !output.empty); + const sentOutputs = chosenOutputs.filter(sent); + const count = (key: 'sourceCount' | 'resultCount', used: number, chosen: number) => used === chosen + ? t(`debateContext.${key}`, { count: used }) + : `${t(`debateContext.${key}Of`, { used, count: chosen })} ${t('debateContext.didntFit', { count: chosen - used })}`; + const parts = [ + chosenSources > 0 && count('sourceCount', sentSources, chosenSources), + chosenOutputs.length > 0 && count('resultCount', sentOutputs.length, chosenOutputs.length), + ].filter((part): part is string => Boolean(part)); + const names = sentOutputs.map((output) => output.side === 'pro' || output.side === 'con' + ? t('debateContext.resultName', { kind: t(`debate.kinds.${output.ref.kind}`), side: t(`debate.side.${output.side}`) }) + : t(`debate.kinds.${output.ref.kind}`)); + return { + text: parts.length > 0 ? t('debateContext.using', { list: listFormat(language, parts) }) : t('debateContext.usingMotionOnly'), + names: names.join(', '), + }; +} + +/** + * The website's DebateContextControl: the line above a form's main button + * that says which Current Debate material the next request sends, with + * Review to change it for that request. One-off work sends none, and the + * one-off notice already says so. + */ +export function ContextLine({ + feature, + side, + debateId: debateIdOverride, + excludeChatId, + includeSources, +}: { + feature: DebateContextFeature; + side: DebateContextSide; + /** The debate the request uses when it is not the Current Debate, such as a reopened chat. */ + debateId?: string | null; + excludeChatId?: string | null; + includeSources?: boolean; +}) { + const { t, i18n } = useTranslation(); + const router = useRouter(); + const { colors } = useTheme(); + const { activeDebateId } = useDebates(); + const debateId = debateIdOverride !== undefined ? debateIdOverride : activeDebateId; + const plan = useContextPlan(feature, side, debateId, { excludeChatId, includeSources }); + if (!debateId || !plan) return null; + const summary = contextSummary(plan, t, i18n.resolvedLanguage ?? 'en'); + return ( + + + + {summary.text} + + {summary.names ? ( + + {`: ${summary.names}`} + + ) : null} + + + router.push({ + pathname: '/context-review', + params: { + feature, + side, + debate: debateId, + ...(excludeChatId ? { exclude: excludeChatId } : {}), + ...(includeSources === false ? { sources: 'off' } : {}), + }, + }) + } + /> + + ); +} + +const styles = StyleSheet.create({ + line: { flexDirection: 'row', alignItems: 'center', gap: 10, borderWidth: 2, borderRadius: radius.field, paddingLeft: 14, paddingRight: 10, paddingVertical: 8 }, + text: { flex: 1, fontSize: 14.5, lineHeight: 20 }, +}); diff --git a/apps/mobile/src/components/icon-paths.ts b/apps/mobile/src/components/icon-paths.ts index 40dbe70e1..23e37a551 100644 --- a/apps/mobile/src/components/icon-paths.ts +++ b/apps/mobile/src/components/icon-paths.ts @@ -57,6 +57,7 @@ export const iconPaths = { stop: '', paperclip: '', bookmark: '', + pin: '', } as const; export type IconName = keyof typeof iconPaths; diff --git a/apps/mobile/src/lib/context-plan.ts b/apps/mobile/src/lib/context-plan.ts new file mode 100644 index 000000000..56a444970 --- /dev/null +++ b/apps/mobile/src/lib/context-plan.ts @@ -0,0 +1,232 @@ +import { isDebateContextOutputKind, MAX_PINNED_OUTPUTS, type DebateContextFeature, type DebateContextOutputKind, type DebateContextSide, type DebateOutputRef } from '@debatelab/core/contract/debate-context'; +import { + outputKey, + planDebateContext, + type DebateContextOverride, + type DebateContextPlan, + type PlanResult, + type PlanSource, + type PlanSparringSession, +} from '@debatelab/core/debate/context-plan'; +import { useFocusEffect } from 'expo-router'; +import { useCallback, useMemo, useSyncExternalStore } from 'react'; + +import { useAuth } from './auth'; +import { sessionSlot } from './session-memory'; +import { supabase } from './supabase'; + +/** + * The Current Debate material a request sends, as the website plans it + * (packages/core/src/debate/context-plan.ts) with its Review choices and pins + * (src/lib/stores/debate-context-store.ts): the material read once per form + * opening, the choices for one feature's next request, and the debate's pins + * in debate_context_pins (three at most). The line on each tool form, its + * Review sheet and the request itself read the same plan. + */ + +type ResultKind = Exclude; + +const RESULT_TABLES = { + case: 'cases', + attack: 'attacks', + brainstorm: 'brainstorms', + factoids: 'factoids', + crossfire: 'crossfires', +} as const satisfies Partial>; +const RESULT_KINDS = Object.keys(RESULT_TABLES) as (keyof typeof RESULT_TABLES)[]; + +export interface ContextMaterial { + debateId: string; + sources: PlanSource[]; + results: Partial>; + sparring: PlanSparringSession[]; + pins: DebateOutputRef[]; +} + +const material = sessionSlot(() => new Map()); +const overrides = sessionSlot((): Partial> => ({})); +const listeners = new Set<() => void>(); +let version = 0; + +function emit() { + version += 1; + listeners.forEach((listener) => listener()); +} + +function subscribe(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); +} + +function rowsOf(result: { data: unknown; error: { message: string } | null }): T[] { + if (result.error) throw new Error(result.error.message); + return (result.data ?? []) as T[]; +} + +const time = (value: string) => new Date(value).getTime(); + +/** + * Reads a debate's material: its sources, saved results with their outputs + * (chats with their messages), judged Sparring rounds and pins. Every read + * filters on the user; row level security is the real boundary. Pins are + * optional: if they cannot be read, the automatic selection is used. + */ +export async function loadContextMaterial(userId: string, debateId: string): Promise { + const [sources, pins, sparring, chats, ...results] = await Promise.all([ + supabase.from('sources').select('id,title,content,type,is_active').eq('user_id', userId).eq('debate_id', debateId).order('created_at', { ascending: false }), + supabase.from('debate_context_pins').select('item_kind,item_id').eq('user_id', userId).eq('debate_id', debateId), + supabase.from('sparring_sessions').select('id,motion,user_side,updated_at,final_evaluation').eq('user_id', userId).eq('debate_id', debateId), + supabase.from('chats').select('id,title,side,created_at,messages(role,content,created_at)').eq('user_id', userId).eq('debate_id', debateId), + ...RESULT_KINDS.map((kind) => supabase.from(RESULT_TABLES[kind]).select('id,title,side,created_at,output').eq('user_id', userId).eq('debate_id', debateId)), + ]); + const next: ContextMaterial = { + debateId, + sources: rowsOf<{ id: string; title: string; content: string | null; type: string | null; is_active: boolean }>(sources).map((row) => ({ + id: row.id, + title: row.title, + content: row.content ?? '', + type: row.type ?? 'text', + debateId, + isActive: row.is_active, + })), + results: { + chat: rowsOf<{ id: string; title: string; side: string | null; created_at: string; messages: { role: string; content: string; created_at: string }[] | null }>(chats).map((row) => ({ + id: row.id, + title: row.title, + side: row.side, + debateId, + createdAt: time(row.created_at), + data: [...(row.messages ?? [])].sort((a, b) => time(a.created_at) - time(b.created_at)).map(({ role, content }) => ({ role, content })), + })), + }, + sparring: rowsOf<{ id: string; motion: string; user_side: string | null; updated_at: string; final_evaluation: unknown }>(sparring).map((row) => ({ + id: row.id, + motion: row.motion, + debateId, + userSide: row.user_side, + updatedAt: time(row.updated_at), + scorecard: row.final_evaluation, + })), + pins: pins.error + ? [] + : ((pins.data ?? []) as { item_kind: unknown; item_id: unknown }[]).flatMap((row) => + isDebateContextOutputKind(row.item_kind) && typeof row.item_id === 'string' ? [{ kind: row.item_kind, id: row.item_id }] : []), + }; + RESULT_KINDS.forEach((kind, index) => { + next.results[kind] = rowsOf<{ id: string; title: string; side: string | null; created_at: string; output: unknown }>(results[index]).map((row) => ({ + id: row.id, + title: row.title, + side: row.side, + debateId, + createdAt: time(row.created_at), + data: row.output, + })); + }); + material.get().set(debateId, next); + emit(); + return next; +} + +interface PlanOptions { + /** The chat open in Ask is already the conversation. */ + excludeChatId?: string | null; + /** False when the request reads no Library sources, as Crossfire with grounding off. */ + includeSources?: boolean; +} + +function planFor(held: ContextMaterial, feature: DebateContextFeature, side: DebateContextSide, options: PlanOptions): DebateContextPlan { + return planDebateContext(feature, side, held.debateId, { + sourceLibrary: held.sources, + results: held.results, + sparringSessions: held.sparring, + pins: held.pins, + override: overrides.get()[feature], + excludeChatId: options.excludeChatId ?? null, + }, { includeSources: options.includeSources }); +} + +/** The plan for a request about to be sent, from material read now. */ +export async function planRequest(userId: string, debateId: string, feature: DebateContextFeature, side: DebateContextSide, options: PlanOptions = {}): Promise { + return planFor(await loadContextMaterial(userId, debateId), feature, side, options); +} + +/** The plan for a form on screen: read when the form comes into view, kept current as choices and pins change. */ +export function useContextPlan(feature: DebateContextFeature, side: DebateContextSide, debateId: string | null, options: PlanOptions = {}): DebateContextPlan | null { + const { session } = useAuth(); + const userId = session?.user.id ?? null; + const current = useSyncExternalStore(subscribe, () => version); + useFocusEffect( + useCallback(() => { + if (!userId || !debateId) return; + loadContextMaterial(userId, debateId).catch((cause: unknown) => console.warn('Could not read the debate context:', cause instanceof Error ? cause.message : cause)); + }, [userId, debateId]), + ); + const { excludeChatId, includeSources } = options; + return useMemo(() => { + const held = debateId ? material.get().get(debateId) : undefined; + return held ? planFor(held, feature, side, { excludeChatId, includeSources }) : null; + // `current` changes whenever the material, a choice or a pin does. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [current, debateId, feature, side, excludeChatId, includeSources]); +} + +function ensureOverride(feature: DebateContextFeature, debateId: string): DebateContextOverride { + const existing = overrides.get()[feature]; + return existing && existing.debateId === debateId ? existing : { debateId, sources: {}, outputs: {} }; +} + +/** Includes or leaves out one source for the feature's next request. */ +export function setSourceChoice(feature: DebateContextFeature, debateId: string, sourceId: string, include: boolean) { + const override = ensureOverride(feature, debateId); + overrides.set({ ...overrides.get(), [feature]: { ...override, sources: { ...override.sources, [sourceId]: include } } }); + emit(); +} + +/** Includes or leaves out one saved result for the feature's next request. */ +export function setOutputChoice(feature: DebateContextFeature, debateId: string, ref: DebateOutputRef, include: boolean) { + const override = ensureOverride(feature, debateId); + overrides.set({ ...overrides.get(), [feature]: { ...override, outputs: { ...override.outputs, [outputKey(ref)]: include } } }); + emit(); +} + +export function hasContextChoices(feature: DebateContextFeature): boolean { + return Boolean(overrides.get()[feature]); +} + +/** Review choices cover one request (or one Crossfire round); the next starts from the automatic selection. */ +export function resetContextChoices(feature: DebateContextFeature) { + if (!overrides.get()[feature]) return; + const next = { ...overrides.get() }; + delete next[feature]; + overrides.set(next); + emit(); +} + +export class PinLimitError extends Error { + constructor() { + super('pin_limit'); + this.name = 'PinLimitError'; + } +} + +/** + * Pins or unpins a saved result in its debate, as the website's togglePin: + * at most three pins count, and only pins on results that still exist. + */ +export async function togglePin(userId: string, debateId: string, ref: DebateOutputRef, resultKeys: ReadonlySet): Promise { + const held = material.get().get(debateId); + if (!held) return; + const key = outputKey(ref); + const pinned = held.pins.some((pin) => outputKey(pin) === key); + if (!pinned && held.pins.filter((pin) => resultKeys.has(outputKey(pin))).length >= MAX_PINNED_OUTPUTS) throw new PinLimitError(); + const { error } = pinned + ? await supabase.from('debate_context_pins').delete().eq('user_id', userId).eq('debate_id', debateId).eq('item_kind', ref.kind).eq('item_id', ref.id) + : await supabase.from('debate_context_pins').insert({ user_id: userId, debate_id: debateId, item_kind: ref.kind, item_id: ref.id }); + // A pin that already exists is the pin asked for. + if (error && error.code !== '23505') throw new Error(error.message); + const latest = material.get().get(debateId); + if (!latest) return; + const without = latest.pins.filter((pin) => outputKey(pin) !== key); + material.get().set(debateId, { ...latest, pins: pinned ? without : [...without, ref] }); + emit(); +} diff --git a/apps/mobile/src/lib/context-request.ts b/apps/mobile/src/lib/context-request.ts index bbbf061e4..3261fa224 100644 --- a/apps/mobile/src/lib/context-request.ts +++ b/apps/mobile/src/lib/context-request.ts @@ -1,77 +1,12 @@ -import { - boundOutputDigests, - digestOutput, - isDebateContextOutputKind, - MAX_CONTEXT_OUTPUTS, - MAX_CONTEXT_SOURCES, - selectDefaultOutputs, - type DebateContextFeature, - type DebateContextOutputKind, - type DebateContextRequest, - type DebateContextSide, - type DebateOutputCandidate, - type DebateOutputRef, -} from '@debatelab/core/contract/debate-context'; +import type { DebateContextFeature, DebateContextRequest, DebateContextSide } from '@debatelab/core/contract/debate-context'; -import { supabase } from './supabase'; - -/** - * The Current Debate material a request sends: IDs only, picked the way the - * website's planDebateContext (src/lib/debate/context.ts) picks them with no - * Review choices. Pins come from debate_context_pins, as on the website; - * if they cannot be read, the automatic selection is used. The server loads - * and checks the text. - */ - -const RESULT_TABLES = { - case: 'cases', - attack: 'attacks', - brainstorm: 'brainstorms', - factoids: 'factoids', - chat: 'chats', - crossfire: 'crossfires', -} as const satisfies Partial>; -type ResultKind = keyof typeof RESULT_TABLES; -const RESULT_KINDS = Object.keys(RESULT_TABLES) as ResultKind[]; - -const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; - -function sideOf(value: unknown): DebateContextSide | null { - return value === 'pro' || value === 'con' || value === 'both' || value === 'neutral' ? value : null; -} - -function rowsOf(result: { data: unknown; error: { message: string } | null }): T[] { - if (result.error) throw new Error(result.error.message); - return (result.data ?? []) as T[]; -} - -/** - * The stored text of one saved result, in the shape digestOutput reads. Like - * every read here it filters on the user, except messages, which have no user - * column and belong to a chat from the user's own list. - */ -async function outputData(userId: string, ref: DebateOutputRef): Promise { - if (ref.kind === 'chat') { - const result = await supabase.from('messages').select('role,content').eq('chat_id', ref.id).order('created_at', { ascending: true }); - return rowsOf(result); - } - if (ref.kind === 'sparring') { - const result = await supabase - .from('sparring_sessions') - .select('user_side,final_evaluation') - .eq('user_id', userId) - .eq('id', ref.id) - .maybeSingle(); - if (result.error) throw new Error(result.error.message); - const row = result.data as { user_side?: unknown; final_evaluation?: unknown } | null; - return { userSide: row?.user_side, scorecard: row?.final_evaluation }; - } - const result = await supabase.from(RESULT_TABLES[ref.kind]).select('output').eq('user_id', userId).eq('id', ref.id).maybeSingle(); - if (result.error) throw new Error(result.error.message); - return (result.data as { output?: unknown } | null)?.output; -} +import { planRequest } from './context-plan'; /** + * The Current Debate material a request sends: IDs only, planned as the + * website's planDebateContext plans them, with the student's Review choices + * and the debate's pins (context-plan.ts). The server loads and checks the text. + * * `side` is the side the request prepares, as the website's requestSide * picks it. `excludeChatId` is the chat open in Ask: it is already the * conversation. @@ -83,46 +18,5 @@ export async function buildDebateContext( side: DebateContextSide, excludeChatId: string | null = null, ): Promise { - const [sources, pins, sparring, ...results] = await Promise.all([ - supabase.from('sources').select('id,is_active').eq('user_id', userId).eq('debate_id', debateId).order('created_at', { ascending: false }), - supabase.from('debate_context_pins').select('item_kind,item_id').eq('user_id', userId).eq('debate_id', debateId), - supabase.from('sparring_sessions').select('id,user_side,updated_at').eq('user_id', userId).eq('debate_id', debateId), - ...RESULT_KINDS.map((kind) => - supabase.from(RESULT_TABLES[kind]).select('id,side,created_at').eq('user_id', userId).eq('debate_id', debateId)), - ]); - - const sourceIds = rowsOf<{ id: string; is_active: boolean }>(sources) - .filter((source) => source.is_active && UUID.test(source.id)) - .slice(0, MAX_CONTEXT_SOURCES) - .map((source) => source.id); - - // Pins are optional; the website falls back to the automatic selection when they cannot be read. - const pinnedKeys = new Set( - pins.error ? [] : ((pins.data ?? []) as { item_kind: unknown; item_id: unknown }[]).flatMap((row) => - isDebateContextOutputKind(row.item_kind) && typeof row.item_id === 'string' ? [`${row.item_kind}:${row.item_id}`] : []), - ); - - const candidates: DebateOutputCandidate[] = []; - RESULT_KINDS.forEach((kind, index) => { - for (const row of rowsOf<{ id: string; side: unknown; created_at: string }>(results[index])) { - if (!UUID.test(row.id) || (kind === 'chat' && row.id === excludeChatId)) continue; - candidates.push({ kind, id: row.id, side: sideOf(row.side), createdAt: new Date(row.created_at).getTime(), pinned: pinnedKeys.has(`${kind}:${row.id}`) }); - } - }); - for (const row of rowsOf<{ id: string; user_side: unknown; updated_at: string }>(sparring)) { - if (!UUID.test(row.id)) continue; - candidates.push({ kind: 'sparring', id: row.id, side: sideOf(row.user_side), createdAt: new Date(row.updated_at).getTime(), pinned: pinnedKeys.has(`sparring:${row.id}`) }); - } - - const sent = selectDefaultOutputs(candidates, feature, side).slice(0, MAX_CONTEXT_OUTPUTS); - // Results with nothing usable yet, such as an unjudged round, are not sent. - const digests = await Promise.all(sent.map(async (ref) => ({ ...ref, digest: digestOutput(ref.kind, await outputData(userId, ref)) }))); - const empty = new Set(boundOutputDigests(digests).empty.map((item) => `${item.kind}:${item.id}`)); - - return { - debateId, - side, - sourceIds, - outputs: sent.filter((ref) => !empty.has(`${ref.kind}:${ref.id}`)), - }; + return (await planRequest(userId, debateId, feature, side, { excludeChatId })).request; } diff --git a/packages/core/src/debate/context-plan.ts b/packages/core/src/debate/context-plan.ts new file mode 100644 index 000000000..d65b33de1 --- /dev/null +++ b/packages/core/src/debate/context-plan.ts @@ -0,0 +1,254 @@ +// The Current Debate material a request will send, with the student's Review +// choices applied: the website's planner, shared with the iOS app. The server +// re-checks every ID and applies the same budgets. + +import { + boundOutputDigests, + type DebateContextFeature, + type DebateContextOutputKind, + type DebateContextRequest, + type DebateContextSide, + type DebateOutputRef, + digestOutput, + MAX_CONTEXT_OUTPUTS, + MAX_CONTEXT_SOURCES, + selectDefaultOutputs, +} from '../contract/debate-context'; +import { boundPromptSources } from '../contract/source-bounds'; + +/** A Library source, as both clients hold it. */ +export interface PlanSource { + id: string; + title: string; + content: string; + /** The source type the prompt bounds read (url, file, text). */ + type: string; + debateId?: string | null; + isActive: boolean; +} + +/** A saved result: its row and the stored output digestOutput reads. */ +export interface PlanResult { + id: string; + title: string; + side?: string | null; + debateId?: string | null; + createdAt: number; + data: unknown; +} + +/** A judged Sparring round. */ +export interface PlanSparringSession { + id: string; + motion: string; + debateId?: string | null; + userSide?: string | null; + updatedAt: number; + scorecard?: unknown; +} + +/** The student's Review choices for one feature's next request, in one debate. */ +export interface DebateContextOverride { + debateId: string; + sources: Record; + outputs: Record; +} + +export function outputKey(ref: DebateOutputRef): string { + return `${ref.kind}:${ref.id}`; +} + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +/** What each feature may use, mirroring the server's per-feature policy. */ +export interface DebateContextPolicy { + sources: boolean; + outputs: boolean; + instructions: boolean; +} + +export function debateContextPolicy(feature: DebateContextFeature, options: { includeSources?: boolean } = {}): DebateContextPolicy { + // A live sparring round stays transcript-only. + if (feature === 'sparring') return { sources: false, outputs: false, instructions: true }; + // Crossfire keeps its non-overridable transcript rules. + if (feature === 'crossfire') return { sources: options.includeSources !== false, outputs: true, instructions: false }; + return { sources: options.includeSources !== false, outputs: true, instructions: true }; +} + +export interface ContextSourceItem { + id: string; + title: string; + active: boolean; + included: boolean; + /** Included but past the per-request source limit, so not sent. */ + overLimit: boolean; + /** Sent but dropped or shortened to fit the prompt budget. */ + omittedForSize: boolean; + shortened: boolean; +} + +export interface ContextOutputItem { + ref: DebateOutputRef; + title: string; + side: DebateContextSide | null; + createdAt: number; + pinned: boolean; + included: boolean; + overLimit: boolean; + omittedForSize: boolean; + /** Nothing usable yet, such as an unjudged sparring round. */ + empty: boolean; +} + +export interface DebateContextPlan { + debateId: string; + feature: DebateContextFeature; + side: DebateContextSide; + policy: DebateContextPolicy; + sources: ContextSourceItem[]; + outputs: ContextOutputItem[]; + /** Every result in this debate, the open Ask chat included; pins on anything else no longer count. */ + resultKeys: ReadonlySet; + request: DebateContextRequest; +} + +export interface DebateContextInputs { + sourceLibrary: PlanSource[]; + results: Partial, PlanResult[]>>; + sparringSessions: PlanSparringSession[]; + pins: DebateOutputRef[]; + override?: DebateContextOverride; + /** The chat currently open in Ask is already the conversation itself. */ + excludeChatId?: string | null; +} + +function sideOf(value: unknown): DebateContextSide | null { + return value === 'pro' || value === 'con' || value === 'both' || value === 'neutral' ? value : null; +} + +interface OutputCandidate { + ref: DebateOutputRef; + title: string; + side: DebateContextSide | null; + createdAt: number; + data: unknown; +} + +function outputCandidates(debateId: string, inputs: DebateContextInputs): OutputCandidate[] { + const candidates: OutputCandidate[] = []; + for (const [kind, items] of Object.entries(inputs.results) as Array<[DebateContextOutputKind, PlanResult[] | undefined]>) { + for (const item of items ?? []) { + if (item.debateId !== debateId || !UUID.test(item.id)) continue; + if (kind === 'chat' && item.id === inputs.excludeChatId) continue; + candidates.push({ ref: { kind, id: item.id }, title: item.title, side: sideOf(item.side), createdAt: item.createdAt, data: item.data }); + } + } + for (const session of inputs.sparringSessions) { + if (session.debateId !== debateId || !UUID.test(session.id)) continue; + candidates.push({ + ref: { kind: 'sparring', id: session.id }, + title: session.motion, + side: sideOf(session.userSide), + createdAt: session.updatedAt, + data: { userSide: session.userSide, scorecard: session.scorecard }, + }); + } + return candidates.sort((a, b) => b.createdAt - a.createdAt); +} + +/** + * The Current Debate material the next request in `feature` will send, with + * the student's Review choices applied. The server re-checks every ID and + * applies the same budgets, and its response reports what actually reached + * the model. + */ +export function planDebateContext( + feature: DebateContextFeature, + side: DebateContextSide, + debateId: string, + inputs: DebateContextInputs, + options: { includeSources?: boolean } = {}, +): DebateContextPlan { + const policy = debateContextPolicy(feature, options); + const override = inputs.override?.debateId === debateId ? inputs.override : undefined; + + const debateSources = policy.sources + ? inputs.sourceLibrary.filter((source) => source.debateId === debateId && UUID.test(source.id)) + : []; + const includedSources = debateSources.filter((source) => override?.sources[source.id] ?? source.isActive); + const sentSources = includedSources.slice(0, MAX_CONTEXT_SOURCES); + const boundedSources = boundPromptSources(sentSources); + const sourceState = new Map(sentSources.map((source, index) => { + const kept = boundedSources[index]; + return [source.id, { omittedForSize: !kept, shortened: Boolean(kept && kept.content.length < source.content.length) }]; + })); + const sources: ContextSourceItem[] = debateSources.map((source) => { + const included = includedSources.includes(source); + const state = sourceState.get(source.id); + return { + id: source.id, + title: source.title, + active: source.isActive, + included, + overLimit: included && !state, + omittedForSize: state?.omittedForSize ?? false, + shortened: state?.shortened ?? false, + }; + }); + + const pinnedKeys = new Set(inputs.pins.map(outputKey)); + const candidates = policy.outputs ? outputCandidates(debateId, inputs) : []; + // A pin on a result that isn't loaded here, because it was deleted or is + // older than local history keeps, is never shown or sent. It must not use up + // the limit, or the student could never free that slot. + const resultKeys = new Set(outputCandidates(debateId, { ...inputs, excludeChatId: null }).map((item) => outputKey(item.ref))); + const defaults = selectDefaultOutputs( + candidates.map((item) => ({ ...item.ref, side: item.side, createdAt: item.createdAt, pinned: pinnedKeys.has(outputKey(item.ref)) })), + feature, + side, + ); + const defaultKeys = new Set(defaults.map(outputKey)); + const isIncluded = (item: OutputCandidate) => override?.outputs[outputKey(item.ref)] ?? defaultKeys.has(outputKey(item.ref)); + // Defaults keep their order (pins first); extra choices follow, newest first. + const byKey = new Map(candidates.map((item) => [outputKey(item.ref), item])); + const ordered = [ + ...defaults.map((ref) => byKey.get(outputKey(ref))!).filter(isIncluded), + ...candidates.filter((item) => !defaultKeys.has(outputKey(item.ref)) && isIncluded(item)), + ]; + const sentOutputs = ordered.slice(0, MAX_CONTEXT_OUTPUTS); + const bounded = boundOutputDigests(sentOutputs.map((item) => ({ key: outputKey(item.ref), digest: digestOutput(item.ref.kind, item.data) }))); + const tooLarge = new Set(bounded.omittedForSize.map((item) => item.key)); + const empty = new Set(bounded.empty.map((item) => item.key)); + const sentKeys = new Set(sentOutputs.map((item) => outputKey(item.ref))); + const outputs: ContextOutputItem[] = candidates.map((item) => { + const key = outputKey(item.ref); + const included = isIncluded(item); + return { + ref: item.ref, + title: item.title, + side: item.side, + createdAt: item.createdAt, + pinned: pinnedKeys.has(key), + included, + overLimit: included && !sentKeys.has(key), + omittedForSize: tooLarge.has(key), + empty: empty.has(key), + }; + }); + + return { + debateId, + feature, + side, + policy, + sources, + outputs, + resultKeys, + request: { + debateId, + side, + sourceIds: sentSources.map((source) => source.id), + outputs: sentOutputs.filter((item) => !empty.has(outputKey(item.ref))).map((item) => item.ref), + }, + }; +} diff --git a/src/lib/debate/context.ts b/src/lib/debate/context.ts index fecb09031..114e1b762 100644 --- a/src/lib/debate/context.ts +++ b/src/lib/debate/context.ts @@ -1,211 +1,10 @@ -import { - boundOutputDigests, - type DebateContextFeature, - type DebateContextOutputKind, - type DebateContextRequest, - type DebateContextSide, - type DebateOutputRef, - digestOutput, - MAX_CONTEXT_OUTPUTS, - MAX_CONTEXT_SOURCES, - selectDefaultOutputs, -} from '../../../supabase/functions/_shared/debate-context'; -import { boundPromptSources } from '../../../supabase/functions/_shared/source-bounds'; -import type { SavedItem, Source } from '@/lib/stores/session-store'; -import type { SparringSession } from '@/lib/stores/sparring-store'; -import { outputKey, type DebateContextOverride } from '@/lib/stores/debate-context-store'; - -const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; - -/** What each feature may use, mirroring the server's per-feature policy. */ -export interface DebateContextPolicy { - sources: boolean; - outputs: boolean; - instructions: boolean; -} - -export function debateContextPolicy(feature: DebateContextFeature, options: { includeSources?: boolean } = {}): DebateContextPolicy { - // A live sparring round stays transcript-only. - if (feature === 'sparring') return { sources: false, outputs: false, instructions: true }; - // Crossfire keeps its non-overridable transcript rules. - if (feature === 'crossfire') return { sources: options.includeSources !== false, outputs: true, instructions: false }; - return { sources: options.includeSources !== false, outputs: true, instructions: true }; -} - -export interface ContextSourceItem { - id: string; - title: string; - active: boolean; - included: boolean; - /** Included but past the per-request source limit, so not sent. */ - overLimit: boolean; - /** Sent but dropped or shortened to fit the prompt budget. */ - omittedForSize: boolean; - shortened: boolean; -} - -export interface ContextOutputItem { - ref: DebateOutputRef; - title: string; - side: DebateContextSide | null; - createdAt: number; - pinned: boolean; - included: boolean; - overLimit: boolean; - omittedForSize: boolean; - /** Nothing usable yet, such as an unjudged sparring round. */ - empty: boolean; -} - -export interface DebateContextPlan { - debateId: string; - feature: DebateContextFeature; - side: DebateContextSide; - policy: DebateContextPolicy; - sources: ContextSourceItem[]; - outputs: ContextOutputItem[]; - /** Every result in this debate, the open Ask chat included; pins on anything else no longer count. */ - resultKeys: ReadonlySet; - request: DebateContextRequest; -} - -export interface DebateContextInputs { - sourceLibrary: Source[]; - results: Partial, SavedItem[]>>; - sparringSessions: SparringSession[]; - pins: DebateOutputRef[]; - override?: DebateContextOverride; - /** The chat currently open in Ask is already the conversation itself. */ - excludeChatId?: string | null; -} - -function sideOf(value: unknown): DebateContextSide | null { - return value === 'pro' || value === 'con' || value === 'both' || value === 'neutral' ? value : null; -} - -interface OutputCandidate { - ref: DebateOutputRef; - title: string; - side: DebateContextSide | null; - createdAt: number; - data: unknown; -} - -function outputCandidates(debateId: string, inputs: DebateContextInputs): OutputCandidate[] { - const candidates: OutputCandidate[] = []; - for (const [kind, items] of Object.entries(inputs.results) as Array<[DebateContextOutputKind, SavedItem[] | undefined]>) { - for (const item of items ?? []) { - if (item.debateId !== debateId || !UUID.test(item.id)) continue; - if (kind === 'chat' && item.id === inputs.excludeChatId) continue; - candidates.push({ ref: { kind, id: item.id }, title: item.title, side: sideOf(item.side), createdAt: item.createdAt, data: item.data }); - } - } - for (const session of inputs.sparringSessions) { - if (session.debateId !== debateId || !UUID.test(session.id)) continue; - candidates.push({ - ref: { kind: 'sparring', id: session.id }, - title: session.motion, - side: sideOf(session.userSide), - createdAt: session.updatedAt, - data: { userSide: session.userSide, scorecard: session.scorecard }, - }); - } - return candidates.sort((a, b) => b.createdAt - a.createdAt); -} - -/** - * The Current Debate material the next request in `feature` will send, with - * the student's Review choices applied. The server re-checks every ID and - * applies the same budgets, and its response reports what actually reached - * the model. - */ -export function planDebateContext( - feature: DebateContextFeature, - side: DebateContextSide, - debateId: string, - inputs: DebateContextInputs, - options: { includeSources?: boolean } = {}, -): DebateContextPlan { - const policy = debateContextPolicy(feature, options); - const override = inputs.override?.debateId === debateId ? inputs.override : undefined; - - const debateSources = policy.sources - ? inputs.sourceLibrary.filter((source) => source.debateId === debateId && UUID.test(source.id)) - : []; - const includedSources = debateSources.filter((source) => override?.sources[source.id] ?? source.isActive); - const sentSources = includedSources.slice(0, MAX_CONTEXT_SOURCES); - const boundedSources = boundPromptSources(sentSources); - const sourceState = new Map(sentSources.map((source, index) => { - const kept = boundedSources[index]; - return [source.id, { omittedForSize: !kept, shortened: Boolean(kept && kept.content.length < source.content.length) }]; - })); - const sources: ContextSourceItem[] = debateSources.map((source) => { - const included = includedSources.includes(source); - const state = sourceState.get(source.id); - return { - id: source.id, - title: source.title, - active: source.isActive, - included, - overLimit: included && !state, - omittedForSize: state?.omittedForSize ?? false, - shortened: state?.shortened ?? false, - }; - }); - - const pinnedKeys = new Set(inputs.pins.map(outputKey)); - const candidates = policy.outputs ? outputCandidates(debateId, inputs) : []; - // A pin on a result that isn't loaded here, because it was deleted or is - // older than local history keeps, is never shown or sent. It must not use up - // the limit, or the student could never free that slot. - const resultKeys = new Set(outputCandidates(debateId, { ...inputs, excludeChatId: null }).map((item) => outputKey(item.ref))); - const defaults = selectDefaultOutputs( - candidates.map((item) => ({ ...item.ref, side: item.side, createdAt: item.createdAt, pinned: pinnedKeys.has(outputKey(item.ref)) })), - feature, - side, - ); - const defaultKeys = new Set(defaults.map(outputKey)); - const isIncluded = (item: OutputCandidate) => override?.outputs[outputKey(item.ref)] ?? defaultKeys.has(outputKey(item.ref)); - // Defaults keep their order (pins first); extra choices follow, newest first. - const byKey = new Map(candidates.map((item) => [outputKey(item.ref), item])); - const ordered = [ - ...defaults.map((ref) => byKey.get(outputKey(ref))!).filter(isIncluded), - ...candidates.filter((item) => !defaultKeys.has(outputKey(item.ref)) && isIncluded(item)), - ]; - const sentOutputs = ordered.slice(0, MAX_CONTEXT_OUTPUTS); - const bounded = boundOutputDigests(sentOutputs.map((item) => ({ key: outputKey(item.ref), digest: digestOutput(item.ref.kind, item.data) }))); - const tooLarge = new Set(bounded.omittedForSize.map((item) => item.key)); - const empty = new Set(bounded.empty.map((item) => item.key)); - const sentKeys = new Set(sentOutputs.map((item) => outputKey(item.ref))); - const outputs: ContextOutputItem[] = candidates.map((item) => { - const key = outputKey(item.ref); - const included = isIncluded(item); - return { - ref: item.ref, - title: item.title, - side: item.side, - createdAt: item.createdAt, - pinned: pinnedKeys.has(key), - included, - overLimit: included && !sentKeys.has(key), - omittedForSize: tooLarge.has(key), - empty: empty.has(key), - }; - }); - - return { - debateId, - feature, - side, - policy, - sources, - outputs, - resultKeys, - request: { - debateId, - side, - sourceIds: sentSources.map((source) => source.id), - outputs: sentOutputs.filter((item) => !empty.has(outputKey(item.ref))).map((item) => item.ref), - }, - }; -} +// The planner is shared with the iOS app (packages/core/src/debate/context-plan.ts). +export { + debateContextPolicy, + planDebateContext, + type ContextOutputItem, + type ContextSourceItem, + type DebateContextInputs, + type DebateContextPlan, + type DebateContextPolicy, +} from '@debatelab/core/debate/context-plan'; From 4ed61ac237f1c071edfea77d0925d2b9a00c3421 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 28 Oct 2026 12:38:37 +0000 Subject: [PATCH 08/14] Join the context line with "and" on the phone, and read the trial length from the site config Hermes has no Intl.ListFormat, so the debate context line fell back to a comma; the formatjs polyfill joins it as the website does in all three languages. The limit sheet's trial length now comes from the website's site config instead of a copy. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/package.json | 1 + apps/mobile/src/lib/i18n.ts | 5 +++++ apps/mobile/src/lib/refusal.ts | 6 ++++-- package-lock.json | 10 ++++++++++ 4 files changed, 20 insertions(+), 2 deletions(-) diff --git a/apps/mobile/package.json b/apps/mobile/package.json index ecec559d3..370b293eb 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -12,6 +12,7 @@ "@debatelab/core": "*", "@expo-google-fonts/fredoka": "^0.4.1", "@expo-google-fonts/nunito": "^0.4.2", + "@formatjs/intl-listformat": "^8.3.15", "@formatjs/intl-pluralrules": "^6.3.15", "@react-native-async-storage/async-storage": "2.2.0", "@supabase/supabase-js": "^2.49.1", diff --git a/apps/mobile/src/lib/i18n.ts b/apps/mobile/src/lib/i18n.ts index d8de4c7d3..b2977448b 100644 --- a/apps/mobile/src/lib/i18n.ts +++ b/apps/mobile/src/lib/i18n.ts @@ -5,6 +5,11 @@ import '@formatjs/intl-pluralrules/polyfill.js'; import '@formatjs/intl-pluralrules/locale-data/en.js'; import '@formatjs/intl-pluralrules/locale-data/es.js'; import '@formatjs/intl-pluralrules/locale-data/hu.js'; +// Nor Intl.ListFormat, which the debate context line joins its parts with ("3 sources and 2 results"). +import '@formatjs/intl-listformat/polyfill.js'; +import '@formatjs/intl-listformat/locale-data/en.js'; +import '@formatjs/intl-listformat/locale-data/es.js'; +import '@formatjs/intl-listformat/locale-data/hu.js'; import { isAppLanguage, type AppLanguage } from '@debatelab/core/languages'; import { getLocales } from 'expo-localization'; diff --git a/apps/mobile/src/lib/refusal.ts b/apps/mobile/src/lib/refusal.ts index f61ccc824..95b6da8b1 100644 --- a/apps/mobile/src/lib/refusal.ts +++ b/apps/mobile/src/lib/refusal.ts @@ -1,6 +1,8 @@ import type { FeatureTab } from '@debatelab/core/ai/feature-configs'; import type { TFunction } from 'i18next'; +import site from '../../../../src/config/site.json'; + import { AIError, type AIErrorDetails } from './ai-request'; /** @@ -9,8 +11,8 @@ import { AIError, type AIErrorDetails } from './ai-request'; * refusal opens, and its words. The app shows them as a sheet (app/limit.tsx). */ -/** The website's TRIAL_DAYS (src/config/site.json pricing.trialDays). */ -const TRIAL_DAYS = 3; +/** The website's TRIAL_DAYS, from the same site config. */ +const TRIAL_DAYS = site.pricing.trialDays; /** trial_expired is what functions older than the other access codes sent; it reads as trial_ended. */ export type LimitVariant = 'limit_reached' | 'trial_ended' | 'payment_failed' | 'subscription_ended' | 'weekly_budget_reached'; diff --git a/package-lock.json b/package-lock.json index 4e33be797..b804a4084 100644 --- a/package-lock.json +++ b/package-lock.json @@ -124,6 +124,7 @@ "@debatelab/core": "*", "@expo-google-fonts/fredoka": "^0.4.1", "@expo-google-fonts/nunito": "^0.4.2", + "@formatjs/intl-listformat": "^8.3.15", "@formatjs/intl-pluralrules": "^6.3.15", "@react-native-async-storage/async-storage": "2.2.0", "@supabase/supabase-js": "^2.49.1", @@ -4007,6 +4008,15 @@ "integrity": "sha512-zXfhLpvA6T7+efdt9JLbBwZ00tT7NsBMDVnDu8rpHeNNv8KfRZAMo2gkG0k9lK/Nzc//3kJ9pImsfuJxk3KhUA==", "license": "MIT" }, + "node_modules/@formatjs/intl-listformat": { + "version": "8.3.15", + "resolved": "https://registry.npmjs.org/@formatjs/intl-listformat/-/intl-listformat-8.3.15.tgz", + "integrity": "sha512-dZEUYngBoAWLgYXvoBX3P6jIk5cg2rva7p5ZJTTn4lsCx/9abUKoPI2Wf59twxl5jgw1SDQMR1MBzJ7oiHwffw==", + "license": "MIT", + "dependencies": { + "@formatjs/intl-localematcher": "0.9.0" + } + }, "node_modules/@formatjs/intl-localematcher": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.9.0.tgz", From d80d490f05a03c7ab4fa9c465449f9e536358a0e Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 28 Oct 2026 13:07:32 +0000 Subject: [PATCH 09/14] Pick the case's menu actions by name in the screenshot script The case menu now starts with Regenerate, and a wide iPad's outline rail is a menu too, so the script finds the action menu and its items by their labels. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/scripts/screenshots.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/mobile/scripts/screenshots.ts b/apps/mobile/scripts/screenshots.ts index d164b66e7..bee88506b 100644 --- a/apps/mobile/scripts/screenshots.ts +++ b/apps/mobile/scripts/screenshots.ts @@ -589,17 +589,19 @@ async function shootResultScreens(context: BrowserContext, prefix: string, langu // The case's menu leads on: Open in Crossfire with the case filled in, and the case built into a map. // Nothing is saved: the Crossfire form is not started and the map is not saved. + // A wide iPad's outline rail is a menu too; the action menu is the one with the actions. + const caseMenu = page.getByRole('menu').filter({ has: page.getByRole('menuitem', { name: MENU.crossfire }) }); await page.getByTestId('document-more').click(); - await page.getByRole('menu').waitFor(); + await caseMenu.waitFor(); await shoot(page, `${prefix}-case-menu`); - await page.getByRole('menu').getByRole('menuitem').first().click(); + await caseMenu.getByRole('menuitem', { name: MENU.crossfire }).click(); await page.getByTestId('crossfire-imported').waitFor({ timeout: 15_000 }); await settle(page); await shoot(page, `${prefix}-crossfire-from-case`); await page.goBack(); await page.getByTestId('case-document').waitFor({ timeout: 15_000 }); await page.getByTestId('document-more').click(); - await page.getByRole('menu').getByRole('menuitem').nth(1).click(); + await caseMenu.getByRole('menuitem', { name: MENU.flowchart }).click(); await page.getByTestId('flowchart-screen').waitFor({ timeout: 15_000 }); await page.waitForTimeout(500); await shoot(page, `${prefix}-flowchart-from-case`); @@ -616,7 +618,7 @@ async function shootResultScreens(context: BrowserContext, prefix: string, langu await shoot(page, `${prefix}-attack-result`); // The case stays mounted under the attack, so its menu is looked up inside the attack. await page.getByTestId('attack-document').getByTestId('document-more').click(); - await page.getByRole('menu').last().getByRole('menuitem').first().click(); + await page.getByRole('menuitem', { name: MENU.crossfire }).last().click(); await page.getByTestId('crossfire-imported').waitFor({ timeout: 15_000 }); // Not settle(): the attack's score meter (a progressbar) stays mounted under Crossfire. await page.waitForLoadState('networkidle'); @@ -694,6 +696,8 @@ async function shootTool(context: BrowserContext, prefix: string, language: stri } const MENU = { + crossfire: /^(Open in Crossfire|Megnyitás a Keresztkérdezésben|Abrir en Interrogatorio)$/, + flowchart: /^(Build flowchart from case|Folyamatábra az esetből|Crear diagrama desde el caso)$/, regenerate: /^(Regenerate a section|Szakasz újragenerálása|Regenerar una sección)$/, arguments: /^(Regenerate Arguments|Érvek újragenerálása|Regenerar Argumentos)$/, save: /^(Save|Mentés|Guardar)$/, From 94dfea2a13f5a6032aaf7dcb857d4f82fc002772 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 28 Oct 2026 13:33:53 +0000 Subject: [PATCH 10/14] Answer Crossfire's round-cost check in the screenshot script's results fake Crossfire, opened from a case or an attack, asks what a round costs; the results fake aborted that call, which the browser logged as an error. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/scripts/screenshots.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/mobile/scripts/screenshots.ts b/apps/mobile/scripts/screenshots.ts index bee88506b..874cb7821 100644 --- a/apps/mobile/scripts/screenshots.ts +++ b/apps/mobile/scripts/screenshots.ts @@ -494,7 +494,10 @@ async function fakeResults(page: Page, hold: FakeFeature | null, live = false) { const outputs = FAKE_OUTPUTS; if (hold && live) await fakeLiveStream(page, hold); await page.route(`https://${STAGING_HOST}/functions/v1/ai`, async (route) => { - const feature = (route.request().postDataJSON() as { feature?: string } | null)?.feature; + const body = route.request().postDataJSON() as { feature?: string; quotaCheck?: boolean } | null; + const feature = body?.feature; + // Crossfire, opened from a case or an attack, asks what a round costs. + if (body?.quotaCheck) return route.fulfill({ status: 200, headers: { 'access-control-allow-origin': '*' }, json: FAKE_QUOTA }); if (feature && feature === hold) return; // Never answered: the page shows its progress state. const json = feature ? outputs[feature] : undefined; if (json) return route.fulfill({ status: 200, headers: { 'access-control-allow-origin': '*' }, json }); From 46add4101ad05c9d3c643eea2d0af52f40d952cd Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 28 Oct 2026 14:07:32 +0000 Subject: [PATCH 11/14] Keep a live request's deadline a timeout on the website The shared live reader rejected with a plain Error named AbortError, which callSupabaseAI did not recognise as its own deadline, so a stalled stream on the website became an unexpected error reported to Sentry. The reader now throws a DOMException where one exists, and both clients tell an abort by its name, since a DOMException is not always an Error. A test covers the website's timeout on a live stream that goes quiet. The app's usage line no longer reloads after a round's cost check, which spends nothing. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/lib/ai-request.ts | 6 ++++-- packages/core/src/ai/abort.ts | 14 ++++++++++++++ packages/core/src/ai/live.ts | 6 +----- packages/core/src/ai/run-recovery.ts | 8 ++------ src/lib/ai/request.ts | 5 ++++- src/lib/ai/run-recovery.test.ts | 20 +++++++++++++++++++- 6 files changed, 44 insertions(+), 15 deletions(-) create mode 100644 packages/core/src/ai/abort.ts diff --git a/apps/mobile/src/lib/ai-request.ts b/apps/mobile/src/lib/ai-request.ts index 4edb0b49e..3debe3c66 100644 --- a/apps/mobile/src/lib/ai-request.ts +++ b/apps/mobile/src/lib/ai-request.ts @@ -44,8 +44,9 @@ export class AIError extends Error { } } +/** A cancellation, whether it is a DOMException (not always an Error) or a plain Error named AbortError. */ export function isAbortError(error: unknown): boolean { - return error instanceof Error && error.name === 'AbortError'; + return typeof error === 'object' && error !== null && (error as { name?: unknown }).name === 'AbortError'; } export function abortError(): Error { @@ -157,7 +158,8 @@ export async function postFunction( } finally { clearTimeout(timeout); signal?.removeEventListener('abort', abortForCaller); - settledListeners.forEach((listener) => listener()); + // A quota check spends nothing, so the usage line has nothing new to read. + if (body.quotaCheck !== true) settledListeners.forEach((listener) => listener()); } } diff --git a/packages/core/src/ai/abort.ts b/packages/core/src/ai/abort.ts new file mode 100644 index 000000000..6ebd8e2ac --- /dev/null +++ b/packages/core/src/ai/abort.ts @@ -0,0 +1,14 @@ +// The error a cancelled request rejects with. Browsers expect a DOMException +// named AbortError, as fetch throws; Hermes may lack DOMException, so a plain +// Error with the same name stands in. + +type DOMExceptionConstructor = new (message: string, name: string) => Error; + +export function abortError(): Error { + const DOMExceptionCtor = (globalThis as { DOMException?: DOMExceptionConstructor }).DOMException; + if (typeof DOMExceptionCtor === 'function') return new DOMExceptionCtor('The request was canceled.', 'AbortError'); + const error = new Error('The request was canceled.'); + error.name = 'AbortError'; + return error; +} + diff --git a/packages/core/src/ai/live.ts b/packages/core/src/ai/live.ts index 26dec2980..16e9008ec 100644 --- a/packages/core/src/ai/live.ts +++ b/packages/core/src/ai/live.ts @@ -3,6 +3,7 @@ // written. Shared by the website (src/lib/ai/live.ts) and the iOS app, which // map the outcome to their own errors. +import { abortError } from './abort'; import { parsePartialJson } from './partial-json'; import { consumeSSEChunk, type ParsedSSEEvent } from './sse'; @@ -83,11 +84,6 @@ export type LiveOutcome = | { kind: 'result'; status: number; body: unknown } | { kind: 'failed'; error: LiveStreamError }; -function abortError(): Error { - const error = new Error('The request was canceled.'); - error.name = 'AbortError'; - return error; -} /** Reads one chunk, or 'stalled' when nothing arrives in time. */ async function readWithin(reader: LiveStreamReader, ms: number): Promise<{ done: boolean; value?: Uint8Array } | 'stalled'> { diff --git a/packages/core/src/ai/run-recovery.ts b/packages/core/src/ai/run-recovery.ts index 991920c45..1e5bea872 100644 --- a/packages/core/src/ai/run-recovery.ts +++ b/packages/core/src/ai/run-recovery.ts @@ -5,6 +5,8 @@ // still be read. Shared by the website and the iOS app; each passes its own // read of the table. +import { abortError } from './abort'; + export interface RecoveredRunResult { status: number; body: unknown; @@ -39,12 +41,6 @@ interface WaitSignal { removeEventListener(type: 'abort', listener: () => void): void; } -function abortError(): Error { - const error = new Error('The request was canceled.'); - error.name = 'AbortError'; - return error; -} - function wait(ms: number, signal?: WaitSignal): Promise { return new Promise((resolve, reject) => { if (signal?.aborted) { diff --git a/src/lib/ai/request.ts b/src/lib/ai/request.ts index e793949ff..3f780d81f 100644 --- a/src/lib/ai/request.ts +++ b/src/lib/ai/request.ts @@ -417,7 +417,10 @@ export async function callSupabaseAI( throw abortError; } - if (err instanceof DOMException && err.name === 'AbortError') { + // Our own deadline aborted the request. A DOMException is not always an Error (jsdom), + // and the shared live reader throws a plain Error where DOMException is missing, so the + // name decides, not the class. + if (err && typeof err === 'object' && (err as { name?: unknown }).name === 'AbortError') { const timeoutError = new TimeoutError(); reportFailure(timeoutError); throw timeoutError; diff --git a/src/lib/ai/run-recovery.test.ts b/src/lib/ai/run-recovery.test.ts index d13a4782b..37254df9c 100644 --- a/src/lib/ai/run-recovery.test.ts +++ b/src/lib/ai/run-recovery.test.ts @@ -40,7 +40,7 @@ vi.mock('@/lib/supabase/client', () => ({ })); const { callSupabaseAI } = await import('@/lib/ai/request'); -const { AINetworkError, AIProviderError, AIResponseError } = await import('@/lib/ai/errors'); +const { AINetworkError, AIProviderError, AIResponseError, TimeoutError } = await import('@/lib/ai/errors'); const config = { provider: 'openrouter' as const, model: 'auto-fast' }; const live = { onDraft: () => {} }; @@ -132,3 +132,21 @@ describe('a live request whose stream dropped', () => { expect(error).toBeInstanceOf(AINetworkError); }); }); + +describe('a live request that outlives its deadline', () => { + it('is a timeout, not an unexpected error', async () => { + vi.useFakeTimers(); + // The stream starts and then goes quiet, short of the 45 s stall check. + invokeWith.mockImplementation(async (_name: string, _options: unknown, handle: (response: Response) => Promise) => { + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('event: phase\ndata: {"phase":"writing"}\n\n')); + }, + }); + return { data: await handle(new Response(body, { headers: { 'Content-Type': 'text/event-stream' } })), error: null }; + }); + const pending = callSupabaseAI('generate', { motion: 'Homework' }, config, { live, debateId: null, timeoutMs: 1_000 }).catch((caught: unknown) => caught); + await vi.advanceTimersByTimeAsync(1_000); + expect(await pending).toBeInstanceOf(TimeoutError); + }); +}); From 14269c4de02cc613a48b10830dacae8c93c12369 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 28 Oct 2026 14:07:32 +0000 Subject: [PATCH 12/14] Apply review fixes to the iOS prepare tools - "Use low reasoning instead" also moves the tool off a Pro-only model, so the retry is not refused again. - The debate context reads a list of titles and dates on each form, and the text only of what a request sends, kept for the session. A list read that ends after a newer one is dropped, and one that started before a pin change keeps the newer pins. - Crossfire and Sparring show Start as busy while the round's cost is checked, and ignore a second tap. - The case document confirms a save outside its state update. - A regenerated case keeps its new sources even if the student leaves, and a failure there no longer reads as a failed regeneration. - Opening a case or an attack in Crossfire while a round runs says why it was not brought in. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/app/crossfire.tsx | 10 +- apps/mobile/src/app/limit.tsx | 12 +- apps/mobile/src/app/sparring/index.tsx | 7 +- apps/mobile/src/lib/case-edits.ts | 16 +- apps/mobile/src/lib/context-plan.ts | 193 +++++++++++++++------ apps/mobile/src/lib/use-result-document.ts | 14 +- src/locales/en.json | 3 +- src/locales/es.json | 3 +- src/locales/hu.json | 3 +- 9 files changed, 192 insertions(+), 69 deletions(-) diff --git a/apps/mobile/src/app/crossfire.tsx b/apps/mobile/src/app/crossfire.tsx index 2d24849d7..2e86118aa 100644 --- a/apps/mobile/src/app/crossfire.tsx +++ b/apps/mobile/src/app/crossfire.tsx @@ -120,12 +120,15 @@ export default function CrossfireScreen() { }); const [state, dispatch] = useReducer(crossfireReducer, imported, restoredState); const roundQuota = useSessionQuota('crossfire'); + // Start waits for the round's cost; a second tap meanwhile does nothing. + const [checkingQuota, setCheckingQuota] = useState(false); // The debate chosen when the round started; null for one-off rounds. const debateTarget = useRef(crossfireDraft.get()?.debateId ?? null); const [draftTurn, setDraftTurn] = useState(''); const [now, setNow] = useState(() => Date.now()); const [formError, setFormError] = useState(''); - const [notice, setNotice] = useState(''); + // A case or an attack opened in Crossfire while a round runs is not brought in, as on the website; say so. + const [notice, setNotice] = useState(() => (imported && state.phase !== 'setup' ? t('mobile.crossfire.prefillHeld') : '')); const [activeSources, setActiveSources] = useState(null); const requestRef = useRef(null); // The finished round still to be recorded in its debate. @@ -343,8 +346,11 @@ export default function CrossfireScreen() { setFormError(''); const forUser = userId; // A round the day's uses cannot cover opens the limit sheet before its clock starts, as on the website. + if (checkingQuota) return; + setCheckingQuota(true); const refusal = sessionQuotaRefusal(await roundQuota.check()); if (!live.current) return; + setCheckingQuota(false); if (refusal) { openRefusal(router, refusal, 'ask'); return; @@ -426,7 +432,7 @@ export default function CrossfireScreen() { } - actions={ void startSession()} />}> + actions={ void startSession()} />}> {imported?.source ? ( diff --git a/apps/mobile/src/app/limit.tsx b/apps/mobile/src/app/limit.tsx index da3678816..23dc35557 100644 --- a/apps/mobile/src/app/limit.tsx +++ b/apps/mobile/src/app/limit.tsx @@ -1,4 +1,5 @@ import type { FeatureTab } from '@debatelab/core/ai/feature-configs'; +import { DEFAULT_MODEL } from '@debatelab/core/contract/model-catalog'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -10,6 +11,7 @@ import { SheetScrollView } from '@/components/sheet'; import { Text } from '@/components/text'; import { useAuth } from '@/lib/auth'; import { formatDate } from '@/lib/dates'; +import { isProOnlyModel } from '@/lib/feature-config'; import { LIMIT_TITLES, limitMessage, limitVariant } from '@/lib/refusal'; import { useSettings } from '@/lib/settings'; import { fetchSubscription, type Subscription } from '@/lib/subscription'; @@ -29,7 +31,7 @@ export default function LimitSheet() { const params = useLocalSearchParams<{ kind?: string; feature?: string; code?: string; limit?: string; current?: string; units?: string }>(); const { session } = useAuth(); const userId = session?.user.id ?? null; - const { setFeatureConfig } = useSettings(); + const { readFeatureConfig, setFeatureConfig } = useSettings(); const [plan, setPlan] = useState(null); const [switching, setSwitching] = useState(false); @@ -55,12 +57,14 @@ export default function LimitSheet() { if (params.kind === 'model') { const feature = FEATURES.has(params.feature as FeatureTab) ? (params.feature as FeatureTab) : null; - // The website only closes its dialog; here the tool's reasoning is set to low, so the retry works. + // The website only closes its dialog. Here the tool moves to what the plan allows, so the + // retry is not refused again: low reasoning, and the free default when its model is Pro-only. const switchToLow = async () => { - if (!feature) return close(); + if (!feature || !userId) return close(); setSwitching(true); try { - await setFeatureConfig(feature, { reasoningEffort: 'low' }); + const current = await readFeatureConfig(userId, feature); + await setFeatureConfig(feature, isProOnlyModel(current.model) ? { model: DEFAULT_MODEL, reasoningEffort: 'low' } : { reasoningEffort: 'low' }); } catch (cause) { console.warn('Could not switch to low reasoning:', cause instanceof Error ? cause.message : cause); } diff --git a/apps/mobile/src/app/sparring/index.tsx b/apps/mobile/src/app/sparring/index.tsx index 0b5f82840..3f938b745 100644 --- a/apps/mobile/src/app/sparring/index.tsx +++ b/apps/mobile/src/app/sparring/index.tsx @@ -115,6 +115,8 @@ export default function SparringScreen() { // Each side choice gets a number; a slower earlier one never starts a round over a newer one. const startAttempt = useRef(0); const roundQuota = useSessionQuota('sparring'); + // Start waits for the round's cost; a second tap meanwhile does nothing. + const [checkingQuota, setCheckingQuota] = useState(false); // Another account starts with no round: the session slot was already cleared, and this screen's copy goes too. useEffect(() => { @@ -263,8 +265,11 @@ export default function SparringScreen() { } setFormError(''); // A round the day's uses cannot cover opens the limit sheet before its clock starts, as on the website. + if (checkingQuota) return; + setCheckingQuota(true); const refusal = sessionQuotaRefusal(await roundQuota.check()); if (!live.current) return; + setCheckingQuota(false); if (refusal) { openRefusal(router, refusal, 'sparring'); return; @@ -352,7 +357,7 @@ export default function SparringScreen() { } - actions={ void start()} />}> + actions={ void start()} />}> ): GenerateInput | null { }; } +async function keepResearch(userId: string, debateId: string | null, assessments: SourceAssessmentUpdate[], sources: DiscoveredSource[]) { + try { + await keepSourceAssessments(userId, debateId, assessments); + await autosaveSources(userId, sources, 'generate', debateId); + } catch (cause) { + console.warn('Could not keep the regenerated case\'s sources:', cause instanceof Error ? cause.message : cause); + } +} + export function useCaseEdits(key: string | null, setNotice: (text: string) => void) { const { t } = useTranslation(); const router = useRouter(); @@ -110,8 +121,9 @@ export function useCaseEdits(key: string | null, setNotice: (text: string) => vo changed(); setNotice(`${t('generate.regenerated')}. ${t('generate.regeneratedDesc')}`); await saveOutput(forUser, base.savedId, merged); - await keepSourceAssessments(forUser, base.debateId, assessments, controller.signal); - await autosaveSources(forUser, discoveredSources, 'generate', base.debateId, controller.signal); + // The rankings and researched sources are the new generation's, kept even if the student + // leaves now; their failure is not the regeneration's, which already landed. + void keepResearch(forUser, base.debateId, assessments, discoveredSources); } catch (cause) { if (controller.signal.aborted || isAbortError(cause)) return; if (openRefusal(router, cause, 'generate')) return; diff --git a/apps/mobile/src/lib/context-plan.ts b/apps/mobile/src/lib/context-plan.ts index 56a444970..1759aca8d 100644 --- a/apps/mobile/src/lib/context-plan.ts +++ b/apps/mobile/src/lib/context-plan.ts @@ -9,7 +9,7 @@ import { type PlanSparringSession, } from '@debatelab/core/debate/context-plan'; import { useFocusEffect } from 'expo-router'; -import { useCallback, useMemo, useSyncExternalStore } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react'; import { useAuth } from './auth'; import { sessionSlot } from './session-memory'; @@ -18,10 +18,15 @@ import { supabase } from './supabase'; /** * The Current Debate material a request sends, as the website plans it * (packages/core/src/debate/context-plan.ts) with its Review choices and pins - * (src/lib/stores/debate-context-store.ts): the material read once per form - * opening, the choices for one feature's next request, and the debate's pins - * in debate_context_pins (three at most). The line on each tool form, its - * Review sheet and the request itself read the same plan. + * (src/lib/stores/debate-context-store.ts): the debate's list of material read + * when a form opens, the choices for one feature's next request, and the + * debate's pins in debate_context_pins (three at most). The line on each tool + * form, its Review sheet and the request itself read the same plan. + * + * The list holds titles, sides and dates only. Which items a request includes + * does not depend on their text, so the text (a source's content, a result's + * output, a chat's messages) is read only for the items the plan will send, + * and kept for the session, so the size limits are checked on what is sent. */ type ResultKind = Exclude; @@ -35,7 +40,7 @@ const RESULT_TABLES = { } as const satisfies Partial>; const RESULT_KINDS = Object.keys(RESULT_TABLES) as (keyof typeof RESULT_TABLES)[]; -export interface ContextMaterial { +interface ContextMaterial { debateId: string; sources: PlanSource[]; results: Partial>; @@ -44,7 +49,13 @@ export interface ContextMaterial { } const material = sessionSlot(() => new Map()); +/** Text read for items a plan sends, by debate and `source:` or `:`. */ +const texts = sessionSlot(() => new Map()); const overrides = sessionSlot((): Partial> => ({})); +/** The latest list read per debate; an older read that ends later is dropped. */ +const loads = new Map(); +/** Pin changes per debate, so a list read that started before one keeps the newer pins. */ +const pinEdits = new Map(); const listeners = new Set<() => void>(); let version = 0; @@ -64,69 +75,116 @@ function rowsOf(result: { data: unknown; error: { message: string } | null }) } const time = (value: string) => new Date(value).getTime(); +const textKey = (debateId: string, key: string) => `${debateId}|${key}`; /** - * Reads a debate's material: its sources, saved results with their outputs - * (chats with their messages), judged Sparring rounds and pins. Every read - * filters on the user; row level security is the real boundary. Pins are - * optional: if they cannot be read, the automatic selection is used. + * Reads a debate's list of material: its sources, saved results and judged + * Sparring rounds, without their text, and its pins. Every read filters on + * the user; row level security is the real boundary. Pins are optional: if + * they cannot be read, the automatic selection is used. */ export async function loadContextMaterial(userId: string, debateId: string): Promise { + const load = (loads.get(debateId) ?? 0) + 1; + loads.set(debateId, load); + const pinsAtStart = pinEdits.get(debateId) ?? 0; const [sources, pins, sparring, chats, ...results] = await Promise.all([ - supabase.from('sources').select('id,title,content,type,is_active').eq('user_id', userId).eq('debate_id', debateId).order('created_at', { ascending: false }), + supabase.from('sources').select('id,title,type,is_active').eq('user_id', userId).eq('debate_id', debateId).order('created_at', { ascending: false }), supabase.from('debate_context_pins').select('item_kind,item_id').eq('user_id', userId).eq('debate_id', debateId), - supabase.from('sparring_sessions').select('id,motion,user_side,updated_at,final_evaluation').eq('user_id', userId).eq('debate_id', debateId), - supabase.from('chats').select('id,title,side,created_at,messages(role,content,created_at)').eq('user_id', userId).eq('debate_id', debateId), - ...RESULT_KINDS.map((kind) => supabase.from(RESULT_TABLES[kind]).select('id,title,side,created_at,output').eq('user_id', userId).eq('debate_id', debateId)), + supabase.from('sparring_sessions').select('id,motion,user_side,updated_at').eq('user_id', userId).eq('debate_id', debateId), + supabase.from('chats').select('id,title,side,created_at').eq('user_id', userId).eq('debate_id', debateId), + ...RESULT_KINDS.map((kind) => supabase.from(RESULT_TABLES[kind]).select('id,title,side,created_at').eq('user_id', userId).eq('debate_id', debateId)), ]); + // A newer read started meanwhile: it decides. + const superseded = material.get().get(debateId); + if (loads.get(debateId) !== load && superseded) return superseded; + const resultRow = (row: { id: string; title: string; side: string | null; created_at: string }): PlanResult => ({ + id: row.id, + title: row.title, + side: row.side, + debateId, + createdAt: time(row.created_at), + data: undefined, + }); + const loadedPins = pins.error + ? [] + : ((pins.data ?? []) as { item_kind: unknown; item_id: unknown }[]).flatMap((row) => + isDebateContextOutputKind(row.item_kind) && typeof row.item_id === 'string' ? [{ kind: row.item_kind, id: row.item_id }] : []); + const held = material.get().get(debateId); const next: ContextMaterial = { debateId, - sources: rowsOf<{ id: string; title: string; content: string | null; type: string | null; is_active: boolean }>(sources).map((row) => ({ + sources: rowsOf<{ id: string; title: string; type: string | null; is_active: boolean }>(sources).map((row) => ({ id: row.id, title: row.title, - content: row.content ?? '', + content: '', type: row.type ?? 'text', debateId, isActive: row.is_active, })), - results: { - chat: rowsOf<{ id: string; title: string; side: string | null; created_at: string; messages: { role: string; content: string; created_at: string }[] | null }>(chats).map((row) => ({ - id: row.id, - title: row.title, - side: row.side, - debateId, - createdAt: time(row.created_at), - data: [...(row.messages ?? [])].sort((a, b) => time(a.created_at) - time(b.created_at)).map(({ role, content }) => ({ role, content })), - })), - }, - sparring: rowsOf<{ id: string; motion: string; user_side: string | null; updated_at: string; final_evaluation: unknown }>(sparring).map((row) => ({ + results: { chat: rowsOf<{ id: string; title: string; side: string | null; created_at: string }>(chats).map(resultRow) }, + sparring: rowsOf<{ id: string; motion: string; user_side: string | null; updated_at: string }>(sparring).map((row) => ({ id: row.id, motion: row.motion, debateId, userSide: row.user_side, updatedAt: time(row.updated_at), - scorecard: row.final_evaluation, })), - pins: pins.error - ? [] - : ((pins.data ?? []) as { item_kind: unknown; item_id: unknown }[]).flatMap((row) => - isDebateContextOutputKind(row.item_kind) && typeof row.item_id === 'string' ? [{ kind: row.item_kind, id: row.item_id }] : []), + // A pin changed while this read ran is newer than what the read saw. + pins: held && (pinEdits.get(debateId) ?? 0) !== pinsAtStart ? held.pins : loadedPins, }; RESULT_KINDS.forEach((kind, index) => { - next.results[kind] = rowsOf<{ id: string; title: string; side: string | null; created_at: string; output: unknown }>(results[index]).map((row) => ({ - id: row.id, - title: row.title, - side: row.side, - debateId, - createdAt: time(row.created_at), - data: row.output, - })); + next.results[kind] = rowsOf<{ id: string; title: string; side: string | null; created_at: string }>(results[index]).map(resultRow); }); material.get().set(debateId, next); + // Text kept as "nothing usable yet" (an unjudged round, a chat with no messages) is read again. + for (const [key, value] of texts.get()) { + if (!key.startsWith(`${debateId}|`)) continue; + const unjudged = key.includes('|sparring:') && !(value as { scorecard?: unknown } | undefined)?.scorecard; + const emptyChat = key.includes('|chat:') && Array.isArray(value) && value.length === 0; + if (unjudged || emptyChat) texts.get().delete(key); + } emit(); return next; } +/** Reads the text of the given items (`source:` or `:`) into the session's store. */ +async function loadTexts(userId: string, debateId: string, keys: string[]): Promise { + const ids = (prefix: string) => keys.filter((key) => key.startsWith(`${prefix}:`)).map((key) => key.slice(prefix.length + 1)); + const reads: PromiseLike[] = []; + const keep = (key: string, value: unknown) => texts.get().set(textKey(debateId, key), value); + const sourceIds = ids('source'); + if (sourceIds.length > 0) { + reads.push(supabase.from('sources').select('id,content').eq('user_id', userId).in('id', sourceIds).then((result) => { + for (const row of rowsOf<{ id: string; content: string | null }>(result)) keep(`source:${row.id}`, row.content ?? ''); + })); + } + for (const kind of RESULT_KINDS) { + const kindIds = ids(kind); + if (kindIds.length === 0) continue; + reads.push(supabase.from(RESULT_TABLES[kind]).select('id,output').eq('user_id', userId).in('id', kindIds).then((result) => { + for (const row of rowsOf<{ id: string; output: unknown }>(result)) keep(`${kind}:${row.id}`, row.output); + })); + } + // Messages have no user column; the chat ids come from the user's own list. + const chatIds = ids('chat'); + if (chatIds.length > 0) { + reads.push(supabase.from('messages').select('chat_id,role,content').in('chat_id', chatIds).order('created_at', { ascending: true }).then((result) => { + const byChat = new Map(chatIds.map((id) => [id, []])); + for (const row of rowsOf<{ chat_id: string; role: string; content: string }>(result)) byChat.get(row.chat_id)?.push({ role: row.role, content: row.content }); + byChat.forEach((messages, id) => keep(`chat:${id}`, messages)); + })); + } + const sparringIds = ids('sparring'); + if (sparringIds.length > 0) { + reads.push(supabase.from('sparring_sessions').select('id,user_side,final_evaluation').eq('user_id', userId).in('id', sparringIds).then((result) => { + for (const row of rowsOf<{ id: string; user_side: unknown; final_evaluation: unknown }>(result)) { + keep(`sparring:${row.id}`, { userSide: row.user_side, scorecard: row.final_evaluation }); + } + })); + } + await Promise.all(reads); + emit(); +} + interface PlanOptions { /** The chat open in Ask is already the conversation. */ excludeChatId?: string | null; @@ -134,20 +192,45 @@ interface PlanOptions { includeSources?: boolean; } -function planFor(held: ContextMaterial, feature: DebateContextFeature, side: DebateContextSide, options: PlanOptions): DebateContextPlan { - return planDebateContext(feature, side, held.debateId, { - sourceLibrary: held.sources, - results: held.results, - sparringSessions: held.sparring, +/** + * The plan, once the text of every item it sends is known; otherwise the + * items whose text is still to be read. + */ +function planFor(held: ContextMaterial, feature: DebateContextFeature, side: DebateContextSide, options: PlanOptions): { plan: DebateContextPlan } | { missing: string[] } { + const known = texts.get(); + const text = (key: string) => known.get(textKey(held.debateId, key)); + const inputs = { + sourceLibrary: held.sources.map((source) => ({ ...source, content: (text(`source:${source.id}`) as string | undefined) ?? '' })), + results: Object.fromEntries( + Object.entries(held.results).map(([kind, rows]) => [kind, rows?.map((row) => ({ ...row, data: text(`${kind}:${row.id}`) }))]), + ) as ContextMaterial['results'], + sparringSessions: held.sparring.map((session) => { + const saved = text(`sparring:${session.id}`) as { userSide?: unknown; scorecard?: unknown } | undefined; + return { ...session, scorecard: saved?.scorecard }; + }), pins: held.pins, override: overrides.get()[feature], excludeChatId: options.excludeChatId ?? null, - }, { includeSources: options.includeSources }); + }; + const plan = planDebateContext(feature, side, held.debateId, inputs, { includeSources: options.includeSources }); + // Which items are sent does not depend on their text; their size and emptiness do. + const sent = [ + ...plan.sources.filter((source) => source.included && !source.overLimit).map((source) => `source:${source.id}`), + ...plan.outputs.filter((output) => output.included && !output.overLimit).map((output) => outputKey(output.ref)), + ]; + const missing = sent.filter((key) => !known.has(textKey(held.debateId, key))); + return missing.length > 0 ? { missing } : { plan }; } -/** The plan for a request about to be sent, from material read now. */ +/** The plan for a request about to be sent, from the list read now. */ export async function planRequest(userId: string, debateId: string, feature: DebateContextFeature, side: DebateContextSide, options: PlanOptions = {}): Promise { - return planFor(await loadContextMaterial(userId, debateId), feature, side, options); + const held = await loadContextMaterial(userId, debateId); + for (let attempt = 0; attempt < 3; attempt += 1) { + const result = planFor(held, feature, side, options); + if ('plan' in result) return result.plan; + await loadTexts(userId, debateId, result.missing); + } + throw new Error('Could not read the debate context'); } /** The plan for a form on screen: read when the form comes into view, kept current as choices and pins change. */ @@ -162,12 +245,21 @@ export function useContextPlan(feature: DebateContextFeature, side: DebateContex }, [userId, debateId]), ); const { excludeChatId, includeSources } = options; - return useMemo(() => { + const result = useMemo(() => { const held = debateId ? material.get().get(debateId) : undefined; return held ? planFor(held, feature, side, { excludeChatId, includeSources }) : null; - // `current` changes whenever the material, a choice or a pin does. + // `current` changes whenever the list, a text, a choice or a pin does. // eslint-disable-next-line react-hooks/exhaustive-deps }, [current, debateId, feature, side, excludeChatId, includeSources]); + const missing = result && 'missing' in result ? result.missing.join(',') : ''; + useEffect(() => { + if (!missing || !userId || !debateId) return; + loadTexts(userId, debateId, missing.split(',')).catch((cause: unknown) => console.warn('Could not read the debate context:', cause instanceof Error ? cause.message : cause)); + }, [missing, userId, debateId]); + // While a newly chosen item's text is read, the last whole plan for this debate stays on screen. + const last = useRef(null); + if (result && 'plan' in result) last.current = result.plan; + return last.current?.debateId === debateId ? last.current : null; } function ensureOverride(feature: DebateContextFeature, debateId: string): DebateContextOverride { @@ -224,6 +316,7 @@ export async function togglePin(userId: string, debateId: string, ref: DebateOut : await supabase.from('debate_context_pins').insert({ user_id: userId, debate_id: debateId, item_kind: ref.kind, item_id: ref.id }); // A pin that already exists is the pin asked for. if (error && error.code !== '23505') throw new Error(error.message); + pinEdits.set(debateId, (pinEdits.get(debateId) ?? 0) + 1); const latest = material.get().get(debateId); if (!latest) return; const without = latest.pins.filter((pin) => outputKey(pin) !== key); diff --git a/apps/mobile/src/lib/use-result-document.ts b/apps/mobile/src/lib/use-result-document.ts index 0a2eb5c9c..3b5c8de41 100644 --- a/apps/mobile/src/lib/use-result-document.ts +++ b/apps/mobile/src/lib/use-result-document.ts @@ -118,17 +118,17 @@ export function useResultDocument(kind: K, id: string) { // A held result changed elsewhere (a regenerated section, an undo, a save from its sheet) shows here. const readyKey = state.status === 'ready' ? state.key : null; + const stateRef = useRef(state); + stateRef.current = state; useEffect(() => { if (!readyKey) return; return subscribeOpenResults(() => { const held = openResult(readyKey, kind); - if (!held || !live.current) return; - setState((previous) => { - if (previous.status !== 'ready' || previous.key !== readyKey || previous.result === held) return previous; - // Saved from the Save case sheet: the website's "Case saved" confirmation. - if (!previous.result.savedId && held.savedId) setNotice(`${t('generate.saved')}. ${t('generate.savedDesc')}`, 'success'); - return { status: 'ready', key: readyKey, result: held }; - }); + const shown = stateRef.current; + if (!held || !live.current || shown.status !== 'ready' || shown.key !== readyKey || shown.result === held) return; + setState({ status: 'ready', key: readyKey, result: held }); + // Saved from the Save case sheet: the website's "Case saved" confirmation. + if (!shown.result.savedId && held.savedId) setNotice(`${t('generate.saved')}. ${t('generate.savedDesc')}`, 'success'); }); }, [kind, readyKey, setNotice, t]); diff --git a/src/locales/en.json b/src/locales/en.json index 464aefa22..48c10145d 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2832,7 +2832,8 @@ "reset": "Reset" }, "crossfire": { - "voiceLater": "Coming with the app build." + "voiceLater": "Coming with the app build.", + "prefillHeld": "A round is still running, so your case was not brought in. End this round, then open Crossfire from your case again." }, "sparring": { "chooseSide": "Which side are you on?", diff --git a/src/locales/es.json b/src/locales/es.json index 6fc311277..7866a90c3 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -2832,7 +2832,8 @@ "reset": "Reiniciar" }, "crossfire": { - "voiceLater": "Llegará con la versión de la app." + "voiceLater": "Llegará con la versión de la app.", + "prefillHeld": "Todavía hay una ronda en curso, así que tu caso no se importó. Termina esta ronda y vuelve a abrir el Interrogatorio desde tu caso." }, "sparring": { "chooseSide": "¿Qué postura defiendes?", diff --git a/src/locales/hu.json b/src/locales/hu.json index b216d267c..ffb822980 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -2832,7 +2832,8 @@ "reset": "Visszaállítás" }, "crossfire": { - "voiceLater": "Az alkalmazás következő verziójával érkezik." + "voiceLater": "Az alkalmazás következő verziójával érkezik.", + "prefillHeld": "Még tart egy kör, ezért az eseted nem került be. Fejezd be ezt a kört, majd nyisd meg újra a Keresztkérdezést az esetedből." }, "sparring": { "chooseSide": "Melyik oldalon állsz?", From 348d29eb3978e67b48d8aa6d7d0a3f12d36743f5 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 28 Oct 2026 14:07:43 +0000 Subject: [PATCH 13/14] Say how the app's debate context reads its material Co-Authored-By: Claude Opus 5.5 --- apps/mobile/AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 319fcbb63..c403b7432 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -22,7 +22,7 @@ The iOS app DebateOwl (`@debatelab/mobile`; the bundle id is still `com.adam.deb - `src/components/`: shared UI. `app-tabs.tsx` uses native tabs; `app-tabs.web.tsx` draws the mock's tab bar for the web preview. - `src/lib/`: auth (`auth.tsx`), `native-capabilities.ts` (what the running binary has beyond Expo Go), the Supabase client, session storage (AES key in SecureStore, ciphertext in AsyncStorage) and i18n. `debates.ts`, `settings.tsx`, `subscription.ts` and `account.ts` read and write the website's tables the way its stores do (`src/lib/stores/debate-store.ts`, `settings-store.ts`, `src/hooks/use-subscription.ts`, `src/lib/account.ts`), so the app and the website share one Current Debate and one set of settings. Change them together with the website code they mirror. - `sources.ts` mirrors the session store's `addSource` and `removeSource` and `src/lib/source-import.ts`. The Library always shows the Current Debate, also in one-off mode, as on the website. Adding a file (the website parses files in the browser) and Scan pages are not in the app yet. -- Ask mirrors `src/lib/ai/streaming.ts` and `askChat` in `src/lib/ai/client.ts`: `ask.ts` sends the requests (plain questions stream from `ai-stream` through `expo/fetch`; with Citations, Web search, Pros/Cons or Argument map on, the whole answer comes from `ai`, because `ai-stream` refuses those options), `ai-request.ts` holds the shared POST with the website's headers, timeout and error kinds, `feature-config.ts` reads each tool's model from `user_settings.feature_configs` (the model picker, `components/model-picker.tsx`, writes it back through `settings.tsx`'s serial queue in the website's canonical shape; tools read it through the same queue, so they run with the model just picked), `context-request.ts` builds the Current Debate context from `context-plan.ts`, which reads a debate's material once and plans it with the website's planner (`packages/core/src/debate/context-plan.ts`), with the Review choices for one request (Crossfire keeps them for its round) and the pins in `debate_context_pins` (three at most); the line on each tool form, Crossfire, Sparring and Ask (`components/context-line.tsx`) and its Review sheet read the same plan, and `chats.ts` saves finished exchanges to `chats` and `messages`. The SSE parser is shared in `packages/core/src/ai/sse.ts`. `markdown.ts` reads answers, because react-markdown needs the DOM. +- Ask mirrors `src/lib/ai/streaming.ts` and `askChat` in `src/lib/ai/client.ts`: `ask.ts` sends the requests (plain questions stream from `ai-stream` through `expo/fetch`; with Citations, Web search, Pros/Cons or Argument map on, the whole answer comes from `ai`, because `ai-stream` refuses those options), `ai-request.ts` holds the shared POST with the website's headers, timeout and error kinds, `feature-config.ts` reads each tool's model from `user_settings.feature_configs` (the model picker, `components/model-picker.tsx`, writes it back through `settings.tsx`'s serial queue in the website's canonical shape; tools read it through the same queue, so they run with the model just picked), `context-request.ts` builds the Current Debate context from `context-plan.ts`, which reads a debate's list of material when a form opens, and the text only of the items a request sends (kept for the session), and plans it with the website's planner (`packages/core/src/debate/context-plan.ts`), with the Review choices for one request (Crossfire keeps them for its round) and the pins in `debate_context_pins` (three at most); the line on each tool form, Crossfire, Sparring and Ask (`components/context-line.tsx`) and its Review sheet read the same plan, and `chats.ts` saves finished exchanges to `chats` and `messages`. The SSE parser is shared in `packages/core/src/ai/sse.ts`. `markdown.ts` reads answers, because react-markdown needs the DOM. - Generate and Attack mirror `generateCase` and `attackCase` in `src/lib/ai/client.ts` and the session store's `recordResult`: `results.ts` sends the requests through `ai-request.ts`'s `postAI`, which streams them as the website does (`stream: true` with a `resultKey`; the reader is `packages/core/src/ai/live.ts`, shared with the website's `src/lib/ai/live.ts`) and reads a dropped stream's answer back from `ai_run_results` (`packages/core/src/ai/run-recovery.ts`). The four tool screens show the draft with `components/live-run.tsx` (the website's `LiveToolRun`; the sections come from `packages/core/src/ai/live-sections.ts`). `results.ts` also checks the output with the website's transform (moved to `packages/core/src/ai/generate-output.ts`), keeps researched sources in the debate's Auto-saved folder (`packages/core/src/ai/discovered-sources.ts`), saves the server's source rankings onto the Library sources a result used (`packages/core/src/ai/source-assessments.ts`, also for Ask's whole answers), says when the server shortened the input, and saves rows to `cases` and `attacks`. `motion.ts` and `motion-gate.ts` are the website's motion gate. `open-results.ts` holds results made in this session, so one-off work and failed saves still open as documents. It and the last Generate and Attack forms live in `session-memory.ts`, which the auth provider clears on every account change, including sign-out. `components/document.tsx` draws results as documents (mock 03); Brainstorm and Factoids reuse it. Export renders `export-html.ts` to a PDF with expo-print and shares it (`export.ts`); the web preview cannot print, so `export.web.ts` keeps the HTML and copied text on `window` for the screenshot script. Word export and Google Docs are not in the app. - Brainstorm and Factoids mirror `brainstorm` and `factoids` in `src/lib/ai/client.ts` and their pages, through the same `results.ts`: the brainstorm transform and the verified-factoid selection live in `packages/core/src/ai/brainstorm-output.ts` and `factoids-output.ts`, rows go to `brainstorms` and `factoids`. Brainstorm has the motion gate; Factoids, like Attack, follows the Current Debate and saves with side `neutral`. A factoid's bookmark saves it to the Current Debate's `Factoids` folder, and a search suggestion reopens the Factoids form with that query. `prepareToolHref` in `components/prepare-tools.ts` is where both tabs open a tool. - A case's Regenerate and Undo are `case-edits.ts`: one part is replaced from a new generation with the website's merge (`mergeRegeneratedSection` in `packages/core/src/ai/generate-output.ts`), earlier versions are kept for the session, and Undo writes the restored version back to the saved row (`updateResult`). Held results are observable (`subscribeOpenResults`), so the document shows a change made from its menu or its sheets. From 112ad07607d630c8039896c940be70d9b01e40b4 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 28 Oct 2026 14:27:18 +0000 Subject: [PATCH 14/14] Read a changed row's text again in the debate context The text the context plan keeps for the session never expired, so after a Regenerate or an Undo the plan judged the old case, a chat kept its old messages, and edits from the website went unseen. Kept text now carries the version the list read reports for its row: a case's generatedAt, which Regenerate and Undo change; a chat's message count; a Sparring round's updated_at. Attacks, brainstorms, factoids and Crossfire rounds are never edited. No source column says when it changed, so source text is read again after each list read. updateResult and saveChat also forget what they wrote. A row deleted between the list read and the text read is left out of the plan instead of failing the request. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/AGENTS.md | 2 +- apps/mobile/src/lib/chats.ts | 3 + apps/mobile/src/lib/context-plan.test.ts | 123 +++++++++++++++++++++++ apps/mobile/src/lib/context-plan.ts | 112 ++++++++++++++------- apps/mobile/src/lib/context-texts.ts | 50 +++++++++ apps/mobile/src/lib/results.ts | 4 +- 6 files changed, 257 insertions(+), 37 deletions(-) create mode 100644 apps/mobile/src/lib/context-plan.test.ts create mode 100644 apps/mobile/src/lib/context-texts.ts diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index c403b7432..8a86d595a 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -22,7 +22,7 @@ The iOS app DebateOwl (`@debatelab/mobile`; the bundle id is still `com.adam.deb - `src/components/`: shared UI. `app-tabs.tsx` uses native tabs; `app-tabs.web.tsx` draws the mock's tab bar for the web preview. - `src/lib/`: auth (`auth.tsx`), `native-capabilities.ts` (what the running binary has beyond Expo Go), the Supabase client, session storage (AES key in SecureStore, ciphertext in AsyncStorage) and i18n. `debates.ts`, `settings.tsx`, `subscription.ts` and `account.ts` read and write the website's tables the way its stores do (`src/lib/stores/debate-store.ts`, `settings-store.ts`, `src/hooks/use-subscription.ts`, `src/lib/account.ts`), so the app and the website share one Current Debate and one set of settings. Change them together with the website code they mirror. - `sources.ts` mirrors the session store's `addSource` and `removeSource` and `src/lib/source-import.ts`. The Library always shows the Current Debate, also in one-off mode, as on the website. Adding a file (the website parses files in the browser) and Scan pages are not in the app yet. -- Ask mirrors `src/lib/ai/streaming.ts` and `askChat` in `src/lib/ai/client.ts`: `ask.ts` sends the requests (plain questions stream from `ai-stream` through `expo/fetch`; with Citations, Web search, Pros/Cons or Argument map on, the whole answer comes from `ai`, because `ai-stream` refuses those options), `ai-request.ts` holds the shared POST with the website's headers, timeout and error kinds, `feature-config.ts` reads each tool's model from `user_settings.feature_configs` (the model picker, `components/model-picker.tsx`, writes it back through `settings.tsx`'s serial queue in the website's canonical shape; tools read it through the same queue, so they run with the model just picked), `context-request.ts` builds the Current Debate context from `context-plan.ts`, which reads a debate's list of material when a form opens, and the text only of the items a request sends (kept for the session), and plans it with the website's planner (`packages/core/src/debate/context-plan.ts`), with the Review choices for one request (Crossfire keeps them for its round) and the pins in `debate_context_pins` (three at most); the line on each tool form, Crossfire, Sparring and Ask (`components/context-line.tsx`) and its Review sheet read the same plan, and `chats.ts` saves finished exchanges to `chats` and `messages`. The SSE parser is shared in `packages/core/src/ai/sse.ts`. `markdown.ts` reads answers, because react-markdown needs the DOM. +- Ask mirrors `src/lib/ai/streaming.ts` and `askChat` in `src/lib/ai/client.ts`: `ask.ts` sends the requests (plain questions stream from `ai-stream` through `expo/fetch`; with Citations, Web search, Pros/Cons or Argument map on, the whole answer comes from `ai`, because `ai-stream` refuses those options), `ai-request.ts` holds the shared POST with the website's headers, timeout and error kinds, `feature-config.ts` reads each tool's model from `user_settings.feature_configs` (the model picker, `components/model-picker.tsx`, writes it back through `settings.tsx`'s serial queue in the website's canonical shape; tools read it through the same queue, so they run with the model just picked), `context-request.ts` builds the Current Debate context from `context-plan.ts`, which reads a debate's list of material when a form opens, and the text only of the items a request sends (kept in `context-texts.ts` under each row's version: a case's generatedAt, a chat's message count, a round's updated_at; source text is read again after each list read, and `updateResult` and `saveChat` forget what they change), and plans it with the website's planner (`packages/core/src/debate/context-plan.ts`), with the Review choices for one request (Crossfire keeps them for its round) and the pins in `debate_context_pins` (three at most); the line on each tool form, Crossfire, Sparring and Ask (`components/context-line.tsx`) and its Review sheet read the same plan, and `chats.ts` saves finished exchanges to `chats` and `messages`. The SSE parser is shared in `packages/core/src/ai/sse.ts`. `markdown.ts` reads answers, because react-markdown needs the DOM. - Generate and Attack mirror `generateCase` and `attackCase` in `src/lib/ai/client.ts` and the session store's `recordResult`: `results.ts` sends the requests through `ai-request.ts`'s `postAI`, which streams them as the website does (`stream: true` with a `resultKey`; the reader is `packages/core/src/ai/live.ts`, shared with the website's `src/lib/ai/live.ts`) and reads a dropped stream's answer back from `ai_run_results` (`packages/core/src/ai/run-recovery.ts`). The four tool screens show the draft with `components/live-run.tsx` (the website's `LiveToolRun`; the sections come from `packages/core/src/ai/live-sections.ts`). `results.ts` also checks the output with the website's transform (moved to `packages/core/src/ai/generate-output.ts`), keeps researched sources in the debate's Auto-saved folder (`packages/core/src/ai/discovered-sources.ts`), saves the server's source rankings onto the Library sources a result used (`packages/core/src/ai/source-assessments.ts`, also for Ask's whole answers), says when the server shortened the input, and saves rows to `cases` and `attacks`. `motion.ts` and `motion-gate.ts` are the website's motion gate. `open-results.ts` holds results made in this session, so one-off work and failed saves still open as documents. It and the last Generate and Attack forms live in `session-memory.ts`, which the auth provider clears on every account change, including sign-out. `components/document.tsx` draws results as documents (mock 03); Brainstorm and Factoids reuse it. Export renders `export-html.ts` to a PDF with expo-print and shares it (`export.ts`); the web preview cannot print, so `export.web.ts` keeps the HTML and copied text on `window` for the screenshot script. Word export and Google Docs are not in the app. - Brainstorm and Factoids mirror `brainstorm` and `factoids` in `src/lib/ai/client.ts` and their pages, through the same `results.ts`: the brainstorm transform and the verified-factoid selection live in `packages/core/src/ai/brainstorm-output.ts` and `factoids-output.ts`, rows go to `brainstorms` and `factoids`. Brainstorm has the motion gate; Factoids, like Attack, follows the Current Debate and saves with side `neutral`. A factoid's bookmark saves it to the Current Debate's `Factoids` folder, and a search suggestion reopens the Factoids form with that query. `prepareToolHref` in `components/prepare-tools.ts` is where both tabs open a tool. - A case's Regenerate and Undo are `case-edits.ts`: one part is replaced from a new generation with the website's merge (`mergeRegeneratedSection` in `packages/core/src/ai/generate-output.ts`), earlier versions are kept for the session, and Undo writes the restored version back to the saved row (`updateResult`). Held results are observable (`subscribeOpenResults`), so the document shows a change made from its menu or its sheets. diff --git a/apps/mobile/src/lib/chats.ts b/apps/mobile/src/lib/chats.ts index 3e9b0a2a7..36e9ca6b4 100644 --- a/apps/mobile/src/lib/chats.ts +++ b/apps/mobile/src/lib/chats.ts @@ -1,6 +1,7 @@ import type { AskOutput } from '@debatelab/core/ai/schemas/ask'; import { debateListScopes } from './debates'; +import { forgetText } from './context-texts'; import { afterWrite, cachedRead, pickFailure, readFailed, type CopyMark } from './offline-cache'; import { supabase } from './supabase'; @@ -132,5 +133,7 @@ export async function saveChat(userId: string, record: ChatRecord, messages: Cha if (error) throw new ChatSaveError(error.message, { chatId, debateId, persisted: record.persisted }); // The conversation and the message count in its debate's list and in History changed. afterWrite(userId, [{ group: 'chat', key: chatId }, { group: 'chats', key: debateId ?? 'one-off' }, { group: 'history', key: debateId ?? 'one-off' }]); + // The debate context reads the chat's messages again. + forgetText(`chat:${chatId}`); return { chatId, debateId, persisted: messages.length }; } diff --git a/apps/mobile/src/lib/context-plan.test.ts b/apps/mobile/src/lib/context-plan.test.ts new file mode 100644 index 000000000..2c73d9586 --- /dev/null +++ b/apps/mobile/src/lib/context-plan.test.ts @@ -0,0 +1,123 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// The debate context plan reads a debate's list each time, and an item's text +// only when the plan sends it and the row changed since: a regenerated case +// and a chat with a new message are read again, an unchanged row is not, and +// a row deleted between the two reads is left out. + +type Row = Record; + +const { db, reads, deletedSinceList } = vi.hoisted(() => ({ + db: {} as Record, + /** Every read, as `table select`. */ + reads: [] as string[], + /** Rows the list read still sees but a text read (by id) no longer finds. */ + deletedSinceList: new Set(), +})); + +vi.mock('expo-router', () => ({ useFocusEffect: vi.fn() })); +vi.mock('./auth', () => ({ useAuth: () => ({ session: null }) })); +vi.mock('./supabase', () => ({ + supabase: { + from: (table: string) => { + let columns = ''; + const filters: [string, unknown][] = []; + let within: [string, unknown[]] | null = null; + const builder = { + select(value: string) { + columns = value; + return builder; + }, + eq(column: string, value: unknown) { + filters.push([column, value]); + return builder; + }, + in(column: string, values: unknown[]) { + within = [column, values]; + return builder; + }, + order() { + return builder; + }, + then(resolve: (value: { data: Row[]; error: null }) => unknown) { + reads.push(`${table} ${columns}`); + const rows = (db[table] ?? []) + .filter((row) => filters.every(([column, value]) => row[column] === value)) + .filter((row) => !within || (within[1].includes(row[within[0]]) && !deletedSinceList.has(row.id))) + .map((row) => ({ + ...row, + // The list's version of a case, and its chats' message counts, as PostgREST computes them. + ...(columns.includes('version:') ? { version: ((row.output as Row | undefined)?.metadata as Row | undefined)?.generatedAt ?? null } : {}), + ...(columns.includes('messages(count)') ? { messages: [{ count: (db.messages ?? []).filter((message) => message.chat_id === row.id).length }] } : {}), + })); + return Promise.resolve({ data: rows, error: null }).then(resolve); + }, + }; + return builder; + }, + }, +})); + +import { forgetText } from './context-texts'; +import { planRequest, setOutputChoice } from './context-plan'; + +const USER = 'u1'; +const DEBATE = '00000000-0000-4000-8000-0000000000d1'; +const CASE = '00000000-0000-4000-8000-0000000000c1'; +const CHAT = '00000000-0000-4000-8000-0000000000a1'; + +const caseRow = (output: Row) => ({ id: CASE, user_id: USER, debate_id: DEBATE, title: 'Case', side: 'pro', created_at: '2026-10-28T10:00:00Z', output }); +const outputReads = (table: string) => reads.filter((read) => read.startsWith(`${table} id,output`)).length; + +beforeEach(() => { + for (const key of Object.keys(db)) delete db[key]; + reads.length = 0; + deletedSinceList.clear(); + forgetText(`case:${CASE}`); + forgetText(`chat:${CHAT}`); +}); + +describe('planRequest', () => { + it('reads a regenerated case again, so the plan uses its new text', async () => { + db.cases = [caseRow({ metadata: { generatedAt: 't1' } })]; + const before = await planRequest(USER, DEBATE, 'attack', 'both'); + // The first version has nothing usable, so it is not sent. + expect(before.request.outputs).toEqual([]); + + db.cases = [caseRow({ caseStructure: { thesis: 'Fares price people out' }, metadata: { generatedAt: 't2' } })]; + const after = await planRequest(USER, DEBATE, 'attack', 'both'); + expect(after.request.outputs).toEqual([{ kind: 'case', id: CASE }]); + expect(outputReads('cases')).toBe(2); + }); + + it('does not read an unchanged case again', async () => { + db.cases = [caseRow({ caseStructure: { thesis: 'Fares price people out' }, metadata: { generatedAt: 't1' } })]; + await planRequest(USER, DEBATE, 'attack', 'both'); + await planRequest(USER, DEBATE, 'attack', 'both'); + expect(outputReads('cases')).toBe(1); + }); + + it('reads a chat again once it has a new message', async () => { + db.chats = [{ id: CHAT, user_id: USER, debate_id: DEBATE, title: 'Chat', side: 'neutral', created_at: '2026-10-28T10:00:00Z' }]; + db.messages = [{ chat_id: CHAT, role: 'user', content: 'Is free transit fair?', created_at: '2026-10-28T10:00:01Z' }]; + // Chats are not chosen automatically; Review includes this one. + setOutputChoice('ask', DEBATE, { kind: 'chat', id: CHAT }, true); + const messageReads = () => reads.filter((read) => read.startsWith('messages ')).length; + + await planRequest(USER, DEBATE, 'ask', 'both'); + await planRequest(USER, DEBATE, 'ask', 'both'); + expect(messageReads()).toBe(1); + + db.messages.push({ chat_id: CHAT, role: 'assistant', content: 'Mostly, for people priced out.', created_at: '2026-10-28T10:00:02Z' }); + await planRequest(USER, DEBATE, 'ask', 'both'); + expect(messageReads()).toBe(2); + }); + + it('leaves out a case deleted between the list read and the text read', async () => { + db.cases = [caseRow({ caseStructure: { thesis: 'Fares price people out' }, metadata: { generatedAt: 't1' } })]; + deletedSinceList.add(CASE); + const plan = await planRequest(USER, DEBATE, 'attack', 'both'); + expect(plan.request.outputs).toEqual([]); + expect(outputReads('cases')).toBe(1); + }); +}); diff --git a/apps/mobile/src/lib/context-plan.ts b/apps/mobile/src/lib/context-plan.ts index 1759aca8d..36fe4fdad 100644 --- a/apps/mobile/src/lib/context-plan.ts +++ b/apps/mobile/src/lib/context-plan.ts @@ -12,6 +12,7 @@ import { useFocusEffect } from 'expo-router'; import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react'; import { useAuth } from './auth'; +import { GONE, keepText, keptText, onTextForgotten } from './context-texts'; import { sessionSlot } from './session-memory'; import { supabase } from './supabase'; @@ -26,7 +27,11 @@ import { supabase } from './supabase'; * The list holds titles, sides and dates only. Which items a request includes * does not depend on their text, so the text (a source's content, a result's * output, a chat's messages) is read only for the items the plan will send, - * and kept for the session, so the size limits are checked on what is sent. + * and kept (context-texts.ts) under a version the list reports: a case's + * generatedAt (Regenerate and Undo change it), a chat's message count, a + * round's updated_at. Attacks, brainstorms, factoids and Crossfire rounds are + * never edited. Sources have no such column, so their text is read again + * after each list read. A row deleted since the list read is left out. */ type ResultKind = Exclude; @@ -46,11 +51,11 @@ interface ContextMaterial { results: Partial>; sparring: PlanSparringSession[]; pins: DebateOutputRef[]; + /** The version of each item's text, by `source:` or `:`. */ + versions: Map; } const material = sessionSlot(() => new Map()); -/** Text read for items a plan sends, by debate and `source:` or `:`. */ -const texts = sessionSlot(() => new Map()); const overrides = sessionSlot((): Partial> => ({})); /** The latest list read per debate; an older read that ends later is dropped. */ const loads = new Map(); @@ -69,13 +74,15 @@ function subscribe(listener: () => void) { return () => listeners.delete(listener); } +// A write that forgot a row's text redraws the plans on screen, which read it again. +onTextForgotten(emit); + function rowsOf(result: { data: unknown; error: { message: string } | null }): T[] { if (result.error) throw new Error(result.error.message); return (result.data ?? []) as T[]; } const time = (value: string) => new Date(value).getTime(); -const textKey = (debateId: string, key: string) => `${debateId}|${key}`; /** * Reads a debate's list of material: its sources, saved results and judged @@ -91,8 +98,13 @@ export async function loadContextMaterial(userId: string, debateId: string): Pro supabase.from('sources').select('id,title,type,is_active').eq('user_id', userId).eq('debate_id', debateId).order('created_at', { ascending: false }), supabase.from('debate_context_pins').select('item_kind,item_id').eq('user_id', userId).eq('debate_id', debateId), supabase.from('sparring_sessions').select('id,motion,user_side,updated_at').eq('user_id', userId).eq('debate_id', debateId), - supabase.from('chats').select('id,title,side,created_at').eq('user_id', userId).eq('debate_id', debateId), - ...RESULT_KINDS.map((kind) => supabase.from(RESULT_TABLES[kind]).select('id,title,side,created_at').eq('user_id', userId).eq('debate_id', debateId)), + supabase.from('chats').select('id,title,side,created_at,messages(count)').eq('user_id', userId).eq('debate_id', debateId), + ...RESULT_KINDS.map((kind) => + supabase + .from(RESULT_TABLES[kind]) + .select(kind === 'case' ? 'id,title,side,created_at,version:output->metadata->>generatedAt' : 'id,title,side,created_at') + .eq('user_id', userId) + .eq('debate_id', debateId)), ]); // A newer read started meanwhile: it decides. const superseded = material.get().get(debateId); @@ -110,38 +122,49 @@ export async function loadContextMaterial(userId: string, debateId: string): Pro : ((pins.data ?? []) as { item_kind: unknown; item_id: unknown }[]).flatMap((row) => isDebateContextOutputKind(row.item_kind) && typeof row.item_id === 'string' ? [{ kind: row.item_kind, id: row.item_id }] : []); const held = material.get().get(debateId); + const versions = new Map(); const next: ContextMaterial = { debateId, - sources: rowsOf<{ id: string; title: string; type: string | null; is_active: boolean }>(sources).map((row) => ({ + sources: rowsOf<{ id: string; title: string; type: string | null; is_active: boolean }>(sources).map((row) => { + // No column says when a source changed, so its text is read again after each list read. + versions.set(`source:${row.id}`, `list:${load}`); + return { id: row.id, title: row.title, content: '', type: row.type ?? 'text', debateId, isActive: row.is_active, - })), - results: { chat: rowsOf<{ id: string; title: string; side: string | null; created_at: string }>(chats).map(resultRow) }, - sparring: rowsOf<{ id: string; motion: string; user_side: string | null; updated_at: string }>(sparring).map((row) => ({ - id: row.id, - motion: row.motion, - debateId, - userSide: row.user_side, - updatedAt: time(row.updated_at), - })), + }; + }), + results: { + chat: rowsOf<{ id: string; title: string; side: string | null; created_at: string; messages?: { count?: number }[] }>(chats).map((row) => { + versions.set(`chat:${row.id}`, `messages:${row.messages?.[0]?.count ?? 0}`); + return resultRow(row); + }), + }, + sparring: rowsOf<{ id: string; motion: string; user_side: string | null; updated_at: string }>(sparring).map((row) => { + versions.set(`sparring:${row.id}`, row.updated_at); + return { + id: row.id, + motion: row.motion, + debateId, + userSide: row.user_side, + updatedAt: time(row.updated_at), + }; + }), // A pin changed while this read ran is newer than what the read saw. pins: held && (pinEdits.get(debateId) ?? 0) !== pinsAtStart ? held.pins : loadedPins, + versions, }; RESULT_KINDS.forEach((kind, index) => { - next.results[kind] = rowsOf<{ id: string; title: string; side: string | null; created_at: string }>(results[index]).map(resultRow); + next.results[kind] = rowsOf<{ id: string; title: string; side: string | null; created_at: string; version?: string | null }>(results[index]).map((row) => { + // Only a case is ever edited (Regenerate, Undo); its generatedAt changes with it. + versions.set(`${kind}:${row.id}`, kind === 'case' ? `generated:${row.version ?? ''}` : 'saved'); + return resultRow(row); + }); }); material.get().set(debateId, next); - // Text kept as "nothing usable yet" (an unjudged round, a chat with no messages) is read again. - for (const [key, value] of texts.get()) { - if (!key.startsWith(`${debateId}|`)) continue; - const unjudged = key.includes('|sparring:') && !(value as { scorecard?: unknown } | undefined)?.scorecard; - const emptyChat = key.includes('|chat:') && Array.isArray(value) && value.length === 0; - if (unjudged || emptyChat) texts.get().delete(key); - } emit(); return next; } @@ -150,7 +173,13 @@ export async function loadContextMaterial(userId: string, debateId: string): Pro async function loadTexts(userId: string, debateId: string, keys: string[]): Promise { const ids = (prefix: string) => keys.filter((key) => key.startsWith(`${prefix}:`)).map((key) => key.slice(prefix.length + 1)); const reads: PromiseLike[] = []; - const keep = (key: string, value: unknown) => texts.get().set(textKey(debateId, key), value); + // Versions as the list read reported them when the read started. + const versions = material.get().get(debateId)?.versions ?? new Map(); + const found = new Set(); + const keep = (key: string, value: unknown) => { + found.add(key); + keepText(debateId, key, versions.get(key) ?? '', value); + }; const sourceIds = ids('source'); if (sourceIds.length > 0) { reads.push(supabase.from('sources').select('id,content').eq('user_id', userId).in('id', sourceIds).then((result) => { @@ -182,6 +211,8 @@ async function loadTexts(userId: string, debateId: string, keys: string[]): Prom })); } await Promise.all(reads); + // A row deleted since the list read has no text; the plan leaves it out. + for (const key of keys) if (!found.has(key)) keepText(debateId, key, versions.get(key) ?? '', GONE); emit(); } @@ -197,17 +228,25 @@ interface PlanOptions { * items whose text is still to be read. */ function planFor(held: ContextMaterial, feature: DebateContextFeature, side: DebateContextSide, options: PlanOptions): { plan: DebateContextPlan } | { missing: string[] } { - const known = texts.get(); - const text = (key: string) => known.get(textKey(held.debateId, key)); + const text = (key: string) => keptText(held.debateId, key, held.versions.get(key) ?? ''); + const present = (key: string) => text(key)?.value !== GONE; + const value = (key: string) => { + const kept = text(key)?.value; + return kept === GONE ? undefined : kept; + }; const inputs = { - sourceLibrary: held.sources.map((source) => ({ ...source, content: (text(`source:${source.id}`) as string | undefined) ?? '' })), + sourceLibrary: held.sources + .filter((source) => present(`source:${source.id}`)) + .map((source) => ({ ...source, content: (value(`source:${source.id}`) as string | undefined) ?? '' })), results: Object.fromEntries( - Object.entries(held.results).map(([kind, rows]) => [kind, rows?.map((row) => ({ ...row, data: text(`${kind}:${row.id}`) }))]), + Object.entries(held.results).map(([kind, rows]) => [ + kind, + rows?.filter((row) => present(`${kind}:${row.id}`)).map((row) => ({ ...row, data: value(`${kind}:${row.id}`) })), + ]), ) as ContextMaterial['results'], - sparringSessions: held.sparring.map((session) => { - const saved = text(`sparring:${session.id}`) as { userSide?: unknown; scorecard?: unknown } | undefined; - return { ...session, scorecard: saved?.scorecard }; - }), + sparringSessions: held.sparring + .filter((session) => present(`sparring:${session.id}`)) + .map((session) => ({ ...session, scorecard: (value(`sparring:${session.id}`) as { scorecard?: unknown } | undefined)?.scorecard })), pins: held.pins, override: overrides.get()[feature], excludeChatId: options.excludeChatId ?? null, @@ -218,14 +257,17 @@ function planFor(held: ContextMaterial, feature: DebateContextFeature, side: Deb ...plan.sources.filter((source) => source.included && !source.overLimit).map((source) => `source:${source.id}`), ...plan.outputs.filter((output) => output.included && !output.overLimit).map((output) => outputKey(output.ref)), ]; - const missing = sent.filter((key) => !known.has(textKey(held.debateId, key))); + const missing = sent.filter((key) => !text(key)); return missing.length > 0 ? { missing } : { plan }; } /** The plan for a request about to be sent, from the list read now. */ export async function planRequest(userId: string, debateId: string, feature: DebateContextFeature, side: DebateContextSide, options: PlanOptions = {}): Promise { const held = await loadContextMaterial(userId, debateId); - for (let attempt = 0; attempt < 3; attempt += 1) { + // Each read settles every key it asks for, found or gone; a gone row can only free its + // slot for another, so this ends within one read per item. + const items = held.sources.length + held.sparring.length + Object.values(held.results).reduce((sum, rows) => sum + (rows?.length ?? 0), 0); + for (let attempt = 0; attempt <= items + 1; attempt += 1) { const result = planFor(held, feature, side, options); if ('plan' in result) return result.plan; await loadTexts(userId, debateId, result.missing); diff --git a/apps/mobile/src/lib/context-texts.ts b/apps/mobile/src/lib/context-texts.ts new file mode 100644 index 000000000..baf6122fe --- /dev/null +++ b/apps/mobile/src/lib/context-texts.ts @@ -0,0 +1,50 @@ +import { sessionSlot } from './session-memory'; + +/** + * The text of debate material the context plan has read (a source's content, + * a result's output, a chat's messages, a round's scorecard), kept for the + * session under the version the list read reported for the row, so a changed + * row is read again. Writes in the app also forget their row here. Kept apart + * from context-plan.ts so the modules that write rows need no React. + */ + +/** A row that was gone when its text was read: the plan leaves it out. */ +export const GONE = Symbol('gone'); + +interface KeptText { + version: string; + value: unknown; +} + +const texts = sessionSlot(() => new Map()); +const listeners = new Set<() => void>(); + +const entry = (debateId: string, key: string) => `${debateId}|${key}`; + +/** The kept text of `key` (`source:` or `:`), if it was read at this version. */ +export function keptText(debateId: string, key: string, version: string): { value: unknown } | undefined { + const kept = texts.get().get(entry(debateId, key)); + return kept && kept.version === version ? { value: kept.value } : undefined; +} + +export function keepText(debateId: string, key: string, version: string, value: unknown) { + texts.get().set(entry(debateId, key), { version, value }); +} + +/** Forgets a row's text in every debate, after the app changed the row. */ +export function forgetText(key: string) { + let forgot = false; + for (const name of texts.get().keys()) { + if (name.endsWith(`|${key}`)) { + texts.get().delete(name); + forgot = true; + } + } + if (forgot) listeners.forEach((listener) => listener()); +} + +/** Called when a write forgets text, so a plan on screen reads it again. */ +export function onTextForgotten(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} diff --git a/apps/mobile/src/lib/results.ts b/apps/mobile/src/lib/results.ts index e1a759d73..5015318e7 100644 --- a/apps/mobile/src/lib/results.ts +++ b/apps/mobile/src/lib/results.ts @@ -19,6 +19,7 @@ import type { DebateContextRequest } from '@debatelab/core/contract/debate-conte import { AIError, postAI, PROVIDER, responseExtras, type ResponseExtras } from './ai-request'; import type { FeatureConfig } from './feature-config'; import { debateListScopes, deletedListScopes } from './debates'; +import { forgetText } from './context-texts'; import { afterWrite, cachedRead, readFailed, updateCopy, type CopyMark } from './offline-cache'; import { saveResearchedSource, applySourceAssessments, fetchSourceIdsInFolder, removeSource } from './sources'; import { supabase } from './supabase'; @@ -245,8 +246,9 @@ export async function updateResult(userId: string, kind: K const { data, error } = await supabase.from(TABLES[kind]).update({ output }).eq('user_id', userId).eq('id', id).select('id'); if (error) throw new Error(error.message); if (!data || data.length === 0) throw new Error(`The ${kind} is no longer saved`); - // The saved copy reads the new output offline too. + // The saved copy reads the new output offline too, and the debate context reads it again. updateCopy | null>(userId, `result:${kind}`, id, (copy) => (copy ? { ...copy, output } : copy)); + forgetText(`${kind}:${id}`); } /** Saves a case outside any debate, as the website's Save button does for unrecorded work. */