Sync CourtListener verification and document safety updates

- Refine CourtListener citation verification, bulk lookup logging, and API fallback behavior
- Persist cancelled chat stream output and render cancellation as the final assistant message
- Add document/version deletion safety fixes and shared warning/modal UI updates
- Sync document panel, case law panel, and response UI styling refinements
- Harden OSS sync script to preserve local env, dependency, and generated files
This commit is contained in:
willchen96 2026-06-09 01:46:58 +08:00
parent 44e868eb42
commit f32a194b33
24 changed files with 2494 additions and 1222 deletions

View file

@ -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;

View file

@ -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
-- ---------------------------------------------------------------------------

View file

@ -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 <CITATIONS> 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)
// ---------------------------------------------------------------------------

View file

@ -90,6 +90,7 @@ async function courtlistenerFetch<T>(
});
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<typeof compactOpinion>[] = [];
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<JsonRecord>(
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<typeof compactCluster>
| ReturnType<typeof compactBulkCluster>;
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<CitationLookupPayload | null> {
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<typeof compactBulkCluster>[];
}[] = [];
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<typeof compactBulkCluster> =>
!!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<CitationLookupPayload> {
const body = new URLSearchParams();
body.set("text", args.text.slice(0, 64000));
const results = await courtlistenerFetch<unknown[]>(
"/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<unknown[]>(
"/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<typeof compactCluster>[];
};
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: {

View file

@ -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"],
},
},
},

View file

@ -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}`,

View file

@ -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<EncryptedKeyRow, "provider"> {

View file

@ -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", {

View file

@ -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);

View file

@ -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);
},

View file

@ -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);

View file

@ -28,6 +28,37 @@ function normalizeDocumentFilename(nextName: unknown, currentName: string) {
return `${trimmed}${ext}`;
}
async function deleteProjectDocumentsAndVersionFiles(
db: ReturnType<typeof createServerSupabase>,
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<string>();
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<string, string[]>();
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<string>();
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);

View file

@ -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<string, unknown> = {};
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);

View file

@ -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({
<span className="text-gray-500">, {citation}</span>
)}
</h2>
{filedDate || judges ? (
{filedDate ? (
<p className="mt-1 font-serif text-sm text-gray-600">
{filedDate && <>Date: {filedDate}</>}
{filedDate && judges && (
<span className="mx-1.5 text-gray-300">|</span>
)}
{judges && <>Judges: {judges}</>}
Date: {filedDate}
</p>
) : null}
</div>

View file

@ -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,
});

View file

@ -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> | void;
onDeleteVersion: (
docId: string,
versionId: string,
) => Promise<void> | void;
onDeleteVersion: (docId: string, versionId: string) => Promise<void> | 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<string | null>(null);
const [extensionWarningOpen, setExtensionWarningOpen] = useState(false);
const [deletingVersion, setDeletingVersion] = useState(false);
const [deletingVersionId, setDeletingVersionId] = useState<string | null>(
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({
<aside
className={cn(
"flex min-h-0 flex-col",
"bg-white/25",
"mb-3 ml-2 mr-3 flex min-h-0 flex-col overflow-hidden rounded-xl",
"border border-white/70 bg-white/55 shadow-[0_3px_9px_rgba(15,23,42,0.06),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-4px_9px_rgba(255,255,255,0.08)] backdrop-blur-2xl",
)}
>
<div
className={cn(
"shrink-0 px-4 pb-3 pt-0",
"shrink-0 px-4 py-3",
"border-b border-white/60",
)}
>
@ -400,28 +401,30 @@ export function DocumentSidePanel({
{editingName ? (
<div className="space-y-1.5">
<div className="flex min-h-6 items-center gap-2">
<input
value={nameDraft}
onChange={(e) => {
setNameDraft(e.target.value);
setNameError(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void handleSaveName();
}
if (e.key === "Escape") {
setEditingName(false);
<input
value={nameDraft}
onChange={(e) => {
setNameDraft(e.target.value);
setNameError(null);
}
}}
className="h-6 min-w-0 flex-1 border-0 border-b border-gray-300 bg-transparent px-0 text-xs leading-6 text-gray-900 outline-none transition-colors focus:border-gray-500"
autoFocus
/>
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void handleSaveName();
}
if (e.key === "Escape") {
setEditingName(false);
setNameError(null);
}
}}
className="h-6 min-w-0 flex-1 border-0 border-b border-gray-300 bg-transparent px-0 text-xs leading-6 text-gray-900 outline-none transition-colors focus:border-gray-500"
autoFocus
/>
<button
type="button"
onClick={() => void handleSaveName()}
onClick={() =>
void handleSaveName()
}
disabled={savingName}
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-gray-500 transition-colors hover:bg-white/65 hover:text-gray-900 disabled:cursor-not-allowed disabled:opacity-40"
title="Save name"
@ -465,96 +468,57 @@ export function DocumentSidePanel({
<div className="mb-3 text-xs font-medium text-gray-900">
Document Data
</div>
<div className="space-y-1.5">
<DataRow label="Type" value={selectedFileType ?? "—"} />
<DataRow
label="Size"
value={
selectedSizeBytes != null
? formatBytes(selectedSizeBytes)
: "—"
}
/>
<DataRow
label="Version"
value={
selectedVersionNumber != null
? String(selectedVersionNumber)
: "—"
}
/>
<DataRow
label="Uploaded"
value={
selectedUploadedAt
? formatDate(selectedUploadedAt)
: "—"
}
/>
{selectedPageCount != null && (
<div className="rounded-xl bg-gray-100/70 px-3 py-3">
<div className="space-y-1.5">
<DataRow
label="Pages"
value={String(selectedPageCount)}
label="Type"
value={selectedFileType ?? "—"}
/>
)}
</div>
<div className="mt-4 flex items-center justify-between gap-2">
<button
type="button"
onClick={() =>
void handleDeleteSelectedVersion()
}
disabled={
!selectedVersionId ||
versions.length <= 1 ||
deletingVersion
}
className={cn(
"inline-flex items-center gap-1.5 rounded-lg border border-gray-300/80 bg-white/65 px-3 py-2 text-xs font-medium text-red-600 transition-colors hover:border-red-200 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-40",
<DataRow
label="Size"
value={
selectedSizeBytes != null
? formatBytes(selectedSizeBytes)
: "—"
}
/>
<DataRow
label="Version"
value={
selectedVersionNumber != null
? String(selectedVersionNumber)
: "—"
}
/>
<DataRow
label="Uploaded"
value={
selectedUploadedAt
? formatDate(selectedUploadedAt)
: "—"
}
/>
{selectedPageCount != null && (
<DataRow
label="Pages"
value={String(selectedPageCount)}
/>
)}
>
{deletingVersion ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
Delete version
</button>
<button
type="button"
onClick={() =>
selectedVersionId
? void onDownloadVersion(
doc.id,
selectedVersionId,
selectedFilename,
)
: void onDownloadDocument(doc.id)
}
className="inline-flex items-center gap-1.5 rounded-lg border border-gray-300/80 bg-white/65 px-3 py-2 text-xs font-medium text-gray-700 transition-colors hover:border-gray-400 hover:bg-white hover:text-gray-900"
>
<Download className="h-3.5 w-3.5" />
Download
</button>
</div>
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col px-4 pb-3 pt-0">
<div className="mb-2 text-xs font-medium text-gray-900">
Versions
</div>
<div
className={cn(
"flex min-h-0 flex-1 flex-col overflow-visible rounded-xl",
"border border-white/60 bg-white/35 shadow-[inset_0_1px_0_rgba(255,255,255,0.8)]",
"bg-gray-100 px-2",
)}
>
<div
className={cn(
"shrink-0 py-2 text-xs font-medium text-gray-900",
"border-b border-white/60",
)}
>
Versions
</div>
<div className="-mx-2 min-h-0 flex-1 overflow-y-auto px-2 py-2">
<div className="min-h-0 flex-1 overflow-y-auto py-2">
{versionsLoading && versions.length === 0 ? (
<div className="flex items-center gap-2 py-2 text-xs text-gray-400">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
@ -565,59 +529,136 @@ export function DocumentSidePanel({
No version history.
</div>
) : (
<div className="space-y-1">
<div className="space-y-1.5">
{orderedVersions.map((version) => {
const title =
versionTitleFor(version);
const filename =
versionFilenameFor(version);
const selected =
selectedVersionId === version.id;
const fileType =
fileTypeForVersion(
version,
doc.file_type,
);
selectedVersionId ===
version.id;
const versionDeleting =
deletingVersionId ===
version.id;
const fileType = fileTypeForVersion(
version,
doc.file_type,
);
const typeLabel =
fileType === "pdf"
? "PDF"
: "DOCX";
return (
<button
<div
key={version.id}
type="button"
role="button"
tabIndex={0}
onClick={() =>
onSelectVersion(
version.id,
filename,
)
}
className={cn(
"group -mx-2 flex w-[calc(100%+1rem)] items-center gap-2 rounded-lg px-2 py-2 text-left transition-colors",
selected
? "bg-gray-100"
: "hover:bg-white/55",
)}
onKeyDown={(event) => {
if (
event.key !==
"Enter" &&
event.key !== " "
) return;
event.preventDefault();
onSelectVersion(
version.id,
filename,
);
}}
className="group relative flex w-full cursor-pointer flex-col overflow-hidden rounded-lg border border-white/70 bg-white px-3 py-2 shadow-[0_1px_4px_rgba(15,23,42,0.045),inset_0_1px_0_rgba(255,255,255,0.72)] backdrop-blur-xl transition-all hover:bg-white"
>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<DocFileIcon
fileType={
fileType
}
/>
<div className="min-w-0 flex-1 truncate text-xs font-medium text-gray-800">
{title}
</div>
{selected && (
<span className="absolute inset-y-0 left-0 w-[3px] bg-blue-500" />
)}
<div className="flex min-w-0 items-center gap-2">
<div
className={cn(
"min-w-0 flex-1 truncate text-xs font-medium text-gray-800",
)}
>
{title}
</div>
<div className="truncate pl-[22px] text-[11px] text-gray-400">
{filename}
</div>
<div className="truncate pl-[22px] text-[11px] text-gray-400">
<span
className={cn(
"shrink-0 text-[10px] font-semibold tracking-normal",
typeLabel ===
"PDF"
? "text-red-600"
: "text-blue-600",
)}
>
{typeLabel}
</span>
</div>
<div className="truncate text-[11px] text-gray-400">
{filename}
</div>
<div className="flex min-w-0 items-center gap-2">
<div className="min-w-0 flex-1 truncate text-[11px] text-gray-400">
{version.created_at
? new Date(
version.created_at,
).toLocaleString()
: "—"}
</div>
<div
className={cn(
"flex h-5 shrink-0 items-center gap-0.5 transition-opacity",
selected
? "opacity-100"
: "opacity-0 group-hover:opacity-100 group-focus-within:opacity-100",
)}
>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
void onDownloadVersion(
doc.id,
version.id,
filename,
);
}}
className="inline-flex h-5 w-5 items-center justify-center rounded-full text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900"
aria-label={`Download ${title}`}
title="Download version"
>
<Download className="h-3 w-3" />
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
void handleDeleteVersion(
version.id,
);
}}
disabled={
versions.length <=
1 ||
deletingVersionId !=
null
}
className="inline-flex h-5 w-5 items-center justify-center rounded-full text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-40"
aria-label={`Delete ${title}`}
title="Delete version"
>
{versionDeleting ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Trash2 className="h-3 w-3" />
)}
</button>
</div>
</div>
</button>
</div>
);
})}
</div>
@ -650,12 +691,12 @@ export function DocumentSidePanel({
type="button"
onClick={requestDeleteDocument}
disabled={deletingDocument}
className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-gray-300/80 bg-white/35 px-3 text-xs font-medium text-red-600 transition-colors hover:border-red-200 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-40"
className={dangerGlassButtonClass}
>
{deletingDocument ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
<Trash2 className="h-3.5 w-3.5 shrink-0" />
)}
Delete
</button>
@ -663,12 +704,12 @@ export function DocumentSidePanel({
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-gray-300/80 bg-white/35 px-3 text-xs font-medium text-gray-800 transition-colors hover:border-gray-400 hover:bg-white/60 disabled:cursor-not-allowed disabled:opacity-40"
className={primaryGlassButtonClass}
>
{uploading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" />
) : (
<Upload className="h-3.5 w-3.5" />
<Upload className="h-3.5 w-3.5 shrink-0" />
)}
Upload new version
</button>
@ -742,10 +783,7 @@ function versionFilenameFor(version: DocumentVersion) {
return version.source === "upload" ? "Original" : "—";
}
function fileTypeForVersion(
version: DocumentVersion,
fallback: string | null,
) {
function fileTypeForVersion(version: DocumentVersion, fallback: string | null) {
const name = version.filename?.trim().toLowerCase() ?? "";
if (name.endsWith(".pdf")) return "pdf";
if (name.endsWith(".doc") || name.endsWith(".docx")) return "docx";

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,7 @@
import { createPortal } from "react-dom";
import type { ReactNode } from "react";
import { Loader2 } from "lucide-react";
import { Loader2, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
type ConfirmStatus = "idle" | "loading" | "complete";
@ -37,14 +37,20 @@ export function ConfirmPopup({
const resolvedConfirmDisabled = confirmDisabled || confirmStatus !== "idle";
const normalizedConfirmLabel =
typeof confirmLabel === "string" ? confirmLabel : "Confirm";
const isDeleteAction = normalizedConfirmLabel.toLowerCase() === "delete";
const resolvedConfirmLabel =
confirmStatus === "loading" ? (
<span className="inline-flex items-center gap-1.5">
<Loader2 className="h-3 w-3 animate-spin" />
<span className="inline-flex h-full items-center gap-1.5">
<Loader2 className="h-3 w-3 shrink-0 animate-spin" />
{progressiveLabel(normalizedConfirmLabel)}
</span>
) : confirmStatus === "complete" ? (
completedLabel(normalizedConfirmLabel)
) : isDeleteAction ? (
<span className="inline-flex h-full items-center gap-1.5">
<Trash2 className="h-3 w-3 shrink-0" />
{confirmLabel}
</span>
) : (
confirmLabel
);
@ -53,17 +59,19 @@ export function ConfirmPopup({
<div className="pointer-events-none fixed inset-x-0 bottom-5 z-[230] flex justify-center px-4">
<div
className={cn(
"pointer-events-auto w-[min(92vw,520px)] rounded-2xl border border-white/70 bg-white/58 px-4 py-3 text-sm shadow-[0_8px_24px_rgba(15,23,42,0.13),inset_0_1px_0_rgba(255,255,255,0.92),inset_0_-10px_24px_rgba(255,255,255,0.2)] backdrop-blur-2xl",
"pointer-events-auto w-[min(92vw,520px)] rounded-2xl border border-white/70 bg-white px-4 py-3 text-sm shadow-[0_4px_14px_rgba(15,23,42,0.08),inset_0_1px_0_rgba(255,255,255,0.92)] backdrop-blur-2xl",
className,
)}
>
{title && (
<div className="text-sm font-medium text-gray-950">
<div className="text-sm font-medium text-gray-950 mb-3">
{title}
</div>
)}
{message && (
<div className={cn("text-xs text-gray-700", title && "mt-1")}>
<div
className={cn("text-xs text-gray-700", title && "mt-1")}
>
{message}
</div>
)}
@ -71,7 +79,7 @@ export function ConfirmPopup({
<button
type="button"
onClick={onCancel}
className="rounded-full px-3 py-1.5 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-100"
className="px-3 py-1.5 text-xs font-medium text-gray-700 transition-colors hover:text-gray-950"
>
{cancelLabel}
</button>
@ -79,7 +87,12 @@ export function ConfirmPopup({
type="button"
onClick={onConfirm}
disabled={resolvedConfirmDisabled}
className="rounded-full bg-gray-950 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-40"
className={cn(
"inline-flex h-7 items-center justify-center rounded-full px-3.5 text-xs font-medium leading-none text-white backdrop-blur-xl transition-all active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100",
isDeleteAction
? "border border-red-700/35 bg-red-600/90 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)] hover:bg-red-600"
: "border border-gray-700/40 bg-gray-950/88 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)] hover:bg-gray-900/90",
)}
aria-busy={confirmBusy}
>
{resolvedConfirmLabel}

View file

@ -77,9 +77,9 @@ export function Modal({
>
<div
className={cn(
"w-full rounded-2xl shadow-2xl flex h-[600px] flex-col",
"w-full rounded-2xl flex h-[600px] flex-col",
sizeClassName[size],
"border border-white/70 bg-white/80 shadow-[0_24px_80px_rgba(15,23,42,0.18)] backdrop-blur-2xl",
"border border-white/70 bg-white/94 shadow-[0_12px_36px_rgba(15,23,42,0.1)] backdrop-blur-2xl",
className,
)}
onClick={(e) => e.stopPropagation()}
@ -123,7 +123,7 @@ export function Modal({
{hasFooter && (
<div
className={cn(
"flex items-center gap-3 px-4 py-3",
"flex items-center gap-3 p-4",
secondaryAction || footerInfo
? "justify-between"
: "justify-end",
@ -181,14 +181,14 @@ function ModalActionButton({
return (
<button
className={cn(
"inline-flex items-center justify-center gap-1.5 rounded-lg px-4 py-1.5 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40",
"inline-flex items-center justify-center gap-1.5 px-4 py-1.5 text-sm font-medium transition-all disabled:cursor-not-allowed disabled:opacity-40",
variant === "primary" &&
"bg-gray-900 text-white hover:bg-gray-700",
variant === "secondary" && "text-gray-600 hover:bg-gray-100",
"rounded-full border border-gray-700/40 bg-gray-950/88 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 hover:bg-gray-900/90 active:scale-[0.98] disabled:active:scale-100",
variant === "secondary" && "text-gray-600 hover:text-gray-950",
fallbackVariant === "secondary" &&
"border border-gray-200 hover:bg-gray-50",
"rounded-full border border-gray-200/80 bg-gray-100/70 shadow-[0_1px_4px_rgba(15,23,42,0.045),inset_0_1px_0_rgba(255,255,255,0.78),inset_0_-3px_8px_rgba(148,163,184,0.14)] backdrop-blur-xl hover:bg-gray-100",
variant === "danger" &&
"bg-red-600 text-white hover:bg-red-700",
"rounded-full border border-red-700/35 bg-red-600/90 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 hover:bg-red-600 active:scale-[0.98] disabled:active:scale-100",
)}
{...props}
>

View file

@ -44,8 +44,7 @@ export function OwnerOnlyModal({
onClose={onClose}
title={title}
message={body}
icon={<Lock className="mt-0.5 h-3.5 w-3.5 shrink-0 text-red-600" />}
primaryAction={{ label: "OK", onClick: onClose }}
icon={<Lock className="h-3.5 w-3.5 shrink-0 text-red-600" />}
>
{ownerEmail && (
<p className="mt-1 text-xs text-gray-600">

View file

@ -36,6 +36,10 @@ export function WarningPopup({
}: WarningPopupProps) {
if (!open) return null;
const warningIcon = icon ?? (
<AlertCircle className="h-3 w-3 shrink-0 text-red-600" />
);
return createPortal(
<div className="pointer-events-none fixed left-1/2 top-5 z-[220] w-[min(92vw,520px)] -translate-x-1/2 px-4">
<div
@ -44,16 +48,21 @@ export function WarningPopup({
className,
)}
>
{icon ?? (
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-red-600" />
)}
<div className="min-w-0 flex-1 self-center text-gray-900">
<div className="min-w-0 flex-1 self-center text-red-600">
{title && (
<div className="font-medium text-gray-950">
<div className="flex items-center gap-1.5 font-medium mb-1">
{warningIcon}
{title}
</div>
)}
{message && <div>{message}</div>}
{message && (
<div
className={cn(!title && "flex items-start gap-1.5")}
>
{!title && warningIcon}
<span className="min-w-0">{message}</span>
</div>
)}
{children}
{(primaryAction || secondaryAction) && (
<div className="mt-2 flex items-center gap-2">
@ -72,7 +81,7 @@ export function WarningPopup({
<button
type="button"
onClick={onClose}
className="shrink-0 text-gray-700 transition-colors hover:text-gray-950"
className="shrink-0 text-red-700 transition-colors hover:text-red-500"
aria-label="Dismiss warning"
>
<X className="h-3.5 w-3.5" />

View file

@ -207,7 +207,6 @@ export type AssistantEvent =
url: string;
pdfUrl?: string | null;
dateFiled?: string | null;
judges?: string | null;
case?: Extract<AssistantEvent, { type: "case_opinions" }>["case"];
}
| {
@ -288,7 +287,6 @@ export type CaseCitationAnnotation = {
url?: string | null;
pdfUrl?: string | null;
dateFiled?: string | null;
judges?: string | null;
quotes: CaseCitationQuote[];
};

View file

@ -34,6 +34,7 @@ export function ShareWorkflowModal({
const [existingShares, setExistingShares] = useState<Share[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const { user } = useAuth();
const ownEmail = user?.email?.trim().toLowerCase() ?? null;
@ -55,13 +56,18 @@ export function ShareWorkflowModal({
: pendingEmails;
if (emails.length === 0) return;
setSaving(true);
setError(null);
try {
await shareWorkflow(workflowId, { emails, allow_edit: allowEdit });
const updated = await listWorkflowShares(workflowId);
setExistingShares(updated);
setPendingEmails([]);
} catch {
// ignore
} catch (err) {
setError(
err instanceof Error && err.message
? err.message
: "Unable to share this workflow. Please try again.",
);
} finally {
setSaving(false);
}
@ -90,6 +96,12 @@ export function ShareWorkflowModal({
autoFocus
/>
{error ? (
<div className="rounded-lg bg-red-50 px-3 py-2 text-xs font-medium text-red-700">
{error}
</div>
) : null}
{/* Permission toggle */}
<div className="flex flex-col gap-2">
<span className="text-xs font-medium text-gray-700">Allow editing by share recipients</span>

View file

@ -1,6 +1,6 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import {
streamChat,
@ -20,13 +20,6 @@ interface UseAssistantChatOptions {
projectId?: string;
}
function findLastContentIndex(events: AssistantEvent[]): number {
for (let i = events.length - 1; i >= 0; i--) {
if (events[i].type === "content") return i;
}
return -1;
}
function readableStreamError(value: unknown): string {
if (typeof value === "string" && value.trim()) return value.trim();
return "Sorry, something went wrong.";
@ -161,21 +154,51 @@ export function useAssistantChat({
});
};
const cancel = () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
setIsResponseLoading(false);
setIsLoadingCitations(false);
}
};
// Transient placeholder events (tool_call_start, thinking) fill the
// latency gap between real SSE events so the wrapper doesn't look stuck.
// Anytime a real event arrives, drop any streaming placeholder first.
const isStreamingPlaceholder = (e: AssistantEvent) =>
(e.type === "tool_call_start" || e.type === "thinking") && !!e.isStreaming;
const cancelStreamingEvents = (events: AssistantEvent[]) =>
events
.filter((event) => !isStreamingPlaceholder(event))
.map((event) => {
if (!("isStreaming" in event) || !event.isStreaming) return event;
const rest = { ...event };
delete (rest as { isStreaming?: boolean }).isStreaming;
return rest as AssistantEvent;
});
const appendCancellationEvent = (events: AssistantEvent[]) => {
const cancelledEvents = cancelStreamingEvents(events);
return [
...cancelledEvents,
{ type: "content" as const, text: "Cancelled by user." },
];
};
const cancel = () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
const snapshot = cancelStreamingEvents(eventsRef.current);
eventsRef.current = snapshot;
setMessages((prev) => {
const updated = [...prev];
const last = updated[updated.length - 1];
if (last?.role === "assistant") {
updated[updated.length - 1] = {
...last,
events: cancelStreamingEvents(last.events ?? snapshot),
};
}
return updated;
});
setIsResponseLoading(false);
setIsLoadingCitations(false);
}
};
const clearStreamingPlaceholders = () => {
const before = eventsRef.current;
const after = before.filter((e) => !isStreamingPlaceholder(e));
@ -284,10 +307,10 @@ export function useAssistantChat({
eventsRef.current = [];
try {
const controller = new AbortController();
abortControllerRef.current = controller;
const controller = new AbortController();
abortControllerRef.current = controller;
try {
const apiMessages = newMessages.map((currentMessage) => ({
role: currentMessage.role,
content: currentMessage.content,
@ -1114,43 +1137,29 @@ export function useAssistantChat({
} catch (error: unknown) {
if (error instanceof Error && error.name === "AbortError") {
finalizeStreamingContent();
finalizeStreamingReasoning();
eventsRef.current = appendCancellationEvent(eventsRef.current);
setMessages((prev) => {
const last = prev[prev.length - 1];
if (last?.role === "assistant") {
const updated = [...prev];
const events = last.events ?? [];
const idx = findLastContentIndex(events);
const cancelText = "Cancelled by user";
if (idx >= 0) {
const newEvents = [...events];
const existing = newEvents[idx] as {
type: "content";
text: string;
};
newEvents[idx] = {
type: "content",
text: existing.text
? `${existing.text}\n\nCancelled by user`
: cancelText,
};
updated[updated.length - 1] = {
...last,
events: newEvents,
};
} else {
updated[updated.length - 1] = {
...last,
events: [...events, { type: "content", text: cancelText }],
};
}
const events = appendCancellationEvent(
last.events ?? eventsRef.current,
);
eventsRef.current = events;
updated[updated.length - 1] = {
...last,
events,
};
return updated;
}
eventsRef.current = [{ type: "content", text: "Cancelled by user." }];
return [
...prev,
{
role: "assistant",
content: "",
events: [{ type: "content", text: "Cancelled by user" }],
events: [{ type: "content", text: "Cancelled by user." }],
},
];
});
@ -1185,7 +1194,9 @@ export function useAssistantChat({
setIsLoadingCitations(false);
return null;
} finally {
abortControllerRef.current = null;
if (abortControllerRef.current === controller) {
abortControllerRef.current = null;
}
}
};