diff --git a/backend/oss-migrations/20260606_oss_schema_diff.sql b/backend/oss-migrations/20260606_oss_schema_diff.sql index 3ff2c71..6020985 100644 --- a/backend/oss-migrations/20260606_oss_schema_diff.sql +++ b/backend/oss-migrations/20260606_oss_schema_diff.sql @@ -23,6 +23,10 @@ alter table public.user_api_keys add constraint user_api_keys_provider_check check (provider in ('claude', 'gemini', 'openai', 'openrouter', 'courtlistener')); +alter table public.user_api_keys enable row level security; + +drop policy if exists user_api_keys_own on public.user_api_keys; + -- --------------------------------------------------------------------------- -- Document metadata now lives on document_versions -- --------------------------------------------------------------------------- @@ -123,6 +127,21 @@ alter table public.documents drop column if exists page_count, drop column if exists structure_tree; +do $$ +begin + if not exists ( + select 1 + from pg_constraint + where conname = 'document_versions_doc_version_unique' + and conrelid = 'public.document_versions'::regclass + ) then + alter table public.document_versions + add constraint document_versions_doc_version_unique + unique (document_id, version_number); + end if; +end; +$$; + -- --------------------------------------------------------------------------- -- CourtListener bulk-data indexes -- --------------------------------------------------------------------------- @@ -144,6 +163,10 @@ create index if not exists courtlistener_citation_lookup_idx create index if not exists courtlistener_citation_cluster_idx on public.courtlistener_citation_index(cluster_id); +alter table public.courtlistener_citation_index enable row level security; + +drop policy if exists cl_citation_read on public.courtlistener_citation_index; + create table if not exists public.courtlistener_opinion_cluster_index ( id bigint primary key, case_name text, @@ -158,5 +181,9 @@ create table if not exists public.courtlistener_opinion_cluster_index ( docket_id bigint ); +alter table public.courtlistener_opinion_cluster_index enable row level security; + +drop policy if exists cl_cluster_read on public.courtlistener_opinion_cluster_index; + revoke all on public.courtlistener_citation_index from anon, authenticated; revoke all on public.courtlistener_opinion_cluster_index from anon, authenticated; diff --git a/backend/schema.sql b/backend/schema.sql index 83a4cf3..359efcf 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -63,6 +63,8 @@ create table if not exists public.user_api_keys ( create index if not exists idx_user_api_keys_user on public.user_api_keys(user_id); +alter table public.user_api_keys enable row level security; + -- --------------------------------------------------------------------------- -- Projects and documents -- --------------------------------------------------------------------------- @@ -142,6 +144,21 @@ create index if not exists document_versions_document_id_idx create index if not exists document_versions_doc_vnum_idx on public.document_versions(document_id, version_number); +do $$ +begin + if not exists ( + select 1 + from pg_constraint + where conname = 'document_versions_doc_version_unique' + and conrelid = 'public.document_versions'::regclass + ) then + alter table public.document_versions + add constraint document_versions_doc_version_unique + unique (document_id, version_number); + end if; +end; +$$; + alter table public.documents add column if not exists current_version_id uuid references public.document_versions(id) on delete set null; @@ -361,6 +378,8 @@ create index if not exists courtlistener_citation_lookup_idx create index if not exists courtlistener_citation_cluster_idx on public.courtlistener_citation_index(cluster_id); +alter table public.courtlistener_citation_index enable row level security; + create table if not exists public.courtlistener_opinion_cluster_index ( id bigint primary key, case_name text, @@ -375,6 +394,8 @@ create table if not exists public.courtlistener_opinion_cluster_index ( docket_id bigint ); +alter table public.courtlistener_opinion_cluster_index enable row level security; + -- --------------------------------------------------------------------------- -- Direct client grant hardening -- --------------------------------------------------------------------------- diff --git a/backend/src/lib/chatTools.ts b/backend/src/lib/chatTools.ts index b16894f..5eeb9ae 100644 --- a/backend/src/lib/chatTools.ts +++ b/backend/src/lib/chatTools.ts @@ -100,6 +100,9 @@ export type ChatMessage = { export const SYSTEM_PROMPT = `You are Mike, an AI legal assistant that helps lawyers and legal professionals analyze documents, answer legal questions, and draft legal documents. +TOOL BUDGET: +You have at most 10 tool-use rounds in a single response. Use tools deliberately, batch independent tool calls in the same round where possible, and reserve enough room to produce a final answer. Do not spend the final tool round gathering more information unless you can answer without another tool call afterward. + DOCUMENT CITATION INSTRUCTIONS: When you reference specific content from an uploaded/generated document, place a numbered marker [1], [2], etc. inline in your prose at the point of reference. These numbered [N] markers and the block are for evidence passages that the UI can open. Uploaded/generated document citations use the document entry shape below. Research tools may define additional source-specific citation entry shapes in their own instructions. @@ -1972,7 +1975,6 @@ type CourtlistenerCaseRecord = { url: string | null; pdfUrl: string | null; dateFiled: string | null; - judges: string | null; opinions?: unknown[]; }; @@ -1984,7 +1986,6 @@ type CourtlistenerCaseInput = { url?: string | null; pdfUrl?: string | null; dateFiled?: string | null; - judges?: string | null; opinions?: unknown[]; }; @@ -2015,7 +2016,6 @@ function upsertCourtlistenerCases( url: null, pdfUrl: null, dateFiled: null, - judges: null, }; const nextCitations = [ ...current.citations, @@ -2031,7 +2031,6 @@ function upsertCourtlistenerCases( url: current.url ?? nonEmpty(input.url), pdfUrl: current.pdfUrl ?? nonEmpty(input.pdfUrl), dateFiled: current.dateFiled ?? nonEmpty(input.dateFiled), - judges: current.judges ?? nonEmpty(input.judges), opinions: current.opinions ?? input.opinions, }; state.casesByClusterId.set(clusterId, record); @@ -2052,7 +2051,6 @@ function caseCitationEventFromRecord( url: record.url, pdfUrl: record.pdfUrl, dateFiled: record.dateFiled, - judges: record.judges, }; } @@ -2104,7 +2102,6 @@ function courtlistenerCaseInputFromFetchedCase( url: stringField(record, "url"), pdfUrl: stringField(record, "pdfUrl"), dateFiled: stringField(record, "dateFiled"), - judges: stringField(record, "judges"), opinions: Array.isArray(record?.opinions) ? record.opinions : undefined, }; } @@ -2146,7 +2143,6 @@ function courtlistenerFetchedCaseMetadata( dateFiled: record.dateFiled, url: record.url, pdfUrl: record.pdfUrl, - judges: record.judges, opinion_count: opinionCount, opinions: (record.opinions ?? []) .map(courtlistenerOpinionMetadata) @@ -2884,7 +2880,6 @@ export async function runToolCalls( citations: record.citations, url: record.url, dateFiled: record.dateFiled, - judges: record.judges, opinion_count: opinions.length, opinions: (record.opinions ?? []) .map(courtlistenerOpinionMetadata) @@ -2933,7 +2928,6 @@ export async function runToolCalls( citations: record.citations, url: record.url, dateFiled: record.dateFiled, - judges: record.judges, opinion_count: opinions.length, returned_opinion_count: selectedOpinions.length, opinions: selectedOpinions, @@ -2944,16 +2938,13 @@ export async function runToolCalls( ? args.citations.filter( (value): value is string => typeof value === "string", ) - : undefined; - const citationCount = - citations?.length ?? - (typeof args.text === "string" && args.text.trim() ? 1 : 0); + : []; + const citationCount = citations.length; write( `data: ${JSON.stringify({ type: "courtlistener_verify_citations_start", citation_count: citationCount })}\n\n`, ); try { const result = (await verifyCourtlistenerCitations({ - text: typeof args.text === "string" ? args.text : undefined, citations, db, apiToken: apiKeys?.courtlistener, @@ -2964,7 +2955,6 @@ export async function runToolCalls( caseName?: string | null; dateFiled?: string | null; pdfUrl?: string | null; - judges?: string | null; url?: string | null; markdown?: string; }[]; @@ -2983,7 +2973,6 @@ export async function runToolCalls( url: link.url, pdfUrl: link.pdfUrl, dateFiled: link.dateFiled, - judges: link.judges, })), ); const recordsByClusterId = new Map( @@ -3712,7 +3701,6 @@ function createCitationAnnotation( url: caseRecord?.url ?? null, pdfUrl: caseRecord?.pdfUrl ?? null, dateFiled: caseRecord?.dateFiled ?? null, - judges: caseRecord?.judges ?? null, quotes: citation.quotes, }; } @@ -3812,6 +3800,13 @@ export class AssistantStreamError extends Error { } } +export class AssistantStreamAbortError extends AssistantStreamError { + constructor(fullText: string, events: AssistantEvent[]) { + super("Stream aborted.", fullText, events); + this.name = "AbortError"; + } +} + export function isAbortError(error: unknown): boolean { if (!error || typeof error !== "object") return false; const record = error as { name?: unknown; message?: unknown }; @@ -3970,22 +3965,25 @@ export async function runLLMStream(params: { } }; - const flushVisibleTail = () => { + const flushVisibleTail = (opts: { emit?: boolean } = {}) => { + const emit = opts.emit ?? true; if (citationsOpenSeen || !visibleTailBuffer) { visibleTailBuffer = ""; return; } iterVisibleText += visibleTailBuffer; - write( - `data: ${JSON.stringify({ type: "content_delta", text: visibleTailBuffer })}\n\n`, - ); + if (emit) { + write( + `data: ${JSON.stringify({ type: "content_delta", text: visibleTailBuffer })}\n\n`, + ); + } visibleTailBuffer = ""; }; - const flushText = () => { + const flushText = (opts: { emit?: boolean } = {}) => { if (!iterText) return; fullText += iterText; - flushVisibleTail(); + flushVisibleTail(opts); if (iterVisibleText) { events.push({ type: "content", text: iterVisibleText }); } @@ -3997,6 +3995,14 @@ export async function runLLMStream(params: { streamedCitationCount = 0; }; + const flushPartialTurn = (opts: { emit?: boolean } = {}) => { + flushText(opts); + if (iterReasoning) { + events.push({ type: "reasoning", text: iterReasoning }); + iterReasoning = ""; + } + }; + const selectedModel = resolveModel(model, DEFAULT_MAIN_MODEL); try { @@ -4161,8 +4167,11 @@ export async function runLLMStream(params: { }, }); } catch (err) { - if (isAbortError(err)) throw err; - flushText(); + if (isAbortError(err)) { + flushPartialTurn({ emit: false }); + throw new AssistantStreamAbortError(fullText, events); + } + flushPartialTurn(); const message = err instanceof Error && err.message ? err.message : "Stream error"; events.push({ type: "error", message }); @@ -4208,6 +4217,24 @@ export function stripTransientAssistantEvents(events: AssistantEvent[]) { return events.filter((event) => event.type !== "case_opinions"); } +export function appendCancelledAssistantEvent(events: AssistantEvent[]) { + return [...events, { type: "content" as const, text: "Cancelled by user." }]; +} + +export function buildCancelledAssistantMessage(args: { + fullText: string; + events: AssistantEvent[]; + buildAnnotations: (fullText: string, events: AssistantEvent[]) => unknown[]; +}) { + const events = appendCancelledAssistantEvent( + stripTransientAssistantEvents(args.events), + ); + return { + events, + annotations: args.buildAnnotations(args.fullText, events), + }; +} + // --------------------------------------------------------------------------- // Document context builder (from message file attachments) // --------------------------------------------------------------------------- diff --git a/backend/src/lib/courtlistener.ts b/backend/src/lib/courtlistener.ts index 82a07bd..b5ee05c 100644 --- a/backend/src/lib/courtlistener.ts +++ b/backend/src/lib/courtlistener.ts @@ -90,6 +90,7 @@ async function courtlistenerFetch( }); const response = await fetch(url, { ...init, + signal: init?.signal ?? AbortSignal.timeout(15_000), headers: { ...courtlistenerHeaders(apiToken), ...(init?.headers ?? {}), @@ -146,7 +147,6 @@ function compactCluster(raw: unknown) { id: null, caseName: null, dateFiled: null, - judges: null, court: null, citations: [], url: null, @@ -161,7 +161,6 @@ function compactCluster(raw: unknown) { asString(cluster.caseName) ?? asString(cluster.name), dateFiled: asString(cluster.date_filed) ?? asString(cluster.dateFiled), - judges: asString(cluster.judges), court: asString((cluster.docket as JsonRecord | undefined)?.court_id) ?? asString(cluster.court) ?? @@ -208,14 +207,19 @@ async function fetchCaseOpinionsFromCourtlistenerOpinionsEndpoint(args: { includeFullText?: boolean; apiToken?: string | null; }) { + const MAX_OPINION_PAGES = 10; const opinions: ReturnType[] = []; const rawOpinions: JsonRecord[] = []; let nextUrl: string | null = `/opinions/?cluster=${args.clusterId}`; + let pages = 0; + let remainingChars = args.maxChars; - while (nextUrl) { + while (nextUrl && pages < MAX_OPINION_PAGES && remainingChars > 0) { + pages += 1; devLog("[courtlistener/opinions-endpoint] fetching page", { clusterId: args.clusterId, path: nextUrl, + page: pages, }); const data = await courtlistenerFetch( nextUrl, @@ -226,21 +230,26 @@ async function fetchCaseOpinionsFromCourtlistenerOpinionsEndpoint(args: { const opinionMaxChars = args.includeFullText ? Math.max( 500, - Math.floor(args.maxChars / Math.max(1, results.length)), + Math.floor(remainingChars / Math.max(1, results.length)), ) - : 3000; + : Math.min(3000, remainingChars); const pageOpinions = results.filter( (opinion): opinion is JsonRecord => !!opinion && typeof opinion === "object" && !Array.isArray(opinion), ); - rawOpinions.push(...pageOpinions); - opinions.push( - ...pageOpinions.map((opinion) => - compactOpinion(opinion, opinionMaxChars), - ), - ); + for (const opinion of pageOpinions) { + if (remainingChars <= 0) break; + const compacted = compactOpinion( + opinion, + Math.max(1, Math.min(opinionMaxChars, remainingChars)), + ); + rawOpinions.push(opinion); + opinions.push(compacted); + remainingChars -= + (compacted.text?.length ?? 0) + (compacted.html?.length ?? 0); + } nextUrl = asString(data.next); } @@ -481,7 +490,6 @@ function compactBulkCluster(cluster: JsonRecord, citations: string[] = []) { asString(cluster.case_name_full) ?? asString(cluster.case_name_short), dateFiled: asString(cluster.date_filed), - judges: asString(cluster.judges), court: null, citations, url: clusterUrl(cluster), @@ -490,29 +498,132 @@ function compactBulkCluster(cluster: JsonRecord, citations: string[] = []) { }; } +type CitationLookupCluster = + | ReturnType + | ReturnType; + +type CitationLookupRow = { + citation: string | null; + status: string; + message: string | null; + clusters: CitationLookupCluster[]; +}; + +type CitationLookupPayload = { + citationsSubmitted?: number; + citationLinks: { + clusterId: number | null; + citation: string | null; + caseName: string | null; + court: string | null; + dateFiled: string | null; + pdfUrl: string | null; + url: string | null; + markdown: string; + }[]; + results: CitationLookupRow[]; + source?: string; +}; + +function buildCitationLinks(results: CitationLookupRow[]) { + return results.flatMap((result) => + result.clusters.flatMap((cluster) => { + if (!cluster.url) return []; + const label = [cluster.caseName, result.citation] + .filter(Boolean) + .join(", "); + return [ + { + clusterId: cluster.id, + citation: result.citation, + caseName: cluster.caseName, + court: cluster.court, + dateFiled: cluster.dateFiled, + pdfUrl: cluster.pdfUrl, + url: cluster.url, + markdown: `[${label || cluster.url}](${cluster.url})`, + }, + ]; + }), + ); +} + +function courtlistenerApiTokenAvailable(apiToken?: string | null) { + return !!(apiToken?.trim() || process.env.COURTLISTENER_API_TOKEN?.trim()); +} + async function getBulkCitationLookup(args: { db?: ServerSupabase; citations: string[]; -}) { - if (!args.db || !courtlistenerBulkDataEnabled()) return null; + allowPartial?: boolean; +}): Promise { const parsed = args.citations.map((citation) => ({ citation, parts: parseCitationParts(citation), })); - if (!parsed.length || parsed.some((row) => !row.parts)) return null; + devLog("[courtlistener/bulk-citation-lookup] candidates", { + enabled: courtlistenerBulkDataEnabled(), + hasDb: !!args.db, + allowPartial: !!args.allowPartial, + count: parsed.length, + candidates: parsed.map((row) => ({ + citation: row.citation, + parsed: row.parts + ? { + volume: row.parts.volume, + reporter: row.parts.reporter, + page: row.parts.page, + } + : null, + })), + }); + if (!args.db || !courtlistenerBulkDataEnabled()) return null; + if (!parsed.length) return null; + if (!args.allowPartial && parsed.some((row) => !row.parts)) { + devLog("[courtlistener/bulk-citation-lookup] skipped", { + reason: "unparseable_candidate", + unparseable: parsed + .filter((row) => !row.parts) + .map((row) => row.citation), + }); + return null; + } - const results: { - citation: string | null; - status: string; - message: string | null; - clusters: ReturnType[]; - }[] = []; + const results: CitationLookupRow[] = []; for (const row of parsed) { const parts = row.parts; - if (!parts) return null; + if (!parts) { + devLog("[courtlistener/bulk-citation-lookup] skipped candidate", { + citation: row.citation, + reason: "unparseable_candidate", + }); + if (!args.allowPartial) return null; + results.push({ + citation: row.citation, + status: "invalid", + message: "Citation could not be parsed for bulk lookup.", + clusters: [], + }); + continue; + } const verifiedCitation = citationPartsLabel(parts); - if (!verifiedCitation) return null; + if (!verifiedCitation) { + if (!args.allowPartial) return null; + results.push({ + citation: row.citation, + status: "invalid", + message: "Citation could not be normalized for bulk lookup.", + clusters: [], + }); + continue; + } + devLog("[courtlistener/bulk-citation-lookup] citation query", { + citation: row.citation, + volume: parts.volume, + reporter: parts.reporter, + page: parts.page, + }); const { data: citationRows, error } = await args.db .from("courtlistener_citation_index") .select("cluster_id, volume, reporter, page") @@ -520,7 +631,21 @@ async function getBulkCitationLookup(args: { .eq("reporter", parts.reporter) .eq("page", parts.page) .limit(20); - if (error) return null; + devLog("[courtlistener/bulk-citation-lookup] citation query result", { + citation: row.citation, + rowCount: citationRows?.length ?? 0, + error: error?.message ?? null, + }); + if (error) { + if (!args.allowPartial) return null; + results.push({ + citation: verifiedCitation, + status: "error", + message: error.message, + clusters: [], + }); + continue; + } const clusterIds = [ ...new Set( (citationRows ?? []) @@ -532,15 +657,43 @@ async function getBulkCitationLookup(args: { .filter((id) => Number.isFinite(id)), ), ]; - if (!clusterIds.length) return null; + if (!clusterIds.length) { + if (!args.allowPartial) return null; + results.push({ + citation: verifiedCitation, + status: "not_found", + message: "Citation was not found in the bulk citation index.", + clusters: [], + }); + continue; + } + devLog("[courtlistener/bulk-citation-lookup] cluster query", { + citation: row.citation, + clusterIds, + }); const { data: clusters, error: clusterError } = await args.db .from("courtlistener_opinion_cluster_index") .select( - "id, case_name, case_name_short, case_name_full, slug, date_filed, judges, filepath_pdf_harvard", + "id, case_name, case_name_short, case_name_full, slug, date_filed, filepath_pdf_harvard", ) .in("id", clusterIds); - if (clusterError) return null; + devLog("[courtlistener/bulk-citation-lookup] cluster query result", { + citation: row.citation, + requestedCount: clusterIds.length, + rowCount: clusters?.length ?? 0, + error: clusterError?.message ?? null, + }); + if (clusterError) { + if (!args.allowPartial) return null; + results.push({ + citation: verifiedCitation, + status: "error", + message: clusterError.message, + clusters: [], + }); + continue; + } const clustersById = new Map( (clusters ?? []) .map((cluster) => { @@ -567,7 +720,16 @@ async function getBulkCitationLookup(args: { (cluster): cluster is ReturnType => !!cluster && !!cluster.caseName, ); - if (matchedClusters.length !== clusterIds.length) return null; + if (matchedClusters.length !== clusterIds.length) { + if (!args.allowPartial) return null; + results.push({ + citation: verifiedCitation, + status: matchedClusters.length ? "partial" : "not_found", + message: "Some citation clusters were missing from the bulk cluster index.", + clusters: matchedClusters, + }); + continue; + } results.push({ citation: verifiedCitation, @@ -577,37 +739,62 @@ async function getBulkCitationLookup(args: { }); } - const citationLinks = results.flatMap((result) => - result.clusters.flatMap((cluster) => { - if (!cluster.url) return []; - const label = [cluster.caseName, result.citation] - .filter(Boolean) - .join(", "); - return [ - { - clusterId: cluster.id, - citation: result.citation, - caseName: cluster.caseName, - court: cluster.court, - dateFiled: cluster.dateFiled, - judges: cluster.judges, - pdfUrl: cluster.pdfUrl, - url: cluster.url, - markdown: `[${label || cluster.url}](${cluster.url})`, - }, - ]; - }), - ); - const payload = { citationsSubmitted: args.citations.length || undefined, - citationLinks, + citationLinks: buildCitationLinks(results), results, source: "bulk", }; return payload; } +async function fetchCourtlistenerCitationLookup(args: { + text: string; + citationsSubmitted?: number; + apiToken?: string | null; +}): Promise { + const body = new URLSearchParams(); + body.set("text", args.text.slice(0, 64000)); + const results = await courtlistenerFetch( + "/citation-lookup/", + { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body, + }, + args.apiToken, + ); + + const compactResults: CitationLookupRow[] = (Array.isArray(results) + ? results + : [] + ) + .map((item) => { + if (!item || typeof item !== "object") return null; + const row = item as JsonRecord; + return { + citation: + asString(row.citation) ?? + asString(row.normalized_citation) ?? + null, + status: asString(row.status) ?? String(row.status ?? "unknown"), + message: asString(row.message), + clusters: Array.isArray(row.clusters) + ? row.clusters.map(compactCluster) + : [], + }; + }) + .filter((row): row is CitationLookupRow => !!row); + + return { + citationsSubmitted: args.citationsSubmitted, + citationLinks: buildCitationLinks(compactResults), + results: compactResults, + }; +} + async function getBulkCourtlistenerCaseOpinions(args: { db?: ServerSupabase; clusterId: number; @@ -697,7 +884,7 @@ async function getBulkCourtlistenerCaseOpinions(args: { const { data: cluster, error } = await args.db .from("courtlistener_opinion_cluster_index") .select( - "id, case_name, case_name_short, case_name_full, slug, date_filed, judges, filepath_pdf_harvard", + "id, case_name, case_name_short, case_name_full, slug, date_filed, filepath_pdf_harvard", ) .eq("id", args.clusterId) .maybeSingle(); @@ -778,7 +965,6 @@ async function getBulkCourtlistenerCaseOpinions(args: { } export async function verifyCourtlistenerCitations(args: { - text?: string; citations?: string[]; db?: ServerSupabase; apiToken?: string | null; @@ -789,85 +975,80 @@ export async function verifyCourtlistenerCitations(args: { .filter(Boolean) .slice(0, 250) : []; - const text = - typeof args.text === "string" && args.text.trim() - ? args.text.trim() - : citations.join("\n"); - if (!text) { - return { error: "Provide text or at least one citation." }; + if (!citations.length) { + return { error: "Provide at least one citation or case name." }; } + const bulkCandidates = citations; const bulk = await getBulkCitationLookup({ db: args.db, - citations: citations.length - ? citations - : text.split(/\n+/).filter(Boolean), + citations: bulkCandidates, + allowPartial: true, }); - if (bulk) return bulk; - - const body = new URLSearchParams(); - body.set("text", text.slice(0, 64000)); - const results = await courtlistenerFetch( - "/citation-lookup/", - { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body, - }, - args.apiToken, - ); - - const compactResults = (Array.isArray(results) ? results : []).map( - (item) => { - if (!item || typeof item !== "object") return item; - const row = item as JsonRecord; - return { - citation: - asString(row.citation) ?? - asString(row.normalized_citation) ?? - null, - status: row.status ?? null, - message: asString(row.message), - clusters: Array.isArray(row.clusters) - ? row.clusters.map(compactCluster) - : [], - }; - }, - ); - const citationLinks = compactResults.flatMap((result) => { - if (!result || typeof result !== "object") return []; - const row = result as { - citation?: string | null; - clusters?: ReturnType[]; - }; - return (row.clusters ?? []).flatMap((cluster) => { - if (!cluster.url) return []; - const label = [cluster.caseName, row.citation] - .filter(Boolean) - .join(", "); - return [ - { - clusterId: cluster.id, - citation: row.citation ?? null, - caseName: cluster.caseName, - court: cluster.court, - dateFiled: cluster.dateFiled, - judges: cluster.judges, - pdfUrl: cluster.pdfUrl, - url: cluster.url, - markdown: `[${label || cluster.url}](${cluster.url})`, - }, - ]; - }); - }); - - return { + devLog("[courtlistener/bulk-citation-lookup] result", { + hit: !!bulk, citationsSubmitted: citations.length || undefined, - citationLinks, - results: compactResults, - }; + candidateCount: bulkCandidates.length, + resultCount: Array.isArray(bulk?.results) ? bulk.results.length : 0, + citationLinkCount: Array.isArray(bulk?.citationLinks) + ? bulk.citationLinks.length + : 0, + statuses: Array.isArray(bulk?.results) + ? bulk.results.map((result) => result.status) + : [], + source: bulk?.source ?? null, + }); + if (bulk) { + const apiFallbackInputs = + citations.length > 0 && courtlistenerApiTokenAvailable(args.apiToken) + ? bulk.results + .filter( + (result) => + result.status === "not_found" || + result.status === "invalid", + ) + .map((result) => result.citation) + .filter((citation): citation is string => !!citation) + : []; + if (!apiFallbackInputs.length) return bulk; + + devLog("[courtlistener/bulk-citation-lookup] api fallback", { + candidateCount: apiFallbackInputs.length, + candidates: apiFallbackInputs, + }); + try { + const apiFallback = await fetchCourtlistenerCitationLookup({ + text: apiFallbackInputs.join("\n"), + citationsSubmitted: apiFallbackInputs.length, + apiToken: args.apiToken, + }); + const fallbackRows = [...apiFallback.results]; + const mergedResults = bulk.results.flatMap((result) => { + if (result.status !== "not_found" && result.status !== "invalid") { + return [result]; + } + return [fallbackRows.shift() ?? result]; + }); + mergedResults.push(...fallbackRows); + return { + citationsSubmitted: bulk.citationsSubmitted, + citationLinks: buildCitationLinks(mergedResults), + results: mergedResults, + source: "bulk+api", + }; + } catch (err) { + devLog("[courtlistener/bulk-citation-lookup] api fallback failed", { + error: err instanceof Error ? err.message : String(err), + }); + return bulk; + } + } + + return fetchCourtlistenerCitationLookup({ + text: citations.join("\n"), + citationsSubmitted: citations.length || undefined, + apiToken: args.apiToken, + }); } export async function searchCourtlistenerCaseLaw(args: { diff --git a/backend/src/lib/legalSourcesTools/courtlistenerTools.ts b/backend/src/lib/legalSourcesTools/courtlistenerTools.ts index 09d2b2e..2acab22 100644 --- a/backend/src/lib/legalSourcesTools/courtlistenerTools.ts +++ b/backend/src/lib/legalSourcesTools/courtlistenerTools.ts @@ -59,7 +59,6 @@ export type CaseCitationEvent = { url: string; pdfUrl?: string | null; dateFiled?: string | null; - judges?: string | null; }; export const COURTLISTENER_TOOL_NAMES = { @@ -72,10 +71,9 @@ export const COURTLISTENER_TOOL_NAMES = { export const COURTLISTENER_SYSTEM_PROMPT = `LEGAL RESEARCH QUERIES: - When a user asks a question on US law, you are required to cite relevant case law in your answer. Always verify US case citations using the courtlistener_verify_citations tool. -- If the user gives case names or reporter citations, use courtlistener_verify_citations for those names/citations. -- CourtListener keyword/issue search is not available. Do not attempt to search CourtListener for new candidate cases by legal issue or keywords. Work only from cases/citations supplied by the user, cases found in the provided documents, or citations already present in the conversation. +- The courtlistener_verify_citations tool accepts only a citations array of clean reporter citations. Do not pass case names to this tool. Correct: {"citations":["467 U.S. 837","323 U.S. 134"]}. Incorrect: {"citations":["Chevron U.S.A. v. NRDC","Skidmore v. Swift"]}. If you only have case names and no reporter citations, do not call courtlistener_verify_citations for those names. - If any CourtListener tool call reports that a CourtListener rate limit was exceeded, or returns a 429/throttled/rate-limit error, do not make any further CourtListener API/search calls in that turn. Do not retry, verify more citations, fetch more cases, or run additional CourtListener searches; answer with the information already available and briefly state that CourtListener is rate limiting requests. -- For cases you may cite or materially rely on, follow this sequence: first use courtlistener_verify_citations for case names/citations, then use courtlistener_get_cases to fetch/cache the relevant case clusters, then use courtlistener_find_in_case to search targeted keywords in the cached opinions, and only if those keyword snippets are insufficient use courtlistener_read_case to read selected opinion text. +- For cases you may cite or materially rely on, follow this sequence when reporter citations are available: first use courtlistener_verify_citations with clean reporter citations, then use courtlistener_get_cases to fetch/cache the relevant case clusters, then use courtlistener_find_in_case to search targeted keywords in the cached opinions, and only if those keyword snippets are insufficient use courtlistener_read_case to read selected opinion text. - Only cite cases whose underlying opinion text, or at least the specific relevant opinion passages, has been supplied to you in this turn. courtlistener_get_cases only fetches and caches opinions; it does NOT place full opinion text in your context. It returns text-free opinion metadata so you can choose which opinion(s) matter. After courtlistener_get_cases, use courtlistener_find_in_case for targeted keyword or phrase lookup inside that cached case. If those snippets are not enough, use courtlistener_read_case to read only the specific already-fetched opinion(s) you need. courtlistener_find_in_case and courtlistener_read_case require the case to have been fetched first. - When a fetched case has multiple opinions, do not read all opinions by default. Choose the specific opinion_id or opinion_ids needed from the metadata or search hits. Prefer the lead/majority/controlling opinion when it is sufficient; read concurrences, dissents, or combined opinions only when they are necessary for the user's question. - When using courtlistener_find_in_case, search for terms that are 1-3 words long and actually likely to appear exactly as written in the opinion text. Do not use long sentence-like phrases. Run courtlistener_find_in_case no more than 3 times in a single assistant turn; if those searches are insufficient, read the smallest needed opinion text with courtlistener_read_case or answer with the available information. @@ -175,22 +173,18 @@ export const COURTLISTENER_TOOLS = [ function: { name: COURTLISTENER_TOOL_NAMES.verifyCitations, description: - "Verify legal case citations using CourtListener's citation lookup. Accepts raw text containing citations, or multiple citation strings. This returns citation metadata and clickable case refs; call courtlistener_get_cases only for matched cases that need full opinion text.", + "Verify legal case citations using CourtListener's citation lookup. Accepts only an array of clean reporter citations, not case names. Example: {\"citations\":[\"467 U.S. 837\",\"323 U.S. 134\"]}. This returns citation metadata and clickable case refs; call courtlistener_get_cases only for matched cases that need full opinion text.", parameters: { type: "object", properties: { - text: { - type: "string", - description: - "Raw text containing one or more legal citations. Max 64,000 characters sent to CourtListener.", - }, citations: { type: "array", items: { type: "string" }, description: - "Optional list of citation strings. Up to 250 will be joined into the request text field.", + "Required list of clean reporter citations only. Put each reporter citation in its own array item, e.g. [\"467 U.S. 837\", \"323 U.S. 134\"]. Do not include case names. Up to 250 items.", }, }, + required: ["citations"], }, }, }, diff --git a/backend/src/lib/llm/rawStreamLog.ts b/backend/src/lib/llm/rawStreamLog.ts index 9c08b13..013c762 100644 --- a/backend/src/lib/llm/rawStreamLog.ts +++ b/backend/src/lib/llm/rawStreamLog.ts @@ -5,12 +5,7 @@ export function logRawLlmStream(args: { label: string; payload: unknown; }) { - if ( - process.env.NODE_ENV === "production" && - process.env.LOG_RAW_LLM_STREAM !== "true" - ) { - return; - } + if (process.env.LOG_RAW_LLM_STREAM !== "true") return; console.log( `[raw-llm-stream:${args.provider}:${args.model}:iter-${args.iteration}] ${args.label}`, diff --git a/backend/src/lib/userApiKeys.ts b/backend/src/lib/userApiKeys.ts index 4975d9c..27f617c 100644 --- a/backend/src/lib/userApiKeys.ts +++ b/backend/src/lib/userApiKeys.ts @@ -30,23 +30,24 @@ const PROVIDERS: ApiKeyProvider[] = [ ]; function envApiKey(provider: ApiKeyProvider): string | null { - if (provider === "claude") { - return ( - process.env.ANTHROPIC_API_KEY?.trim() || - process.env.CLAUDE_API_KEY?.trim() || - null - ); + switch (provider) { + case "claude": + return ( + process.env.ANTHROPIC_API_KEY?.trim() || + process.env.CLAUDE_API_KEY?.trim() || + null + ); + case "gemini": + return process.env.GEMINI_API_KEY?.trim() || null; + case "openai": + return process.env.OPENAI_API_KEY?.trim() || null; + case "openrouter": + return process.env.OPENROUTER_API_KEY?.trim() || null; + case "courtlistener": + return process.env.COURTLISTENER_API_TOKEN?.trim() || null; + default: + return null; } - if (provider === "openai") { - return process.env.OPENAI_API_KEY?.trim() || null; - } - if (provider === "openrouter") { - return process.env.OPENROUTER_API_KEY?.trim() || null; - } - if (provider === "courtlistener") { - return process.env.COURTLISTENER_API_TOKEN?.trim() || null; - } - return process.env.GEMINI_API_KEY?.trim() || null; } export function hasEnvApiKey(provider: ApiKeyProvider): boolean { @@ -58,7 +59,7 @@ function encryptionKey(): Buffer { if (!secret) { throw new Error("USER_API_KEYS_ENCRYPTION_SECRET is not configured"); } - return crypto.createHash("sha256").update(secret).digest(); + return crypto.scryptSync(secret, "mike-user-api-keys-v1", 32); } function encrypt(value: string): Omit { diff --git a/backend/src/routes/caseLaw.ts b/backend/src/routes/caseLaw.ts index ef32078..4be3898 100644 --- a/backend/src/routes/caseLaw.ts +++ b/backend/src/routes/caseLaw.ts @@ -44,7 +44,7 @@ caseLawRouter.post("/case-opinions", async (req, res) => { clusterId, }); const db = createServerSupabase(); - const fetchKey = String(clusterId); + const fetchKey = `${userId}:${clusterId}`; let fetchPromise = sidepanelOpinionFetches.get(fetchKey); if (fetchPromise) { devLog("[case-law/case-opinions] joining in-flight fetch", { diff --git a/backend/src/routes/chat.ts b/backend/src/routes/chat.ts index 0062125..96b1fc7 100644 --- a/backend/src/routes/chat.ts +++ b/backend/src/routes/chat.ts @@ -7,6 +7,7 @@ import { enrichWithPriorEvents, buildWorkflowStore, AssistantStreamError, + buildCancelledAssistantMessage, extractAnnotations, isAbortError, runLLMStream, @@ -614,6 +615,28 @@ chatRouter.post("/", requireAuth, async (req, res) => { } catch (err) { if (isAbortError(err)) { devLog("[chat/stream] client aborted stream", { chatId }); + if (err instanceof AssistantStreamError) { + const partial = buildCancelledAssistantMessage({ + fullText: err.fullText, + events: err.events, + buildAnnotations: (fullText, events) => + extractAnnotations(fullText, docIndex, events), + }); + const { error: saveError } = await db.from("chat_messages").insert({ + chat_id: chatId, + role: "assistant", + content: partial.events.length ? partial.events : null, + annotations: partial.annotations.length + ? partial.annotations + : null, + }); + if (saveError) { + console.error( + "[chat/stream] failed to save aborted stream", + saveError, + ); + } + } return; } console.error("[chat/stream] error:", err); diff --git a/backend/src/routes/documents.ts b/backend/src/routes/documents.ts index 58c3cf5..9f1d970 100644 --- a/backend/src/routes/documents.ts +++ b/backend/src/routes/documents.ts @@ -423,6 +423,15 @@ documentsRouter.post( const sourceAccess = await ensureDocAccess(sourceDoc, userId, userEmail, db); if (!sourceAccess.ok) return void res.status(404).json({ detail: "Source document not found" }); + const willDeleteSource = + sourceDoc.project_id && + targetDoc.project_id && + sourceDoc.project_id === targetDoc.project_id; + if (willDeleteSource && !sourceAccess.isOwner) { + return void res.status(403).json({ + detail: "Only the source document owner can move it into a version.", + }); + } const targetActive = await loadActiveVersion(documentId, db); const targetType = targetActive?.file_type ?? ""; @@ -548,11 +557,7 @@ documentsRouter.post( .json({ detail: "Failed to update document current version." }); } - if ( - sourceDoc.project_id && - targetDoc.project_id && - sourceDoc.project_id === targetDoc.project_id - ) { + if (willDeleteSource) { const { error: deleteErr } = await deleteDocumentAndVersionFiles( db, sourceDocumentId, @@ -721,12 +726,21 @@ documentsRouter.post( .json({ detail: "Failed to record new version." }); } - await db + const { error: updateDocErr } = await db .from("documents") .update({ current_version_id: versionRow.id, }) .eq("id", documentId); + if (updateDocErr) { + console.error( + "[versions/upload] current version update failed", + updateDocErr, + ); + return void res + .status(500) + .json({ detail: "Failed to update document current version." }); + } res.status(201).json(versionRow); }, diff --git a/backend/src/routes/projectChat.ts b/backend/src/routes/projectChat.ts index d376d49..29a62d9 100644 --- a/backend/src/routes/projectChat.ts +++ b/backend/src/routes/projectChat.ts @@ -7,6 +7,7 @@ import { buildWorkflowStore, enrichWithPriorEvents, AssistantStreamError, + buildCancelledAssistantMessage, extractAnnotations, isAbortError, runLLMStream, @@ -199,6 +200,28 @@ projectChatRouter.post("/", requireAuth, async (req, res) => { console.log("[project-chat/stream] client aborted stream", { chatId, }); + if (err instanceof AssistantStreamError) { + const partial = buildCancelledAssistantMessage({ + fullText: err.fullText, + events: err.events, + buildAnnotations: (fullText, events) => + extractAnnotations(fullText, docIndex, events), + }); + const { error: saveError } = await db.from("chat_messages").insert({ + chat_id: chatId, + role: "assistant", + content: partial.events.length ? partial.events : null, + annotations: partial.annotations.length + ? partial.annotations + : null, + }); + if (saveError) { + console.error( + "[project-chat/stream] failed to save aborted stream", + saveError, + ); + } + } return; } console.error("[project-chat/stream] error:", err); diff --git a/backend/src/routes/projects.ts b/backend/src/routes/projects.ts index f470d21..0b62b8e 100644 --- a/backend/src/routes/projects.ts +++ b/backend/src/routes/projects.ts @@ -28,6 +28,37 @@ function normalizeDocumentFilename(nextName: unknown, currentName: string) { return `${trimmed}${ext}`; } +async function deleteProjectDocumentsAndVersionFiles( + db: ReturnType, + projectId: string, + documentIds: string[], +) { + if (documentIds.length === 0) return null; + const { data: versions, error: versionsError } = await db + .from("document_versions") + .select("storage_path, pdf_storage_path") + .in("document_id", documentIds); + if (versionsError) return versionsError; + + const paths = new Set(); + for (const v of versions ?? []) { + if (typeof v.storage_path === "string" && v.storage_path.length > 0) { + paths.add(v.storage_path); + } + if (typeof v.pdf_storage_path === "string" && v.pdf_storage_path.length > 0) { + paths.add(v.pdf_storage_path); + } + } + await Promise.all([...paths].map((p) => deleteFile(p).catch(() => {}))); + + const { error } = await db + .from("documents") + .delete() + .eq("project_id", projectId) + .in("id", documentIds); + return error ?? null; +} + // GET /projects projectsRouter.get("/", requireAuth, async (req, res) => { const userId = res.locals.userId as string; @@ -710,11 +741,48 @@ projectsRouter.delete("/:projectId/folders/:folderId", requireAuth, async (req, const access = await checkProjectAccess(projectId, userId, userEmail, db); if (!access.ok) return void res.status(404).json({ detail: "Project not found" }); - const folder = await loadProjectFolder(db, projectId, folderId); - if (!folder) return void res.status(404).json({ detail: "Folder not found" }); + const { data: allFolders, error: foldersError } = await db + .from("project_subfolders") + .select("id, parent_folder_id") + .eq("project_id", projectId); + if (foldersError) + return void res.status(500).json({ detail: foldersError.message }); + if (!(allFolders ?? []).some((f) => f.id === folderId)) + return void res.status(404).json({ detail: "Folder not found" }); - // Move direct documents to root before cascade-deleting subfolders - await db.from("documents").update({ folder_id: null }).eq("folder_id", folderId).eq("project_id", projectId); + const childrenByParent = new Map(); + for (const f of allFolders ?? []) { + const parentId = f.parent_folder_id as string | null; + if (!parentId) continue; + const children = childrenByParent.get(parentId) ?? []; + children.push(f.id as string); + childrenByParent.set(parentId, children); + } + + const folderIds = new Set(); + const stack = [folderId]; + while (stack.length > 0) { + const id = stack.pop()!; + if (folderIds.has(id)) continue; + folderIds.add(id); + stack.push(...(childrenByParent.get(id) ?? [])); + } + + const { data: docs, error: docsError } = await db + .from("documents") + .select("id") + .eq("project_id", projectId) + .in("folder_id", [...folderIds]); + if (docsError) return void res.status(500).json({ detail: docsError.message }); + + const docIds = (docs ?? []).map((d) => d.id as string); + const deleteDocsError = await deleteProjectDocumentsAndVersionFiles( + db, + projectId, + docIds, + ); + if (deleteDocsError) + return void res.status(500).json({ detail: deleteDocsError.message }); const { error } = await db.from("project_subfolders") .delete().eq("id", folderId).eq("project_id", projectId); diff --git a/backend/src/routes/tabular.ts b/backend/src/routes/tabular.ts index 5bc0049..907c4f5 100644 --- a/backend/src/routes/tabular.ts +++ b/backend/src/routes/tabular.ts @@ -9,6 +9,7 @@ import { import { normalizeDocxZipPaths } from "../lib/convert"; import { AssistantStreamError, + buildCancelledAssistantMessage, isAbortError, runLLMStream, stripTransientAssistantEvents, @@ -480,8 +481,6 @@ tabularRouter.patch("/:reviewId", requireAuth, async (req, res) => { const { reviewId } = req.params; const updates: Record = {}; if (req.body.title != null) updates.title = req.body.title; - if (req.body.columns_config != null) - updates.columns_config = req.body.columns_config; const projectIdUpdateProvided = req.body.project_id !== undefined; const projectIdUpdate = req.body.project_id === null @@ -534,6 +533,14 @@ tabularRouter.patch("/:reviewId", requireAuth, async (req, res) => { ); if (!access.ok) return void res.status(404).json({ detail: "Review not found" }); + if (req.body.columns_config != null) { + if (!access.isOwner) { + return void res.status(403).json({ + detail: "Only the review owner can change columns", + }); + } + updates.columns_config = req.body.columns_config; + } if (sharedWithUpdate !== undefined) { if (!access.isOwner) return void res @@ -1365,8 +1372,9 @@ tabularRouter.post("/:reviewId/chat", requireAuth, async (req, res) => { messages.filter((m) => m.role === "user").length === 1; if (chatId) { - // Either chat owner OR any project member of the parent review can - // continue the chat. We've already verified review access above. + // The chat must belong to this exact review and to the requester. + // Review access alone is not enough: otherwise a user could reuse one + // of their chats from a different review in this route. const { data: existing } = await db .from("tabular_review_chats") .select("id, title, review_id, user_id") @@ -1374,7 +1382,8 @@ tabularRouter.post("/:reviewId/chat", requireAuth, async (req, res) => { .single(); const canUse = !!existing && - (existing.review_id === reviewId || existing.user_id === userId); + existing.review_id === reviewId && + existing.user_id === userId; if (!canUse || !existing) chatId = null; else chatTitle = existing.title; } @@ -1479,6 +1488,34 @@ tabularRouter.post("/:reviewId/chat", requireAuth, async (req, res) => { } catch (err) { if (isAbortError(err)) { console.log("[tabular/chat] client aborted stream", { chatId }); + if (chatId && err instanceof AssistantStreamError) { + const partial = buildCancelledAssistantMessage({ + fullText: err.fullText, + events: err.events, + buildAnnotations: (fullText) => + extractTabularAnnotations(fullText, tabularStore), + }); + const { error: saveError } = await db + .from("tabular_review_chat_messages") + .insert({ + chat_id: chatId, + role: "assistant", + content: partial.events.length ? partial.events : null, + annotations: partial.annotations.length + ? partial.annotations + : null, + }); + if (saveError) { + console.error( + "[tabular/chat] failed to save aborted stream", + saveError, + ); + } + await db + .from("tabular_review_chats") + .update({ updated_at: new Date().toISOString() }) + .eq("id", chatId); + } return; } console.error("[tabular/chat] error", err); diff --git a/frontend/src/app/components/assistant/CaseLawPanel.tsx b/frontend/src/app/components/assistant/CaseLawPanel.tsx index 2583c36..28d8aea 100644 --- a/frontend/src/app/components/assistant/CaseLawPanel.tsx +++ b/frontend/src/app/components/assistant/CaseLawPanel.tsx @@ -40,7 +40,6 @@ export type CaseTab = { url: string | null; dateFiled: string | null; pdfUrl: string | null; - judges: string | null; quotes?: CaseCitationQuote[]; opinions?: CaseLawOpinion[]; }; @@ -281,7 +280,6 @@ export function CaseLawPanel({ const citation = tab.citation; const courtlistenerUrl = tab.url; const filedDate = formatCaseDate(tab.dateFiled); - const judges = tab.judges?.trim() || null; const orderedOpinions = orderOpinions(opinions); const activeOpinion = opinions.find( (opinion) => opinion.opinionId === activeOpinionId, @@ -377,13 +375,9 @@ export function CaseLawPanel({ , {citation} )} - {filedDate || judges ? ( + {filedDate ? (

- {filedDate && <>Date: {filedDate}} - {filedDate && judges && ( - | - )} - {judges && <>Judges: {judges}} + Date: {filedDate}

) : null} diff --git a/frontend/src/app/components/assistant/ChatView.tsx b/frontend/src/app/components/assistant/ChatView.tsx index 3c61ac8..5c4a577 100644 --- a/frontend/src/app/components/assistant/ChatView.tsx +++ b/frontend/src/app/components/assistant/ChatView.tsx @@ -214,7 +214,6 @@ export function ChatView({ url: citation.url ?? null, dateFiled: citation.dateFiled ?? null, pdfUrl: citation.pdfUrl ?? null, - judges: citation.judges ?? null, quotes: showQuotes ? citation.quotes : undefined, opinions: undefined, }); @@ -259,7 +258,6 @@ export function ChatView({ url: citation.url, dateFiled: citation.dateFiled ?? null, pdfUrl: citation.pdfUrl ?? null, - judges: citation.judges ?? null, quotes: undefined, opinions: citation.case?.opinions, }); diff --git a/frontend/src/app/components/projects/DocumentSidePanel.tsx b/frontend/src/app/components/projects/DocumentSidePanel.tsx index 584567e..05b9958 100644 --- a/frontend/src/app/components/projects/DocumentSidePanel.tsx +++ b/frontend/src/app/components/projects/DocumentSidePanel.tsx @@ -14,7 +14,6 @@ import { } from "lucide-react"; import { ConfirmPopup } from "@/app/components/shared/ConfirmPopup"; import { DocView } from "@/app/components/shared/DocView"; -import { DocFileIcon } from "@/app/components/shared/FileDirectory"; import { WarningPopup } from "@/app/components/shared/WarningPopup"; import type { Document } from "@/app/components/shared/types"; import type { DocumentVersion } from "@/app/lib/mikeApi"; @@ -27,6 +26,10 @@ const MIN_DATA_COLUMN_WIDTH = 280; const DEFAULT_DATA_COLUMN_WIDTH = 340; const RESIZER_WIDTH = 6; const MAX_PANEL_WIDTH = 1180; +const primaryGlassButtonClass = + "inline-flex h-8 items-center justify-center gap-1.5 rounded-full border border-gray-700/40 bg-gray-950/88 px-3 text-xs font-medium text-white shadow-[0_3px_9px_rgba(15,23,42,0.16),inset_0_1px_0_rgba(255,255,255,0.22),inset_0_-4px_9px_rgba(15,23,42,0.2)] backdrop-blur-xl transition-all hover:bg-gray-900/90 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"; +const dangerGlassButtonClass = + "inline-flex h-8 items-center justify-center gap-1.5 rounded-full border border-red-700/35 bg-red-600/90 px-3 text-xs font-medium text-white shadow-[0_3px_9px_rgba(127,29,29,0.16),inset_0_1px_0_rgba(255,255,255,0.22),inset_0_-4px_9px_rgba(127,29,29,0.18)] backdrop-blur-xl transition-all hover:bg-red-600 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"; interface DocumentSidePanelProps { doc: Document | null; @@ -48,10 +51,7 @@ interface DocumentSidePanelProps { versionId: string, filename: string, ) => Promise | void; - onDeleteVersion: ( - docId: string, - versionId: string, - ) => Promise | void; + onDeleteVersion: (docId: string, versionId: string) => Promise | void; onUploadNewVersion: ( doc: Document, file: File, @@ -69,7 +69,6 @@ export function DocumentSidePanel({ onClose, onLoadVersions, onSelectVersion, - onDownloadDocument, onDownloadVersion, onRenameVersion, onDeleteVersion, @@ -84,7 +83,9 @@ export function DocumentSidePanel({ const [savingName, setSavingName] = useState(false); const [nameError, setNameError] = useState(null); const [extensionWarningOpen, setExtensionWarningOpen] = useState(false); - const [deletingVersion, setDeletingVersion] = useState(false); + const [deletingVersionId, setDeletingVersionId] = useState( + null, + ); const [deletingDocument, setDeletingDocument] = useState(false); const [confirmDeleteDocumentOpen, setConfirmDeleteDocumentOpen] = useState(false); @@ -142,8 +143,7 @@ export function DocumentSidePanel({ orderedVersions[0] ?? null; const selectedVersionId = selectedVersion?.id ?? versionId ?? null; - const selectedFilename = - selectedVersion?.filename?.trim() || doc.filename; + const selectedFilename = selectedVersion?.filename?.trim() || doc.filename; const selectedFileType = selectedVersion != null ? fileTypeForVersion(selectedVersion, doc.file_type) @@ -207,15 +207,14 @@ export function DocumentSidePanel({ } } - async function handleDeleteSelectedVersion() { - if (!selectedVersionId) return; - setDeletingVersion(true); + async function handleDeleteVersion(versionIdToDelete: string) { + setDeletingVersionId(versionIdToDelete); try { - await onDeleteVersion(documentId, selectedVersionId); + await onDeleteVersion(documentId, versionIdToDelete); } catch (err) { console.error("delete version failed", err); } finally { - setDeletingVersion(false); + setDeletingVersionId(null); } } @@ -261,7 +260,8 @@ export function DocumentSidePanel({ panelWidth - MIN_DOC_COLUMN_WIDTH - RESIZER_WIDTH, ); const nextWidth = - dragStartDataWidth.current + (dragStartX.current - event.clientX); + dragStartDataWidth.current + + (dragStartX.current - event.clientX); setDataColumnWidth( Math.min( maxDataWidth, @@ -290,7 +290,8 @@ export function DocumentSidePanel({ const handleMouseMove = (event: MouseEvent) => { const nextWidth = - dragStartPanelWidth.current + (dragStartX.current - event.clientX); + dragStartPanelWidth.current + + (dragStartX.current - event.clientX); setPanelWidth(clampPanelWidth(nextWidth, dataColumnWidth)); }; @@ -383,13 +384,13 @@ export function DocumentSidePanel({