Refactor ProjectPageParts and ProjectPageHeader components for improved loading states and skeleton UI. Update Modal and PageHeader components to support loading states. Enhance RenameableTitle for better caret positioning. Adjust DisplayWorkflowModal to utilize the new Modal component structure. Update WorkflowList to include loading indicators and improve sticky header behavior.

This commit is contained in:
willchen96 2026-06-11 21:50:58 +08:00
parent 444d1d38e4
commit 1fa0554ea5
49 changed files with 3623 additions and 1587 deletions

1
.gitignore vendored
View file

@ -11,6 +11,7 @@ build
!.env.local.example
*.log
*.raw-llm-stream.json
*.tsbuildinfo
next-env.d.ts
.DS_Store

1
backend/.gitignore vendored
View file

@ -3,5 +3,6 @@ dist
.env*
!.env.example
*.log
*.raw-llm-stream.json
logs/
.DS_Store

View file

@ -0,0 +1,14 @@
-- Keep document version tombstones after deleting version file bytes.
-- Deleted versions remain visible in history but are ignored by active-file
-- lookups and cannot be opened/downloaded/replaced.
alter table public.document_versions
alter column storage_path drop not null;
alter table public.document_versions
add column if not exists deleted_at timestamptz,
add column if not exists deleted_by uuid;
create index if not exists document_versions_active_document_id_idx
on public.document_versions(document_id, created_at desc)
where deleted_at is null;

View file

@ -20,6 +20,7 @@ create table if not exists public.user_profiles (
tabular_model text not null default 'gemini-3-flash-preview',
quote_model text,
mfa_on_login boolean not null default false,
legal_research_us boolean not null default true,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
@ -119,7 +120,7 @@ create index if not exists idx_documents_project_folder
create table if not exists public.document_versions (
id uuid primary key default gen_random_uuid(),
document_id uuid not null references public.documents(id) on delete cascade,
storage_path text not null,
storage_path text,
pdf_storage_path text,
source text not null default 'upload',
version_number integer,
@ -127,6 +128,8 @@ create table if not exists public.document_versions (
file_type text,
size_bytes integer,
page_count integer,
deleted_at timestamptz,
deleted_by uuid,
created_at timestamptz not null default now(),
constraint document_versions_source_check
check (source = any (array[
@ -142,6 +145,10 @@ create table if not exists public.document_versions (
create index if not exists document_versions_document_id_idx
on public.document_versions(document_id, created_at desc);
create index if not exists document_versions_active_document_id_idx
on public.document_versions(document_id, created_at desc)
where deleted_at is null;
create index if not exists document_versions_doc_vnum_idx
on public.document_versions(document_id, version_number);

View file

@ -128,6 +128,10 @@ app.post("/chat/create", chatCreateLimiter);
app.post("/chat/:chatId/generate-title", chatCreateLimiter);
app.post("/single-documents", uploadLimiter);
app.post("/single-documents/:documentId/versions", uploadLimiter);
app.put(
"/single-documents/:documentId/versions/:versionId/file",
uploadLimiter,
);
app.post("/projects/:projectId/documents", uploadLimiter);
app.get("/user/export", exportLimiter);
app.get("/user/chats/export", exportLimiter);

View file

@ -99,69 +99,79 @@ export type ChatMessage = {
// Constants
// ---------------------------------------------------------------------------
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.
const SYSTEM_PROMPT_BEFORE_RESEARCH = `You are Mike, an AI legal assistant for lawyers and legal professionals. Help 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.
CORE RULES:
- Be precise, professional, and evidence-aware.
- Do not fabricate document content.
- Use at most 10 tool-use rounds per response. Batch independent tool calls and leave room for the final answer.
- If the user selects a workflow with [Workflow: <title> (id: <id>)], immediately call read_workflow with that id and follow the workflow before doing anything else.
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.
DOCUMENT CITATIONS:
Use document citations only for verbatim evidence from uploaded or generated documents.
After your complete response, append a <CITATIONS> block containing a JSON array with one entry per marker:
In prose, put sequential markers [1], [2], etc. exactly where the cited claim appears. The marker number is the citation "ref" value, not a page, footnote, section, or document number.
At the very end of the response, append:
<CITATIONS>
[
{"ref": 1, "doc_id": "doc-0", "page": 3, "quote": "exact verbatim text from the document"},
{"ref": 2, "doc_id": "doc-1", "page": "41-42", "quote": "Section 4.2 describes the procedure [[PAGE_BREAK]] in all material respects."}
{"ref": 1, "doc_id": "doc-0", "quotes": [{"page": 3, "quote": "exact verbatim text"}]},
{"ref": 2, "doc_id": "doc-1", "quotes": [{"page": "41-42", "quote": "text before page break [[PAGE_BREAK]] text after page break"}]}
]
</CITATIONS>
CRITICAL: The number inside the [N] marker in your prose is the "ref" value of a citation entry in the <CITATIONS> block it is NOT a page number, footnote number, section number, or any other number that appears in the document. The marker [1] refers to the entry with "ref": 1 in the JSON block; [2] refers to "ref": 2; and so on. Refs are simple sequential integers you assign (1, 2, 3, ) in the order citations appear in your prose. Never use a page number or a document's own numbering as the marker number. Every [N] you write in prose MUST have a matching {"ref": N, ...} entry in the JSON block.
Rules:
- Only cite text that appears verbatim in the provided documents
- In every document <CITATIONS> entry, "doc_id" MUST be the exact chat-local document label you were given (for example "doc-0"). Never use a filename, document UUID, or any other identifier in "doc_id"
- Prefer one citation entry per inline marker. If one marker needs multiple supporting passages, use a "quotes" array on that citation entry instead of inventing extra markers. Keep "quotes" arrays short: 1 quote by default, maximum 3.
- For document citations, use this shape: {"ref": 1, "doc_id": "doc-0", "quotes": [{"page": 1, "quote": "exact verbatim text"}]}. For legacy compatibility you may also include top-level "page" and "quote" matching the first quote.
- Keep quotes short (ideally 25 words) and narrowly scoped to the specific claim. Don't reuse one quote to support multiple different claims give each claim its own quote in the citation entry
- "page" refers to the sequential [Page N] marker in the text you were given (1-indexed from the first page). IGNORE any page numbers printed inside the document itself (footers, roman numerals, etc.)
- For a single-page quote, set its "page" to an integer. If a quote is one continuous sentence that spans two pages, set "page" to "N-M" and insert [[PAGE_BREAK]] in the quote at the page break. Otherwise, use separate quote objects for text on different pages
- Put the <CITATIONS> block at the very end of the response. Omit it entirely if there are no citations
Citation rules:
- Every [N] marker must have exactly one matching entry with "ref": N.
- "doc_id" must be the exact chat-local label you were given, such as "doc-0". Never use a filename or document UUID in "doc_id".
- Use one citation entry per marker. If one marker needs several passages, use "quotes" with 1 quote by default and at most 3.
- Keep quotes short, ideally 25 words or fewer, and tightly matched to the claim.
- "page" means the sequential [Page N] marker in the provided text, not printed page numbers inside the document.
- For a continuous quote crossing two pages, set "page" to "N-M" and include [[PAGE_BREAK]] at the page break. Otherwise, use separate quote objects.
- For legacy compatibility, you may also include top-level "page" and "quote" matching the first quote.
- Omit the <CITATIONS> block when there are no citations.
DOCX GENERATION:
If asked to draft or generate a document, use the generate_docx tool to produce a downloadable Word document. Always use this tool rather than just displaying the document content inline when the user asks for a document to be created.
If the user follows up on a document you just generated and asks for changes (e.g. "make section 3 longer", "add a termination clause", "change the parties"), default to calling edit_document on that newly generated document. Do not call generate_docx again to regenerate the whole document unless the user explicitly asks for a brand-new document or the change is so sweeping that an edit would not be coherent.
Heading hierarchy: always use Heading 1 before introducing Heading 2, Heading 2 before Heading 3, and so on. Never skip levels (e.g. do not jump from Heading 1 to Heading 3).
Numbering: all numbering MUST start from 1, never 0. This applies at every level of the hierarchy. Legal clause numbering is applied automatically by the document generator: top-level operative headings render as 1., 2., 3.; the first numbered body clause under a top-level heading renders as 1.1; nested body clauses under that render as (a), (b), (c); deeper nested clauses render as (i), (ii), (iii), then (A), (B), (C). Do NOT use 1.1.1 for legal body clauses when (a) is the expected next level. Never produce 0., 0.1, 1.0, 1.0.1, or any other sequence that begins a level with 0.
Never duplicate the numbering prefix in heading text. The heading's own numbering is applied automatically by the document generator, so the heading text must contain the title only. Do NOT prepend "1.", "1.1", "2.", etc. into the heading text itself. For example, a Heading 1 titled "Introduction" must be passed as "Introduction", never as "1. Introduction" (which would render as "1. 1. Introduction"). The same rule applies at every level.
Do not repeat the document title as the first section heading. The document generator already renders the title as a centered title paragraph. Put any opening preamble text directly in the first section's content, without a duplicate heading such as "Agreement", "Contract", "Mutual Non-Disclosure Agreement", or another shortened form of the title.
Contracts: when generating a contract or agreement, always include a signatures block at the very end of the document on its own page. Set pageBreak: true on that final section so it starts on a fresh page, and include a signature line for each party, typically the party name followed by lines for "By:", "Name:", "Title:", and "Date:". The entire signature block must be plain unnumbered text: do NOT number the signatures heading, do NOT number or letter the introductory signature sentence, party names, "By:", "Name:", "Title:", or "Date:" lines, and do NOT place the signature block inside a numbered clause. Put the signature block in the section's content rather than as a numbered heading.
Contract preambles: the preamble of a contract (the opening recitals, parties block, "WHEREAS" clauses, and any introductory narrative before the first operative clause) must NOT be numbered. Render these as unnumbered content (plain paragraphs or an unnumbered heading), and begin numbering only at the first operative clause/section.
- If the user asks you to create or draft a document, call generate_docx and provide the downloadable Word document rather than only displaying text inline.
- If the user asks to revise a document you just generated, call edit_document on that document unless they explicitly want a brand-new document or the change is too broad for coherent editing.
- Use heading levels in order; do not skip from Heading 1 to Heading 3.
- Numbering starts at 1, never 0. The generator applies legal numbering automatically. Do not type numbering prefixes into headings.
- Do not repeat the document title as the first section heading.
- Contract preambles, party blocks, recitals, and WHEREAS clauses are unnumbered. Begin numbering at the first operative clause or section.
- Contracts and agreements must end with an unnumbered signature block on a fresh page. Set pageBreak: true on the final section and include signature lines such as By, Name, Title, and Date for each party.
DOCUMENT EDITING:
When using edit_document, any edit that adds, removes, or reorders a numbered clause, section, sub-clause, schedule, exhibit, or list item shifts every downstream number. You MUST update all affected numbering AND every cross-reference to those numbers in the same edit_document call:
- Renumber the sibling clauses/sections/sub-clauses that follow the change so the sequence stays contiguous (e.g. if you insert a new Section 4, existing Sections 4, 5, 6 become 5, 6, 7).
- Find every in-document reference to the shifted numbers, e.g. "see Section 5", "pursuant to Clause 4.2(b)", "as set out in Schedule 3", "defined in Section 2.1", and update them to the new numbers. Include defined-term blocks, cross-references in recitals, schedules, and exhibits.
- Before issuing the edits, scan the full document (use read_document or find_in_document) to enumerate affected cross-references; do not assume references only appear near the change site.
- If you are uncertain whether a reference points to the shifted number or an unrelated number, err on the side of including it as an edit and explain in the reason field.
- When deleting square brackets, delete both the opening \`[\` and the closing \`]\`. Never leave behind an unmatched square bracket after an edit.
When edit_document adds, deletes, moves, or reorders any numbered clause, section, schedule, exhibit, or list item:
- Renumber all affected downstream items in the same edit.
- Update all affected cross-references, including references in recitals, definitions, schedules, and exhibits.
- Before editing, scan the full document with read_document or find_in_document for affected references.
- If a reference might point to a shifted number, include the update and explain the reason.
- When deleting square brackets, delete both "[" and "]".`;
WORKFLOWS:
When a user message begins with a [Workflow: <title> (id: <id>)] marker, the user has selected a workflow and you MUST apply it. Immediately call the read_workflow tool with that exact id to load the workflow's full prompt, then follow those instructions for the current turn. Do this before producing any other output or calling any other tools (aside from any document reads the workflow requires). Do not ask the user to confirm the selection itself is the instruction to apply the workflow.
${COURTLISTENER_SYSTEM_PROMPT}
DOCUMENT NAMING IN PROSE:
The chat-local labels ("doc-0", "doc-1", "doc-N", ) are internal handles for tool calls and citation JSON ONLY. NEVER write them in your prose response or in any text the user reads not in body text, not in headings, not in lists, not in tool-activity descriptions. The user does not know what "doc-0" means and seeing it is jarring. When referring to a document in prose, always use its filename (e.g. "the NDA draft" or "nda_v1.docx"). This rule applies to every word streamed back to the user; the only places "doc-N" identifiers are allowed are inside tool-call arguments and inside the <CITATIONS> JSON block's "doc_id" field.
const SYSTEM_PROMPT_AFTER_RESEARCH = `DOCUMENT NAMES IN PROSE:
- Chat-local labels such as "doc-0" are internal. Use them only in tool arguments and citation JSON.
- Never show "doc-N" labels to the user in prose, headings, lists, or tool activity text.
- Refer to documents by filename or a natural description, such as "the NDA draft".
GENERAL GUIDANCE:
- Be precise and professional
- Cite the specific document or fetched opinion passage when making evidence-backed claims. Use [N] markers only as described in the citation instructions above
- When no documents are provided, answer based on your legal knowledge
- Do not fabricate document content
- Do not use emojis in your responses.
- Cite the exact document or fetched opinion passage for evidence-backed claims.
- If no documents are provided, answer from legal knowledge.
- Do not use emojis.
`;
/**
* Assemble the chat system prompt. When `includeResearchTools` is true the
* CourtListener (US case-law) research instructions are spliced in; when
* false they are omitted entirely so the model is not told about tools it
* does not have. Gated per-user by the Legal Research > US feature toggle.
*/
export function buildSystemPrompt(includeResearchTools = true): string {
return includeResearchTools
? `${SYSTEM_PROMPT_BEFORE_RESEARCH}\n\n${COURTLISTENER_SYSTEM_PROMPT}\n${SYSTEM_PROMPT_AFTER_RESEARCH}`
: `${SYSTEM_PROMPT_BEFORE_RESEARCH}\n\n${SYSTEM_PROMPT_AFTER_RESEARCH}`;
}
export const SYSTEM_PROMPT = buildSystemPrompt(true);
export const PROJECT_EXTRA_TOOLS = [
{
type: "function",
@ -763,9 +773,10 @@ export function buildMessages(
}[],
systemPromptExtra?: string,
docIndex?: DocIndex,
includeResearchTools = true,
) {
const formatted: unknown[] = [];
let systemContent = SYSTEM_PROMPT;
let systemContent = buildSystemPrompt(includeResearchTools);
if (systemPromptExtra) {
systemContent += `\n\n${systemPromptExtra.trim()}`;
@ -3619,18 +3630,65 @@ const CITATIONS_BLOCK_RE = /<CITATIONS>\s*([\s\S]*?)\s*<\/CITATIONS>/;
const CITATIONS_OPEN_TAG = "<CITATIONS>";
const CITATIONS_CLOSE_TAG = "</CITATIONS>";
function parseCitations(text: string): ParsedCitation[] {
type CitationParseDiagnostics = {
hasBlock: boolean;
rawLength: number;
error: string | null;
};
function parseCitationsWithDiagnostics(text: string): {
citations: ParsedCitation[];
diagnostics: CitationParseDiagnostics;
} {
const match = text.match(CITATIONS_BLOCK_RE);
if (!match) return [];
try {
const raw = JSON.parse(match[1]);
if (!Array.isArray(raw)) return [];
return raw
.map(normalizeCitation)
.filter((c): c is ParsedCitation => c !== null);
} catch {
return [];
if (!match) {
return {
citations: [],
diagnostics: {
hasBlock: false,
rawLength: 0,
error: null,
},
};
}
const raw = match[1] ?? "";
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return {
citations: [],
diagnostics: {
hasBlock: true,
rawLength: raw.length,
error: "CITATIONS block JSON was not an array.",
},
};
}
return {
citations: parsed
.map(normalizeCitation)
.filter((c): c is ParsedCitation => c !== null),
diagnostics: {
hasBlock: true,
rawLength: raw.length,
error: null,
},
};
} catch (error) {
return {
citations: [],
diagnostics: {
hasBlock: true,
rawLength: raw.length,
error: error instanceof Error ? error.message : String(error),
},
};
}
}
function parseCitations(text: string): ParsedCitation[] {
return parseCitationsWithDiagnostics(text).citations;
}
function parsePartialCitationObjects(text: string): ParsedCitation[] {
@ -4181,7 +4239,8 @@ export async function runLLMStream(params: {
flushText();
// Parse and emit citations from <CITATIONS> block
const parsedCitations = parseCitations(fullText);
const { citations: parsedCitations, diagnostics: citationDiagnostics } =
parseCitationsWithDiagnostics(fullText);
const citations = buildCitations
? buildCitations(fullText)
: parsedCitations.map((c) =>
@ -4191,6 +4250,14 @@ export async function runLLMStream(params: {
courtlistenerTurnState.casesByClusterId,
),
);
devLog("[chat/stream] final citation annotations", {
hasCitationsBlock: citationDiagnostics.hasBlock,
citationsBlockLength: citationDiagnostics.rawLength,
parseError: citationDiagnostics.error,
parsedCitationCount: parsedCitations.length,
emittedAnnotationCount: citations.length,
usedCustomCitationBuilder: !!buildCitations,
});
write(
`data: ${JSON.stringify({ type: "citations", status: "final", citations })}\n\n`,
);

View file

@ -66,6 +66,7 @@ export async function loadActiveVersion(
"id, document_id, storage_path, pdf_storage_path, version_number, filename, source, file_type, size_bytes, page_count",
)
.eq("id", targetVersionId)
.is("deleted_at", null)
.single();
if (!v || v.document_id !== documentId || !v.storage_path) return null;
return {
@ -111,7 +112,8 @@ export async function attachActiveVersionPaths<T extends VersionPathRow>(
.select(
"id, storage_path, pdf_storage_path, version_number, filename, file_type, size_bytes, page_count",
)
.in("id", versionIds);
.in("id", versionIds)
.is("deleted_at", null);
const byId = new Map<
string,
{
@ -174,6 +176,7 @@ export async function attachLatestVersionNumbers<T extends DocRow>(
.select("document_id, version_number")
.in("document_id", ids)
.eq("source", "assistant_edit")
.is("deleted_at", null)
.not("version_number", "is", null);
const latestByDoc = new Map<string, number>();

View file

@ -69,19 +69,25 @@ export const COURTLISTENER_TOOL_NAMES = {
verifyCitations: "courtlistener_verify_citations",
} as const;
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.
- 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 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.
- Do not cite a case based only on memory, search-result snippets, reporter metadata, citationLinks, or verification results. Those sources may help choose candidates, but final case citations must be grounded in supplied opinion text/passages.
- Every case citation in final prose must be rendered as a clickable case-law panel link using the markdown link returned in citationLinks, e.g. [Case Name, Citation](us-case-12345). Do not write plain-text case citations without the link.
- Use numbered [N] markers for case citations in the final prose and include each cited case in the final <CITATIONS> block.
- Each case entry in the <CITATIONS> block must include quote(s) copied exactly from the supplied opinion text/passages for that case, e.g. {"ref": N, "cluster_id": 123, "quotes": [{"opinion_id": 456, "quote": "exact verbatim opinion text"}]}. Do not include top-level "quote", "doc_id", "page", "case_name", or "citation" for case entries.
- If a case is useful but you do not have its opinion text or relevant passages, either fetch the opinions before citing it or say that you could not read the opinion and do not cite or characterize the case beyond basic metadata.`;
export const COURTLISTENER_SYSTEM_PROMPT = `US CASE LAW RESEARCH:
Use CourtListener when answering US-law questions that require case law.
Workflow:
1. If you have reporter citations, verify them with courtlistener_verify_citations using only clean citations: {"citations":["467 U.S. 837","323 U.S. 134"]}. Never pass case names to this tool.
2. Fetch matched clusters with courtlistener_get_cases.
3. Get cite-worthy text from the fetched cases with courtlistener_find_in_case. Use short 1-3 word searches, maximum 3 searches per assistant turn.
4. If snippets are not enough, read only the necessary opinion(s) with courtlistener_read_case. For multi-opinion cases, choose the specific opinion_id/opinionIds needed; do not read all opinions by default.
Citation rules:
- Final case citations must be based on opinion text or passage snippets supplied in this turn. Do not cite cases based only on memory, metadata, search results, citationLinks, or verification results.
- If you mention a CourtListener case as legal support in the final answer, cite it with both: (a) the clickable markdown link returned in citationLinks, and (b) an inline [N] marker. Include the clickable case link only the first time you cite that case; later references to the same case should use the existing inline [N] marker without repeating the link unless clarity requires it.
- Assign new annotation refs in first-use order as much as possible: [1], then [2], then [3]. Reuse an existing ref when citing the same case/passage again, even if that means a later sentence cites [3] and then [1] again.
- The final <CITATIONS> block must include one matching case entry for each [N] case marker: {"ref": N, "cluster_id": 123, "quotes": [{"opinion_id": 456, "quote": "exact verbatim opinion text"}]}.
- Do not use doc_id, page, top-level quote, case_name, or citation fields in case entries.
- If you have not obtained opinion text or snippets for a useful case, fetch/read it before citing it, or say you could not read it and do not rely on it.
Limits:
- If any CourtListener call returns a rate-limit/throttling/429 error, stop all CourtListener calls for that turn and answer using only information already available.`;
export const COURTLISTENER_TOOLS = [
{

View file

@ -1,274 +1,294 @@
import Anthropic from "@anthropic-ai/sdk";
import type { Tool } from "@anthropic-ai/sdk/resources/messages/messages";
import type {
StreamChatParams,
StreamChatResult,
NormalizedToolCall,
NormalizedToolResult,
StreamChatParams,
StreamChatResult,
NormalizedToolCall,
NormalizedToolResult,
} from "./types";
import { toClaudeTools } from "./tools";
import { logRawLlmStream } from "./rawStreamLog";
import { createRawLlmStreamRecorder, logRawLlmStream } from "./rawStreamLog";
type ContentBlock =
| { type: "text"; text: string }
| { type: "tool_use"; id: string; name: string; input: unknown }
| { type: string; [key: string]: unknown };
| { type: "text"; text: string }
| { type: "tool_use"; id: string; name: string; input: unknown }
| { type: string; [key: string]: unknown };
type NativeMessage = {
role: "user" | "assistant";
content: string | ContentBlock[];
role: "user" | "assistant";
content: string | ContentBlock[];
};
const MAX_TOKENS = 16384;
function apiKey(override?: string | null): string {
const key = override?.trim() || process.env.ANTHROPIC_API_KEY?.trim() || "";
if (!key) {
throw new Error(
"Anthropic API key is not configured. Set ANTHROPIC_API_KEY or add a user Anthropic key.",
);
}
return key;
const key = override?.trim() || process.env.ANTHROPIC_API_KEY?.trim() || "";
if (!key) {
throw new Error(
"Anthropic API key is not configured. Set ANTHROPIC_API_KEY or add a user Anthropic key.",
);
}
return key;
}
function client(override?: string | null): Anthropic {
const apiKeyValue = apiKey(override);
return new Anthropic({ apiKey: apiKeyValue });
const apiKeyValue = apiKey(override);
return new Anthropic({ apiKey: apiKeyValue });
}
function toNativeMessages(
messages: StreamChatParams["messages"],
messages: StreamChatParams["messages"],
): NativeMessage[] {
return messages.map((m) => ({ role: m.role, content: m.content }));
return messages.map((m) => ({ role: m.role, content: m.content }));
}
function claudeErrorMessage(error: unknown): string {
const parsedObject = claudeStreamFailureMessage(error);
if (parsedObject) return parsedObject;
if (error instanceof Error && error.message) {
const parsed = parseClaudeErrorPayload(error.message);
if (parsed) return parsed;
return error.message.startsWith("Claude error:")
? error.message
: `Claude error: ${error.message}`;
}
const parsed = parseClaudeErrorPayload(String(error));
const parsedObject = claudeStreamFailureMessage(error);
if (parsedObject) return parsedObject;
if (error instanceof Error && error.message) {
const parsed = parseClaudeErrorPayload(error.message);
if (parsed) return parsed;
return `Claude error: ${String(error)}`;
return error.message.startsWith("Claude error:")
? error.message
: `Claude error: ${error.message}`;
}
const parsed = parseClaudeErrorPayload(String(error));
if (parsed) return parsed;
return `Claude error: ${String(error)}`;
}
function parseClaudeErrorPayload(value: string): string | null {
const trimmed = value.trim();
const jsonStart = trimmed.indexOf("{");
if (jsonStart < 0) return null;
const jsonEnd = trimmed.lastIndexOf("}");
if (jsonEnd <= jsonStart) return null;
const payload = trimmed.slice(jsonStart, jsonEnd + 1);
try {
const parsed = JSON.parse(payload) as unknown;
return claudeStreamFailureMessage(parsed);
} catch {
return null;
}
const trimmed = value.trim();
const jsonStart = trimmed.indexOf("{");
if (jsonStart < 0) return null;
const jsonEnd = trimmed.lastIndexOf("}");
if (jsonEnd <= jsonStart) return null;
const payload = trimmed.slice(jsonStart, jsonEnd + 1);
try {
const parsed = JSON.parse(payload) as unknown;
return claudeStreamFailureMessage(parsed);
} catch {
return null;
}
}
function claudeStreamFailureMessage(event: unknown): string | null {
if (!event || typeof event !== "object") return null;
const record = event as Record<string, unknown>;
const error = record.error;
if (record.type !== "error" || !error || typeof error !== "object") {
return null;
}
const err = error as Record<string, unknown>;
const type =
typeof err.type === "string" && err.type.trim()
? err.type.trim()
: null;
const message =
typeof err.message === "string" && err.message.trim()
? err.message.trim()
: "Claude stream failed.";
return type ? `Claude error (${type}): ${message}` : `Claude error: ${message}`;
if (!event || typeof event !== "object") return null;
const record = event as Record<string, unknown>;
const error = record.error;
if (record.type !== "error" || !error || typeof error !== "object") {
return null;
}
const err = error as Record<string, unknown>;
const type =
typeof err.type === "string" && err.type.trim() ? err.type.trim() : null;
const message =
typeof err.message === "string" && err.message.trim()
? err.message.trim()
: "Claude stream failed.";
return type
? `Claude error (${type}): ${message}`
: `Claude error: ${message}`;
}
function abortError(): Error {
const err = new Error("Stream aborted.");
err.name = "AbortError";
return err;
const err = new Error("Stream aborted.");
err.name = "AbortError";
return err;
}
function throwIfAborted(signal?: AbortSignal) {
if (signal?.aborted) throw abortError();
if (signal?.aborted) throw abortError();
}
export async function streamClaude(
params: StreamChatParams,
params: StreamChatParams,
): Promise<StreamChatResult> {
const {
model,
systemPrompt,
tools = [],
callbacks = {},
runTools,
apiKeys,
enableThinking,
} = params;
const maxIter = params.maxIterations ?? 10;
const anthropic = client(apiKeys?.claude);
const claudeTools = toClaudeTools(tools);
const {
model,
systemPrompt,
tools = [],
callbacks = {},
runTools,
apiKeys,
enableThinking,
} = params;
const maxIter = params.maxIterations ?? 10;
const anthropic = client(apiKeys?.claude);
const claudeTools = toClaudeTools(tools);
const messages: NativeMessage[] = toNativeMessages(params.messages);
let fullText = "";
const messages: NativeMessage[] = toNativeMessages(params.messages);
let fullText = "";
const rawStreamRecorder = createRawLlmStreamRecorder({
provider: "claude",
model,
});
try {
for (let iter = 0; iter < maxIter; iter++) {
throwIfAborted(params.abortSignal);
const stream = anthropic.messages.stream({
model,
system: systemPrompt,
messages: messages as Anthropic.MessageParam[],
tools: claudeTools.length
? (claudeTools as unknown as Tool[])
: undefined,
max_tokens: MAX_TOKENS,
// Claude 4.x models require `thinking.type: "adaptive"` and
// drive effort via `output_config.effort` rather than a fixed
// token budget. We only opt in when the caller requested it.
...(enableThinking
? ({
thinking: { type: "adaptive" },
output_config: { effort: "high" },
} as unknown as Record<string, unknown>)
: {}),
// Extended thinking requires temperature to be default (omitted).
});
throwIfAborted(params.abortSignal);
const stream = anthropic.messages.stream({
model,
system: systemPrompt,
messages: messages as Anthropic.MessageParam[],
tools: claudeTools.length
? (claudeTools as unknown as Tool[])
: undefined,
max_tokens: MAX_TOKENS,
// Claude 4.x models require `thinking.type: "adaptive"` and
// drive effort via `output_config.effort` rather than a fixed
// token budget. We only opt in when the caller requested it.
...(enableThinking
? ({
thinking: { type: "adaptive" },
output_config: { effort: "high" },
} as unknown as Record<string, unknown>)
: {}),
// Extended thinking requires temperature to be default (omitted).
});
let sawThinking = false;
let streamFailureMessage: string | null = null;
const abortStream = () => stream.abort();
params.abortSignal?.addEventListener("abort", abortStream, {
once: true,
});
let sawThinking = false;
let streamFailureMessage: string | null = null;
const abortStream = () => stream.abort();
params.abortSignal?.addEventListener("abort", abortStream, {
once: true,
});
stream.on("streamEvent", (event) => {
logRawLlmStream({
provider: "claude",
model,
iteration: iter,
label: "streamEvent",
payload: event,
});
const failureMessage = claudeStreamFailureMessage(event);
if (failureMessage) {
streamFailureMessage = failureMessage;
stream.abort();
}
stream.on("streamEvent", (event) => {
logRawLlmStream({
provider: "claude",
model,
iteration: iter,
label: "streamEvent",
payload: event,
});
stream.on("error", (error) => {
logRawLlmStream({
provider: "claude",
model,
iteration: iter,
label: "error",
payload: error,
});
rawStreamRecorder?.record({
iteration: iter,
label: "streamEvent",
payload: event,
});
stream.on("text", (delta) => {
callbacks.onContentDelta?.(delta);
});
if (enableThinking) {
stream.on("thinking", (delta) => {
sawThinking = true;
callbacks.onReasoningDelta?.(delta);
});
const failureMessage = claudeStreamFailureMessage(event);
if (failureMessage) {
streamFailureMessage = failureMessage;
stream.abort();
}
let final: Awaited<ReturnType<typeof stream.finalMessage>>;
try {
final = await stream.finalMessage();
} catch (error) {
if (params.abortSignal?.aborted) throw abortError();
if (streamFailureMessage) throw new Error(streamFailureMessage);
throw new Error(claudeErrorMessage(error));
} finally {
params.abortSignal?.removeEventListener("abort", abortStream);
}
if (sawThinking) callbacks.onReasoningBlockEnd?.();
throwIfAborted(params.abortSignal);
const stopReason = final.stop_reason;
const assistantBlocks = final.content as ContentBlock[];
// Extract text content and tool_use calls from the final assistant
// message so we can accumulate text and drive the tool-call loop.
const toolCalls: NormalizedToolCall[] = [];
for (const block of assistantBlocks) {
if (block.type === "text") {
const txt = (block as { text: string }).text;
if (typeof txt === "string") fullText += txt;
} else if (block.type === "tool_use") {
const tu = block as {
id: string;
name: string;
input: unknown;
};
const call: NormalizedToolCall = {
id: tu.id,
name: tu.name,
input: (tu.input as Record<string, unknown>) ?? {},
};
callbacks.onToolCallStart?.(call);
toolCalls.push(call);
}
}
if (stopReason !== "tool_use" || !toolCalls.length || !runTools) {
break;
}
const results = await runTools(toolCalls);
throwIfAborted(params.abortSignal);
// Record the assistant turn (preserving the original content blocks,
// which Claude requires on the follow-up) and the user turn that
// carries the tool_result blocks.
messages.push({ role: "assistant", content: assistantBlocks });
messages.push({
role: "user",
content: results.map((r) => ({
type: "tool_result",
tool_use_id: r.tool_use_id,
content: r.content,
})),
});
stream.on("error", (error) => {
logRawLlmStream({
provider: "claude",
model,
iteration: iter,
label: "error",
payload: error,
});
rawStreamRecorder?.record({
iteration: iter,
label: "error",
payload: error,
});
});
stream.on("text", (delta) => {
callbacks.onContentDelta?.(delta);
});
if (enableThinking) {
stream.on("thinking", (delta) => {
sawThinking = true;
callbacks.onReasoningDelta?.(delta);
});
}
let final: Awaited<ReturnType<typeof stream.finalMessage>>;
try {
final = await stream.finalMessage();
} catch (error) {
if (params.abortSignal?.aborted) throw abortError();
if (streamFailureMessage) throw new Error(streamFailureMessage);
throw new Error(claudeErrorMessage(error));
} finally {
params.abortSignal?.removeEventListener("abort", abortStream);
}
if (sawThinking) callbacks.onReasoningBlockEnd?.();
throwIfAborted(params.abortSignal);
const stopReason = final.stop_reason;
const assistantBlocks = final.content as ContentBlock[];
// Extract text content and tool_use calls from the final assistant
// message so we can accumulate text and drive the tool-call loop.
const toolCalls: NormalizedToolCall[] = [];
for (const block of assistantBlocks) {
if (block.type === "text") {
const txt = (block as { text: string }).text;
if (typeof txt === "string") fullText += txt;
} else if (block.type === "tool_use") {
const tu = block as {
id: string;
name: string;
input: unknown;
};
const call: NormalizedToolCall = {
id: tu.id,
name: tu.name,
input: (tu.input as Record<string, unknown>) ?? {},
};
callbacks.onToolCallStart?.(call);
toolCalls.push(call);
}
}
if (stopReason !== "tool_use" || !toolCalls.length || !runTools) {
break;
}
const results = await runTools(toolCalls);
throwIfAborted(params.abortSignal);
// Record the assistant turn (preserving the original content blocks,
// which Claude requires on the follow-up) and the user turn that
// carries the tool_result blocks.
messages.push({ role: "assistant", content: assistantBlocks });
messages.push({
role: "user",
content: results.map((r) => ({
type: "tool_result",
tool_use_id: r.tool_use_id,
content: r.content,
})),
});
}
await rawStreamRecorder?.flush("completed");
return { fullText };
} catch (error) {
await rawStreamRecorder?.flush("error", error);
throw error;
}
}
export async function completeClaudeText(params: {
model: string;
systemPrompt?: string;
user: string;
maxTokens?: number;
apiKeys?: { claude?: string | null };
model: string;
systemPrompt?: string;
user: string;
maxTokens?: number;
apiKeys?: { claude?: string | null };
}): Promise<string> {
const anthropic = client(params.apiKeys?.claude);
let resp: Awaited<ReturnType<typeof anthropic.messages.create>>;
try {
resp = await anthropic.messages.create({
model: params.model,
max_tokens: params.maxTokens ?? 512,
system: params.systemPrompt,
messages: [{ role: "user", content: params.user }],
});
} catch (error) {
throw new Error(claudeErrorMessage(error));
}
const text = resp.content
.filter((b): b is Anthropic.TextBlock => b.type === "text")
.map((b) => b.text)
.join("");
return text;
const anthropic = client(params.apiKeys?.claude);
let resp: Awaited<ReturnType<typeof anthropic.messages.create>>;
try {
resp = await anthropic.messages.create({
model: params.model,
max_tokens: params.maxTokens ?? 512,
system: params.systemPrompt,
messages: [{ role: "user", content: params.user }],
});
} catch (error) {
throw new Error(claudeErrorMessage(error));
}
const text = resp.content
.filter((b): b is Anthropic.TextBlock => b.type === "text")
.map((b) => b.text)
.join("");
return text;
}
// Helper re-export for callers wanting to hand normalized results back in.

View file

@ -1,326 +1,351 @@
import { GoogleGenAI } from "@google/genai";
import type {
StreamChatParams,
StreamChatResult,
NormalizedToolCall,
StreamChatParams,
StreamChatResult,
NormalizedToolCall,
} from "./types";
import { toGeminiTools } from "./tools";
import { logRawLlmStream } from "./rawStreamLog";
import { createRawLlmStreamRecorder, logRawLlmStream } from "./rawStreamLog";
type GeminiPart = {
text?: string;
// Set by Gemini when the text content is a thought summary rather than
// final-answer prose. Requires `thinkingConfig.includeThoughts: true`.
thought?: boolean;
functionCall?: { id?: string; name: string; args?: Record<string, unknown> };
functionResponse?: {
id?: string;
name: string;
response: Record<string, unknown>;
};
// Gemini 3 returns a thoughtSignature on parts that contain reasoning or
// a functionCall. It must be echoed back verbatim on the same part when
// we replay the model's turn, or the API rejects the next call.
thoughtSignature?: string;
text?: string;
// Set by Gemini when the text content is a thought summary rather than
// final-answer prose. Requires `thinkingConfig.includeThoughts: true`.
thought?: boolean;
functionCall?: { id?: string; name: string; args?: Record<string, unknown> };
functionResponse?: {
id?: string;
name: string;
response: Record<string, unknown>;
};
// Gemini 3 returns a thoughtSignature on parts that contain reasoning or
// a functionCall. It must be echoed back verbatim on the same part when
// we replay the model's turn, or the API rejects the next call.
thoughtSignature?: string;
};
type GeminiContent = {
role: "user" | "model";
parts: GeminiPart[];
role: "user" | "model";
parts: GeminiPart[];
};
function apiKey(override?: string | null): string {
const key = override?.trim() || process.env.GEMINI_API_KEY?.trim() || "";
if (!key) {
throw new Error(
"Gemini API key is not configured. Set GEMINI_API_KEY or add a user Gemini key.",
);
}
return key;
const key = override?.trim() || process.env.GEMINI_API_KEY?.trim() || "";
if (!key) {
throw new Error(
"Gemini API key is not configured. Set GEMINI_API_KEY or add a user Gemini key.",
);
}
return key;
}
function client(override?: string | null): GoogleGenAI {
return new GoogleGenAI({ apiKey: apiKey(override) });
return new GoogleGenAI({ apiKey: apiKey(override) });
}
function toNativeContents(messages: StreamChatParams["messages"]): GeminiContent[] {
return messages.map((m) => ({
role: m.role === "assistant" ? "model" : "user",
parts: [{ text: m.content }],
}));
function toNativeContents(
messages: StreamChatParams["messages"],
): GeminiContent[] {
return messages.map((m) => ({
role: m.role === "assistant" ? "model" : "user",
parts: [{ text: m.content }],
}));
}
function geminiErrorMessage(error: unknown): string {
const parsedObject = geminiStreamFailureMessage(error);
if (parsedObject) return parsedObject;
if (typeof error === "string") {
const parsed = parseGeminiErrorPayload(error);
if (parsed) return parsed;
return error.startsWith("Gemini error:")
? error
: `Gemini error: ${error}`;
}
if (error instanceof Error && error.message) {
const parsed = parseGeminiErrorPayload(error.message);
if (parsed) return parsed;
return error.message.startsWith("Gemini error:")
? error.message
: `Gemini error: ${error.message}`;
}
return `Gemini error: ${String(error)}`;
const parsedObject = geminiStreamFailureMessage(error);
if (parsedObject) return parsedObject;
if (typeof error === "string") {
const parsed = parseGeminiErrorPayload(error);
if (parsed) return parsed;
return error.startsWith("Gemini error:") ? error : `Gemini error: ${error}`;
}
if (error instanceof Error && error.message) {
const parsed = parseGeminiErrorPayload(error.message);
if (parsed) return parsed;
return error.message.startsWith("Gemini error:")
? error.message
: `Gemini error: ${error.message}`;
}
return `Gemini error: ${String(error)}`;
}
function parseGeminiErrorPayload(value: string): string | null {
const trimmed = value.trim();
if (!trimmed.startsWith("{")) return null;
try {
const parsed = JSON.parse(trimmed) as unknown;
return geminiStreamFailureMessage(parsed);
} catch {
return null;
}
const trimmed = value.trim();
if (!trimmed.startsWith("{")) return null;
try {
const parsed = JSON.parse(trimmed) as unknown;
return geminiStreamFailureMessage(parsed);
} catch {
return null;
}
}
function geminiStreamFailureMessage(chunk: unknown): string | null {
if (!chunk || typeof chunk !== "object") return null;
const record = chunk as Record<string, unknown>;
const error = record.error;
if (error && typeof error === "object") {
const err = error as Record<string, unknown>;
const nested =
typeof err.message === "string"
? parseGeminiErrorPayload(err.message)
: null;
if (nested) return nested;
const message =
typeof err.message === "string" && err.message.trim()
? err.message.trim()
: "Gemini stream failed.";
const code =
typeof err.code === "string" && err.code.trim()
? err.code.trim()
: typeof err.code === "number" && Number.isFinite(err.code)
? String(err.code)
: typeof err.status === "string" && err.status.trim()
? err.status.trim()
: null;
return code ? `Gemini error (${code}): ${message}` : `Gemini error: ${message}`;
}
const promptFeedback = record.promptFeedback;
if (promptFeedback && typeof promptFeedback === "object") {
const feedback = promptFeedback as Record<string, unknown>;
const blockReason =
typeof feedback.blockReason === "string"
? feedback.blockReason
: null;
if (blockReason) {
const detail =
typeof feedback.blockReasonMessage === "string" &&
feedback.blockReasonMessage.trim()
? feedback.blockReasonMessage.trim()
: "The Gemini response was blocked.";
return `Gemini error (${blockReason}): ${detail}`;
}
}
const candidates = Array.isArray(record.candidates)
? (record.candidates as Record<string, unknown>[])
: [];
const finishReason =
typeof candidates[0]?.finishReason === "string"
? candidates[0].finishReason
if (!chunk || typeof chunk !== "object") return null;
const record = chunk as Record<string, unknown>;
const error = record.error;
if (error && typeof error === "object") {
const err = error as Record<string, unknown>;
const nested =
typeof err.message === "string"
? parseGeminiErrorPayload(err.message)
: null;
if (nested) return nested;
const message =
typeof err.message === "string" && err.message.trim()
? err.message.trim()
: "Gemini stream failed.";
const code =
typeof err.code === "string" && err.code.trim()
? err.code.trim()
: typeof err.code === "number" && Number.isFinite(err.code)
? String(err.code)
: typeof err.status === "string" && err.status.trim()
? err.status.trim()
: null;
const errorFinishReasons = new Set([
"SAFETY",
"RECITATION",
"BLOCKLIST",
"PROHIBITED_CONTENT",
"SPII",
"MALFORMED_FUNCTION_CALL",
"OTHER",
]);
if (finishReason && errorFinishReasons.has(finishReason)) {
return `Gemini error (${finishReason}): The Gemini stream ended with an error finish reason.`;
}
return code
? `Gemini error (${code}): ${message}`
: `Gemini error: ${message}`;
}
return null;
const promptFeedback = record.promptFeedback;
if (promptFeedback && typeof promptFeedback === "object") {
const feedback = promptFeedback as Record<string, unknown>;
const blockReason =
typeof feedback.blockReason === "string" ? feedback.blockReason : null;
if (blockReason) {
const detail =
typeof feedback.blockReasonMessage === "string" &&
feedback.blockReasonMessage.trim()
? feedback.blockReasonMessage.trim()
: "The Gemini response was blocked.";
return `Gemini error (${blockReason}): ${detail}`;
}
}
const candidates = Array.isArray(record.candidates)
? (record.candidates as Record<string, unknown>[])
: [];
const finishReason =
typeof candidates[0]?.finishReason === "string"
? candidates[0].finishReason
: null;
const errorFinishReasons = new Set([
"SAFETY",
"RECITATION",
"BLOCKLIST",
"PROHIBITED_CONTENT",
"SPII",
"MALFORMED_FUNCTION_CALL",
"OTHER",
]);
if (finishReason && errorFinishReasons.has(finishReason)) {
return `Gemini error (${finishReason}): The Gemini stream ended with an error finish reason.`;
}
return null;
}
function abortError(): Error {
const err = new Error("Stream aborted.");
err.name = "AbortError";
return err;
const err = new Error("Stream aborted.");
err.name = "AbortError";
return err;
}
function throwIfAborted(signal?: AbortSignal) {
if (signal?.aborted) throw abortError();
if (signal?.aborted) throw abortError();
}
export async function streamGemini(
params: StreamChatParams,
params: StreamChatParams,
): Promise<StreamChatResult> {
const { model, systemPrompt, tools = [], callbacks = {}, runTools, apiKeys, enableThinking } = params;
const maxIter = params.maxIterations ?? 10;
const ai = client(apiKeys?.gemini);
const functionDeclarations = toGeminiTools(tools);
const {
model,
systemPrompt,
tools = [],
callbacks = {},
runTools,
apiKeys,
enableThinking,
} = params;
const maxIter = params.maxIterations ?? 10;
const ai = client(apiKeys?.gemini);
const functionDeclarations = toGeminiTools(tools);
const contents: GeminiContent[] = toNativeContents(params.messages);
let fullText = "";
const contents: GeminiContent[] = toNativeContents(params.messages);
let fullText = "";
const rawStreamRecorder = createRawLlmStreamRecorder({
provider: "gemini",
model,
});
try {
for (let iter = 0; iter < maxIter; iter++) {
throwIfAborted(params.abortSignal);
let stream: AsyncIterable<unknown>;
try {
stream = await ai.models.generateContentStream({
model,
contents: contents as never,
config: {
systemInstruction: systemPrompt,
tools: functionDeclarations.length
? [{ functionDeclarations } as never]
: undefined,
// When enabled, ask Gemini to surface thought summaries.
// When disabled, explicitly zero the thinking budget so the
// model skips thinking entirely (saves tokens and latency
// for bulk extraction jobs).
thinkingConfig: enableThinking
? { includeThoughts: true }
: { thinkingBudget: 0 },
},
});
} catch (error) {
throw new Error(geminiErrorMessage(error));
}
// Per-iteration accumulators.
const textParts: string[] = [];
const callParts: GeminiPart[] = [];
const toolCalls: NormalizedToolCall[] = [];
let sawThinking = false;
const iterator = stream[Symbol.asyncIterator]();
let rejectAbort: ((reason?: unknown) => void) | null = null;
const abortPromise = new Promise<never>((_, reject) => {
rejectAbort = reject;
});
const onAbort = () => rejectAbort?.(abortError());
params.abortSignal?.addEventListener("abort", onAbort, {
once: true,
throwIfAborted(params.abortSignal);
let stream: AsyncIterable<unknown>;
try {
stream = await ai.models.generateContentStream({
model,
contents: contents as never,
config: {
systemInstruction: systemPrompt,
tools: functionDeclarations.length
? [{ functionDeclarations } as never]
: undefined,
// When enabled, ask Gemini to surface thought summaries.
// When disabled, explicitly zero the thinking budget so the
// model skips thinking entirely (saves tokens and latency
// for bulk extraction jobs).
thinkingConfig: enableThinking
? { includeThoughts: true }
: { thinkingBudget: 0 },
},
});
} catch (error) {
throw new Error(geminiErrorMessage(error));
}
try {
while (true) {
throwIfAborted(params.abortSignal);
const { value: chunk, done } = await Promise.race([
iterator.next(),
abortPromise,
]);
if (done) break;
logRawLlmStream({
provider: "gemini",
model,
iteration: iter,
label: "chunk",
payload: chunk,
});
const failureMessage = geminiStreamFailureMessage(chunk);
if (failureMessage) throw new Error(failureMessage);
// Per-iteration accumulators.
const textParts: string[] = [];
const callParts: GeminiPart[] = [];
const toolCalls: NormalizedToolCall[] = [];
let sawThinking = false;
const iterator = stream[Symbol.asyncIterator]();
let rejectAbort: ((reason?: unknown) => void) | null = null;
const abortPromise = new Promise<never>((_, reject) => {
rejectAbort = reject;
});
const onAbort = () => rejectAbort?.(abortError());
params.abortSignal?.addEventListener("abort", onAbort, {
once: true,
});
const parts =
(chunk as { candidates?: { content?: { parts?: GeminiPart[] } }[] })
.candidates?.[0]?.content?.parts ?? [];
try {
while (true) {
throwIfAborted(params.abortSignal);
const { value: chunk, done } = await Promise.race([
iterator.next(),
abortPromise,
]);
if (done) break;
logRawLlmStream({
provider: "gemini",
model,
iteration: iter,
label: "chunk",
payload: chunk,
});
rawStreamRecorder?.record({
iteration: iter,
label: "chunk",
payload: chunk,
});
const failureMessage = geminiStreamFailureMessage(chunk);
if (failureMessage) throw new Error(failureMessage);
for (const part of parts) {
if (part.text) {
if (part.thought) {
sawThinking = true;
callbacks.onReasoningDelta?.(part.text);
} else {
textParts.push(part.text);
callbacks.onContentDelta?.(part.text);
}
}
if (part.functionCall) {
// Preserve the whole part (including thoughtSignature)
// so it can be echoed verbatim in the replay turn.
callParts.push(part);
const call: NormalizedToolCall = {
id: part.functionCall.id ?? `${part.functionCall.name}-${toolCalls.length}`,
name: part.functionCall.name,
input: part.functionCall.args ?? {},
};
callbacks.onToolCallStart?.(call);
toolCalls.push(call);
}
}
const parts =
(chunk as { candidates?: { content?: { parts?: GeminiPart[] } }[] })
.candidates?.[0]?.content?.parts ?? [];
for (const part of parts) {
if (part.text) {
if (part.thought) {
sawThinking = true;
callbacks.onReasoningDelta?.(part.text);
} else {
textParts.push(part.text);
callbacks.onContentDelta?.(part.text);
}
}
} catch (error) {
if (params.abortSignal?.aborted) throw abortError();
throw new Error(geminiErrorMessage(error));
} finally {
params.abortSignal?.removeEventListener("abort", onAbort);
if (params.abortSignal?.aborted) {
await iterator.return?.();
if (part.functionCall) {
// Preserve the whole part (including thoughtSignature)
// so it can be echoed verbatim in the replay turn.
callParts.push(part);
const call: NormalizedToolCall = {
id:
part.functionCall.id ??
`${part.functionCall.name}-${toolCalls.length}`,
name: part.functionCall.name,
input: part.functionCall.args ?? {},
};
callbacks.onToolCallStart?.(call);
toolCalls.push(call);
}
}
}
if (sawThinking) callbacks.onReasoningBlockEnd?.();
throwIfAborted(params.abortSignal);
fullText += textParts.join("");
if (!toolCalls.length || !runTools) {
break;
} catch (error) {
if (params.abortSignal?.aborted) throw abortError();
throw new Error(geminiErrorMessage(error));
} finally {
params.abortSignal?.removeEventListener("abort", onAbort);
if (params.abortSignal?.aborted) {
await iterator.return?.();
}
}
const results = await runTools(toolCalls);
throwIfAborted(params.abortSignal);
if (sawThinking) callbacks.onReasoningBlockEnd?.();
throwIfAborted(params.abortSignal);
// Append the model's turn (text + functionCall parts, in that order)
// and the matching functionResponse turn.
const modelParts: GeminiPart[] = [];
if (textParts.length) modelParts.push({ text: textParts.join("") });
for (const cp of callParts) modelParts.push(cp);
contents.push({ role: "model", parts: modelParts });
fullText += textParts.join("");
contents.push({
role: "user",
parts: results.map((r) => {
const match = toolCalls.find((c) => c.id === r.tool_use_id);
return {
functionResponse: {
...(r.tool_use_id && !r.tool_use_id.startsWith(match?.name ?? "")
? { id: r.tool_use_id }
: {}),
name: match?.name ?? "tool",
response: { output: r.content },
},
};
}),
});
if (!toolCalls.length || !runTools) {
break;
}
const results = await runTools(toolCalls);
throwIfAborted(params.abortSignal);
// Append the model's turn (text + functionCall parts, in that order)
// and the matching functionResponse turn.
const modelParts: GeminiPart[] = [];
if (textParts.length) modelParts.push({ text: textParts.join("") });
for (const cp of callParts) modelParts.push(cp);
contents.push({ role: "model", parts: modelParts });
contents.push({
role: "user",
parts: results.map((r) => {
const match = toolCalls.find((c) => c.id === r.tool_use_id);
return {
functionResponse: {
...(r.tool_use_id && !r.tool_use_id.startsWith(match?.name ?? "")
? { id: r.tool_use_id }
: {}),
name: match?.name ?? "tool",
response: { output: r.content },
},
};
}),
});
}
await rawStreamRecorder?.flush("completed");
return { fullText };
} catch (error) {
await rawStreamRecorder?.flush("error", error);
throw error;
}
}
export async function completeGeminiText(params: {
model: string;
systemPrompt?: string;
user: string;
apiKeys?: { gemini?: string | null };
model: string;
systemPrompt?: string;
user: string;
apiKeys?: { gemini?: string | null };
}): Promise<string> {
const ai = client(params.apiKeys?.gemini);
let resp: Awaited<ReturnType<typeof ai.models.generateContent>>;
try {
resp = await ai.models.generateContent({
model: params.model,
contents: [{ role: "user", parts: [{ text: params.user }] }],
config: params.systemPrompt
? { systemInstruction: params.systemPrompt }
: undefined,
});
} catch (error) {
throw new Error(geminiErrorMessage(error));
}
return resp.text ?? "";
const ai = client(params.apiKeys?.gemini);
let resp: Awaited<ReturnType<typeof ai.models.generateContent>>;
try {
resp = await ai.models.generateContent({
model: params.model,
contents: [{ role: "user", parts: [{ text: params.user }] }],
config: params.systemPrompt
? { systemInstruction: params.systemPrompt }
: undefined,
});
} catch (error) {
throw new Error(geminiErrorMessage(error));
}
return resp.text ?? "";
}

View file

@ -4,8 +4,14 @@ import type { Provider } from "./types";
// Canonical model IDs
// ---------------------------------------------------------------------------
// Main-chat tier (top-end) — user picks one of these per message.
export const CLAUDE_MAIN_MODELS = ["claude-opus-4-7", "claude-sonnet-4-6"] as const;
export const CLAUDE_MAIN_MODELS = [
"claude-fable-5",
"claude-opus-4-8",
"claude-opus-4-7",
"claude-sonnet-4-6",
] as const;
export const GEMINI_MAIN_MODELS = [
"gemini-3.5-flash",
"gemini-3.1-pro-preview",
"gemini-3-flash-preview",
] as const;
@ -13,7 +19,7 @@ export const OPENAI_MAIN_MODELS = ["gpt-5.5", "gpt-5.4"] as const;
// Mid-tier (used for tabular review) — user picks one in account settings.
export const CLAUDE_MID_MODELS = ["claude-sonnet-4-6"] as const;
export const GEMINI_MID_MODELS = ["gemini-3-flash-preview"] as const;
export const GEMINI_MID_MODELS = ["gemini-3.5-flash", "gemini-3-flash-preview"] as const;
export const OPENAI_MID_MODELS = ["gpt-5.4"] as const;
// Low-tier (used for title generation, lightweight extractions) — user picks

View file

@ -1,363 +1,400 @@
import type {
LlmMessage,
NormalizedToolCall,
NormalizedToolResult,
OpenAIToolSchema,
StreamChatParams,
StreamChatResult,
LlmMessage,
NormalizedToolCall,
NormalizedToolResult,
OpenAIToolSchema,
StreamChatParams,
StreamChatResult,
} from "./types";
import { logRawLlmStream } from "./rawStreamLog";
import { createRawLlmStreamRecorder, logRawLlmStream } from "./rawStreamLog";
const OPENAI_RESPONSES_URL = "https://api.openai.com/v1/responses";
const MAX_OUTPUT_TOKENS = 16384;
const COURTLISTENER_CITATION_REMINDER_TOOL_NAMES = new Set([
"courtlistener_find_in_case",
"courtlistener_read_case",
]);
const COURTLISTENER_CITATION_REMINDER = `COURTLISTENER CITATION REMINDER:
If your final answer relies on any CourtListener case, every such case reference must have BOTH a clickable markdown case link and an inline [N] marker.
Include the clickable case link only the first time you cite that case; later references to the same case should reuse the existing inline [N] marker without repeating the link unless clarity requires it.
Assign new refs in first-use order as much as possible: [1], then [2], then [3]. Reuse an existing ref when citing the same case/passage again, even if that means a later sentence cites [3] and then [1] again.
End the response with a <CITATIONS> block containing one matching case entry per [N] marker:
{"ref": N, "cluster_id": 123, "quotes": [{"opinion_id": 456, "quote": "exact verbatim opinion text"}]}.
Do not use doc_id, page, top-level quote, case_name, or citation fields for CourtListener case entries.`;
type ResponseInputItem =
| { role: "user" | "assistant"; content: string }
| { type: "function_call_output"; call_id: string; output: string };
| { role: "user" | "assistant"; content: string }
| { type: "function_call_output"; call_id: string; output: string };
type ResponseFunctionTool = {
type: "function";
name: string;
description?: string;
parameters: Record<string, unknown>;
type: "function";
name: string;
description?: string;
parameters: Record<string, unknown>;
};
type ResponseFunctionCallItem = {
type: "function_call";
call_id?: string;
name?: string;
arguments?: string;
type: "function_call";
call_id?: string;
name?: string;
arguments?: string;
};
type ResponseStreamEvent = {
type?: string;
delta?: string;
response?: {
id?: string;
output_text?: string;
status?: string;
error?: { code?: string; message?: string } | null;
};
type?: string;
delta?: string;
response?: {
id?: string;
output_text?: string;
status?: string;
error?: { code?: string; message?: string } | null;
item?: ResponseFunctionCallItem;
};
error?: { code?: string; message?: string } | null;
item?: ResponseFunctionCallItem;
};
function apiKey(override?: string | null): string {
const key = override?.trim() || process.env.OPENAI_API_KEY?.trim() || "";
if (!key) {
throw new Error(
"OpenAI API key is not configured. Set OPENAI_API_KEY or add a user OpenAI key.",
);
}
return key;
const key = override?.trim() || process.env.OPENAI_API_KEY?.trim() || "";
if (!key) {
throw new Error(
"OpenAI API key is not configured. Set OPENAI_API_KEY or add a user OpenAI key.",
);
}
return key;
}
function toResponseTools(tools: OpenAIToolSchema[]): ResponseFunctionTool[] {
return tools.map((tool) => ({
type: "function",
name: tool.function.name,
description: tool.function.description,
parameters: tool.function.parameters,
}));
return tools.map((tool) => ({
type: "function",
name: tool.function.name,
description: tool.function.description,
parameters: tool.function.parameters,
}));
}
function toResponseInput(messages: LlmMessage[]): ResponseInputItem[] {
return messages.map((message) => ({
role: message.role,
content: message.content,
}));
return messages.map((message) => ({
role: message.role,
content: message.content,
}));
}
function extractSseJson(buffer: string): { events: unknown[]; rest: string } {
const events: unknown[] = [];
const chunks = buffer.split(/\n\n/);
const rest = chunks.pop() ?? "";
const events: unknown[] = [];
const chunks = buffer.split(/\n\n/);
const rest = chunks.pop() ?? "";
for (const chunk of chunks) {
const dataLines = chunk
.split("\n")
.map((line) => line.trim())
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice(5).trim());
for (const chunk of chunks) {
const dataLines = chunk
.split("\n")
.map((line) => line.trim())
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice(5).trim());
for (const data of dataLines) {
if (!data || data === "[DONE]") continue;
try {
events.push(JSON.parse(data));
} catch {
// Incomplete events stay buffered until the next read.
}
}
for (const data of dataLines) {
if (!data || data === "[DONE]") continue;
try {
events.push(JSON.parse(data));
} catch {
// Incomplete events stay buffered until the next read.
}
}
}
return { events, rest };
return { events, rest };
}
function parseFunctionCall(item: ResponseFunctionCallItem): NormalizedToolCall {
let input: Record<string, unknown> = {};
try {
const parsed = JSON.parse(item.arguments || "{}");
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
input = parsed as Record<string, unknown>;
}
} catch {
input = {};
let input: Record<string, unknown> = {};
try {
const parsed = JSON.parse(item.arguments || "{}");
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
input = parsed as Record<string, unknown>;
}
} catch {
input = {};
}
return {
id: item.call_id ?? item.name ?? "function_call",
name: item.name ?? "",
input,
};
return {
id: item.call_id ?? item.name ?? "function_call",
name: item.name ?? "",
input,
};
}
function openAIStreamFailureMessage(event: ResponseStreamEvent): string | null {
const error = event.response?.error ?? event.error ?? null;
const failed =
event.type === "response.failed" ||
event.response?.status === "failed" ||
!!error;
if (!failed) return null;
const error = event.response?.error ?? event.error ?? null;
const failed =
event.type === "response.failed" ||
event.response?.status === "failed" ||
!!error;
if (!failed) return null;
const message =
typeof error?.message === "string" && error.message.trim()
? error.message.trim()
: "OpenAI response failed.";
const code =
typeof error?.code === "string" && error.code.trim()
? error.code.trim()
: null;
return code ? `OpenAI error (${code}): ${message}` : message;
const message =
typeof error?.message === "string" && error.message.trim()
? error.message.trim()
: "OpenAI response failed.";
const code =
typeof error?.code === "string" && error.code.trim()
? error.code.trim()
: null;
return code ? `OpenAI error (${code}): ${message}` : message;
}
function abortError(): Error {
const err = new Error("Stream aborted.");
err.name = "AbortError";
return err;
const err = new Error("Stream aborted.");
err.name = "AbortError";
return err;
}
function throwIfAborted(signal?: AbortSignal) {
if (signal?.aborted) throw abortError();
if (signal?.aborted) throw abortError();
}
function responseInstructions(systemPrompt: string, includeReminder: boolean) {
return includeReminder
? `${systemPrompt}\n\n${COURTLISTENER_CITATION_REMINDER}`
: systemPrompt;
}
function shouldAppendCourtlistenerCitationReminder(call: NormalizedToolCall) {
return COURTLISTENER_CITATION_REMINDER_TOOL_NAMES.has(call.name);
}
async function createResponse(params: {
model: string;
input: ResponseInputItem[];
instructions?: string;
tools?: ResponseFunctionTool[];
stream?: boolean;
maxTokens?: number;
previousResponseId?: string;
reasoningSummary?: boolean;
apiKey: string;
signal?: AbortSignal;
model: string;
input: ResponseInputItem[];
instructions?: string;
tools?: ResponseFunctionTool[];
stream?: boolean;
maxTokens?: number;
previousResponseId?: string;
reasoningSummary?: boolean;
apiKey: string;
signal?: AbortSignal;
}): Promise<Response> {
const response = await fetch(OPENAI_RESPONSES_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${params.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: params.model,
instructions: params.instructions || undefined,
input: params.input,
tools: params.tools?.length ? params.tools : undefined,
stream: params.stream,
max_output_tokens: params.maxTokens ?? MAX_OUTPUT_TOKENS,
previous_response_id: params.previousResponseId,
reasoning: params.reasoningSummary
? { summary: "auto" }
: undefined,
}),
signal: params.signal,
});
const response = await fetch(OPENAI_RESPONSES_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${params.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: params.model,
instructions: params.instructions || undefined,
input: params.input,
tools: params.tools?.length ? params.tools : undefined,
stream: params.stream,
max_output_tokens: params.maxTokens ?? MAX_OUTPUT_TOKENS,
previous_response_id: params.previousResponseId,
reasoning: params.reasoningSummary ? { summary: "auto" } : undefined,
}),
signal: params.signal,
});
if (!response.ok) {
const text = await response.text().catch(() => "");
const err = new Error(
`OpenAI request failed (${response.status}): ${text || response.statusText}`,
);
(err as { status?: number }).status = response.status;
throw err;
}
if (!response.ok) {
const text = await response.text().catch(() => "");
const err = new Error(
`OpenAI request failed (${response.status}): ${text || response.statusText}`,
);
(err as { status?: number }).status = response.status;
throw err;
}
return response;
return response;
}
export async function streamOpenAI(
params: StreamChatParams,
params: StreamChatParams,
): Promise<StreamChatResult> {
const {
model,
systemPrompt,
tools = [],
callbacks = {},
runTools,
apiKeys,
enableThinking,
} = params;
const maxIter = params.maxIterations ?? 10;
const key = apiKey(apiKeys?.openai);
const responseTools = toResponseTools(tools);
let input = toResponseInput(params.messages);
let previousResponseId: string | undefined;
let fullText = "";
const hasTools = responseTools.length > 0;
const {
model,
systemPrompt,
tools = [],
callbacks = {},
runTools,
apiKeys,
enableThinking,
} = params;
const maxIter = params.maxIterations ?? 10;
const key = apiKey(apiKeys?.openai);
const responseTools = toResponseTools(tools);
let input = toResponseInput(params.messages);
let previousResponseId: string | undefined;
let fullText = "";
let needsCourtlistenerCitationReminder = false;
const rawStreamRecorder = createRawLlmStreamRecorder({
provider: "openai",
model,
});
try {
for (let iter = 0; iter < maxIter; iter++) {
throwIfAborted(params.abortSignal);
const response = await createResponse({
model,
instructions: responseInstructions(
systemPrompt,
needsCourtlistenerCitationReminder,
),
input,
tools: responseTools,
stream: true,
previousResponseId,
reasoningSummary: !!enableThinking,
apiKey: key,
signal: params.abortSignal,
});
if (!response.body) throw new Error("OpenAI response had no body");
const reader = response.body.getReader();
const decoder = new TextDecoder();
const toolCalls: NormalizedToolCall[] = [];
const startedToolCallIds = new Set<string>();
let buffer = "";
let sawReasoning = false;
while (true) {
throwIfAborted(params.abortSignal);
const response = await createResponse({
model,
instructions: iter === 0 ? systemPrompt : undefined,
input,
tools: responseTools,
stream: true,
previousResponseId,
reasoningSummary: !!enableThinking,
apiKey: key,
signal: params.abortSignal,
const { done, value } = await reader.read();
if (done) break;
const decoded = decoder.decode(value, { stream: true });
logRawLlmStream({
provider: "openai",
model,
iteration: iter,
label: "sse_chunk",
payload: decoded,
});
if (!response.body) throw new Error("OpenAI response had no body");
rawStreamRecorder?.record({
iteration: iter,
label: "sse_chunk",
payload: decoded,
});
buffer += decoded;
const extracted = extractSseJson(buffer);
buffer = extracted.rest;
const reader = response.body.getReader();
const decoder = new TextDecoder();
const toolCalls: NormalizedToolCall[] = [];
const startedToolCallIds = new Set<string>();
let buffer = "";
let pendingText = "";
let sawReasoning = false;
for (const event of extracted.events as ResponseStreamEvent[]) {
logRawLlmStream({
provider: "openai",
model,
iteration: iter,
label: "sse_event",
payload: event,
});
rawStreamRecorder?.record({
iteration: iter,
label: "sse_event",
payload: event,
});
while (true) {
throwIfAborted(params.abortSignal);
const { done, value } = await reader.read();
if (done) break;
const failureMessage = openAIStreamFailureMessage(event);
if (failureMessage) {
throw new Error(failureMessage);
}
const decoded = decoder.decode(value, { stream: true });
logRawLlmStream({
provider: "openai",
model,
iteration: iter,
label: "sse_chunk",
payload: decoded,
});
buffer += decoded;
const extracted = extractSseJson(buffer);
buffer = extracted.rest;
if (event.response?.id) {
previousResponseId = event.response.id;
}
for (const event of extracted.events as ResponseStreamEvent[]) {
logRawLlmStream({
provider: "openai",
model,
iteration: iter,
label: "sse_event",
payload: event,
});
if (
event.type === "response.reasoning_summary_text.delta" &&
typeof event.delta === "string"
) {
sawReasoning = true;
callbacks.onReasoningDelta?.(event.delta);
}
const failureMessage = openAIStreamFailureMessage(event);
if (failureMessage) {
throw new Error(failureMessage);
}
if (
event.type === "response.output_text.delta" &&
typeof event.delta === "string"
) {
fullText += event.delta;
callbacks.onContentDelta?.(event.delta);
}
if (event.response?.id) {
previousResponseId = event.response.id;
}
if (
event.type === "response.output_item.added" &&
event.item?.type === "function_call"
) {
const call = parseFunctionCall(event.item);
startedToolCallIds.add(call.id);
callbacks.onToolCallStart?.(call);
}
if (
event.type === "response.reasoning_summary_text.delta" &&
typeof event.delta === "string"
) {
sawReasoning = true;
callbacks.onReasoningDelta?.(event.delta);
}
if (
event.type === "response.output_text.delta" &&
typeof event.delta === "string"
) {
if (hasTools) {
pendingText += event.delta;
} else {
fullText += event.delta;
callbacks.onContentDelta?.(event.delta);
}
}
if (
event.type === "response.output_item.added" &&
event.item?.type === "function_call"
) {
const call = parseFunctionCall(event.item);
startedToolCallIds.add(call.id);
callbacks.onToolCallStart?.(call);
}
if (
event.type === "response.output_item.done" &&
event.item?.type === "function_call"
) {
const call = parseFunctionCall(event.item);
if (!startedToolCallIds.has(call.id)) {
callbacks.onToolCallStart?.(call);
}
toolCalls.push(call);
}
if (
event.type === "response.output_item.done" &&
event.item?.type === "function_call"
) {
const call = parseFunctionCall(event.item);
if (!startedToolCallIds.has(call.id)) {
callbacks.onToolCallStart?.(call);
}
toolCalls.push(call);
}
}
}
if (sawReasoning) callbacks.onReasoningBlockEnd?.();
throwIfAborted(params.abortSignal);
if (sawReasoning) callbacks.onReasoningBlockEnd?.();
throwIfAborted(params.abortSignal);
if (!toolCalls.length || !runTools) {
if (pendingText) {
fullText += pendingText;
callbacks.onContentDelta?.(pendingText);
}
break;
}
if (!toolCalls.length || !runTools) {
break;
}
const results = await runTools(toolCalls);
throwIfAborted(params.abortSignal);
input = results.map((result) => ({
type: "function_call_output",
call_id: result.tool_use_id,
output: result.content,
}));
if (toolCalls.some(shouldAppendCourtlistenerCitationReminder)) {
needsCourtlistenerCitationReminder = true;
}
const results = await runTools(toolCalls);
throwIfAborted(params.abortSignal);
input = results.map((result) => ({
type: "function_call_output",
call_id: result.tool_use_id,
output: result.content,
}));
}
await rawStreamRecorder?.flush("completed");
return { fullText };
} catch (error) {
await rawStreamRecorder?.flush("error", error);
throw error;
}
}
export async function completeOpenAIText(params: {
model: string;
systemPrompt?: string;
user: string;
maxTokens?: number;
apiKeys?: { openai?: string | null };
model: string;
systemPrompt?: string;
user: string;
maxTokens?: number;
apiKeys?: { openai?: string | null };
}): Promise<string> {
const response = await createResponse({
model: params.model,
instructions: params.systemPrompt,
input: [{ role: "user", content: params.user }],
maxTokens: params.maxTokens ?? 512,
apiKey: apiKey(params.apiKeys?.openai),
});
const json = (await response.json()) as {
output_text?: string;
output?: {
content?: { type?: string; text?: string }[];
}[];
};
const response = await createResponse({
model: params.model,
instructions: params.systemPrompt,
input: [{ role: "user", content: params.user }],
maxTokens: params.maxTokens ?? 512,
apiKey: apiKey(params.apiKeys?.openai),
});
const json = (await response.json()) as {
output_text?: string;
output?: {
content?: { type?: string; text?: string }[];
}[];
};
if (typeof json.output_text === "string") return json.output_text;
if (typeof json.output_text === "string") return json.output_text;
return (
json.output
?.flatMap((item) => item.content ?? [])
.filter((content) => content.type === "output_text")
.map((content) => content.text ?? "")
.join("") ?? ""
);
return (
json.output
?.flatMap((item) => item.content ?? [])
.filter((content) => content.type === "output_text")
.map((content) => content.text ?? "")
.join("") ?? ""
);
}
export type { NormalizedToolResult };

View file

@ -1,14 +1,170 @@
export function logRawLlmStream(args: {
provider: string;
model: string;
iteration: number;
label: string;
payload: unknown;
}) {
if (process.env.LOG_RAW_LLM_STREAM !== "true") return;
import { randomUUID } from "crypto";
import { mkdir, open } from "fs/promises";
import type { FileHandle } from "fs/promises";
import path from "path";
console.log(
`[raw-llm-stream:${args.provider}:${args.model}:iter-${args.iteration}] ${args.label}`,
);
console.dir(args.payload, { depth: null, maxArrayLength: null });
type RawStreamEntry = {
timestamp: string;
iteration: number;
label: string;
payload: unknown;
};
function rawStreamLogDir(): string | null {
return process.env.RAW_LLM_STREAM_LOG_DIR?.trim() || null;
}
function safeFilePart(value: string) {
return value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
}
function stringifyJson(value: unknown) {
const seen = new WeakSet<object>();
return JSON.stringify(value, (_key, innerValue: unknown) => {
if (typeof innerValue === "bigint") return innerValue.toString();
if (innerValue instanceof Error) {
return {
name: innerValue.name,
message: innerValue.message,
stack: innerValue.stack,
};
}
if (innerValue && typeof innerValue === "object") {
if (seen.has(innerValue)) return "[Circular]";
seen.add(innerValue);
}
return innerValue;
});
}
export function logRawLlmStream(args: {
provider: string;
model: string;
iteration: number;
label: string;
payload: unknown;
}) {
if (process.env.LOG_RAW_LLM_STREAM !== "true") return;
console.log(
`[raw-llm-stream:${args.provider}:${args.model}:iter-${args.iteration}] ${args.label}`,
);
console.dir(args.payload, { depth: null, maxArrayLength: null });
}
export function createRawLlmStreamRecorder(args: {
provider: string;
model: string;
}) {
const dir = rawStreamLogDir();
if (!dir) return null;
const logDir = dir;
const startedAt = new Date();
const id = randomUUID();
const filename = [
safeFilePart(args.provider),
safeFilePart(args.model),
startedAt.toISOString().replace(/[:.]/g, "-"),
id,
].join("-");
const filePath = path.join(logDir, `${filename}.raw-llm-stream.json`);
let fileHandle: FileHandle | null = null;
let writeChain: Promise<void> = Promise.resolve();
let writeError: unknown = null;
let wroteEntry = false;
let finalized = false;
async function ensureOpen() {
if (fileHandle) return fileHandle;
await mkdir(logDir, { recursive: true });
fileHandle = await open(filePath, "w");
const header = {
id,
provider: args.provider,
model: args.model,
startedAt: startedAt.toISOString(),
};
await fileHandle.write(`${stringifyJson(header)?.slice(0, -1)},"entries":[`);
return fileHandle;
}
function queueWrite(action: () => Promise<void>) {
writeChain = writeChain
.then(action)
.catch((error) => {
writeError = error;
console.error("[raw-llm-stream] failed to write log file", {
filePath,
error: error instanceof Error ? error.message : String(error),
});
});
}
return {
record(entry: Omit<RawStreamEntry, "timestamp">) {
if (finalized) return;
const rawEntry = {
timestamp: new Date().toISOString(),
...entry,
};
queueWrite(async () => {
const handle = await ensureOpen();
const serialized =
stringifyJson(rawEntry) ??
stringifyJson({
timestamp: rawEntry.timestamp,
iteration: rawEntry.iteration,
label: rawEntry.label,
payload: "[Unserializable payload]",
});
await handle.write(`${wroteEntry ? "," : ""}${serialized}`);
wroteEntry = true;
});
},
async flush(status: "completed" | "error", error?: unknown) {
if (finalized) return;
finalized = true;
const errorPayload =
error instanceof Error
? {
name: error.name,
message: error.message,
stack: error.stack,
}
: error
? { message: String(error) }
: undefined;
const footer = {
finishedAt: new Date().toISOString(),
status,
error: errorPayload,
};
try {
await writeChain;
const handle = await ensureOpen();
await handle.write(`],${stringifyJson(footer)?.slice(1)}\n`);
} catch (writeError) {
console.error("[raw-llm-stream] failed to write log file", {
filePath,
error:
writeError instanceof Error
? writeError.message
: String(writeError),
});
} finally {
if (fileHandle) {
await fileHandle.close().catch(() => {});
fileHandle = null;
}
if (writeError) {
console.error("[raw-llm-stream] log file may be incomplete", {
filePath,
});
}
}
},
};
}

View file

@ -51,3 +51,31 @@ export async function getUserApiKeys(
const client = db ?? createServerSupabase();
return getStoredUserApiKeys(userId, client);
}
/**
* Whether the user has US legal research (CourtListener) tools enabled in
* chat. Controlled by the Features > Legal Research > Jurisdiction > US
* toggle in account settings. Defaults to enabled both when the user has
* no profile row yet and when the column is missing (migration not applied),
* so existing behaviour is preserved on partially-migrated deployments.
*/
export async function getLegalResearchUsEnabled(
userId: string,
db?: ReturnType<typeof createServerSupabase>,
): Promise<boolean> {
const client = db ?? createServerSupabase();
try {
const { data, error } = await client
.from("user_profiles")
.select("legal_research_us")
.eq("user_id", userId)
.maybeSingle();
if (error || !data) return true;
return (
(data as { legal_research_us?: boolean | null })
.legal_research_us !== false
);
} catch {
return true;
}
}

View file

@ -15,7 +15,11 @@ import {
type ChatMessage,
} from "../lib/chatTools";
import { completeText } from "../lib/llm";
import { getUserApiKeys, getUserModelSettings } from "../lib/userSettings";
import {
getLegalResearchUsEnabled,
getUserApiKeys,
getUserModelSettings,
} from "../lib/userSettings";
import { checkProjectAccess } from "../lib/access";
import { safeErrorLog, safeErrorMessage } from "../lib/safeError";
@ -552,7 +556,14 @@ chatRouter.post("/", requireAuth, async (req, res) => {
db,
docIndex,
);
const apiMessages = buildMessages(enrichedMessages, docAvailability);
const legalResearchUs = await getLegalResearchUsEnabled(userId, db);
const apiMessages = buildMessages(
enrichedMessages,
docAvailability,
undefined,
undefined,
legalResearchUs,
);
const workflowStore = await buildWorkflowStore(userId, userEmail, db);
@ -588,6 +599,7 @@ chatRouter.post("/", requireAuth, async (req, res) => {
db,
write,
workflowStore,
includeResearchTools: legalResearchUs,
model,
apiKeys,
signal: streamAbort.signal,

View file

@ -364,7 +364,7 @@ documentsRouter.get("/:documentId/versions", requireAuth, async (req, res) => {
const { data: rows } = await db
.from("document_versions")
.select(
"id, version_number, source, created_at, filename, file_type, size_bytes, page_count",
"id, version_number, source, created_at, filename, file_type, size_bytes, page_count, deleted_at, deleted_by",
)
.eq("document_id", documentId)
.order("created_at", { ascending: true });
@ -433,19 +433,12 @@ documentsRouter.post(
});
}
const targetActive = await loadActiveVersion(documentId, db);
const targetType = targetActive?.file_type ?? "";
const active = await loadActiveVersion(sourceDocumentId, db);
if (!active)
return void res
.status(404)
.json({ detail: "Source document has no active version." });
const sourceType = active.file_type ?? "";
if (targetType && sourceType && targetType !== sourceType) {
return void res.status(400).json({
detail: `Source document type (${sourceType}) does not match document type (${targetType}).`,
});
}
const bytes = await downloadFile(active.storage_path);
if (!bytes)
@ -603,8 +596,6 @@ documentsRouter.post(
if (!access.ok)
return void res.status(404).json({ detail: "Document not found" });
// Reject if the uploaded file's extension doesn't match the document's
// declared type — otherwise every downstream viewer/extractor breaks.
const suffix = file.originalname.includes(".")
? file.originalname.split(".").pop()!.toLowerCase()
: "";
@ -614,14 +605,6 @@ documentsRouter.post(
});
}
const currentActive = await loadActiveVersion(documentId, db);
const expectedType = currentActive?.file_type ?? "";
if (expectedType && expectedType !== suffix) {
return void res.status(400).json({
detail: `Uploaded file type (${suffix}) does not match document type (${expectedType}).`,
});
}
// Peg the new version into a predictable /versions/:id path under the
// existing document folder so ops can spot the history in storage.
const versionSlug = crypto.randomUUID().replace(/-/g, "");
@ -777,6 +760,7 @@ documentsRouter.patch(
.update({ filename })
.eq("id", versionId)
.eq("document_id", documentId)
.is("deleted_at", null)
.select(
"id, version_number, source, created_at, filename, file_type, size_bytes, page_count",
)
@ -788,6 +772,160 @@ documentsRouter.patch(
},
);
// PUT /single-documents/:documentId/versions/:versionId/file
// Replace the file bytes and metadata for an existing version while keeping
// its version number and id. This is destructive and owner-only.
documentsRouter.put(
"/:documentId/versions/:versionId/file",
requireAuth,
singleFileUpload("file"),
async (req, res) => {
const userId = res.locals.userId as string;
const userEmail = res.locals.userEmail as string | undefined;
const { documentId, versionId } = req.params;
const db = createServerSupabase();
const file = req.file;
if (!file)
return void res.status(400).json({ detail: "file is required" });
const { data: doc } = await db
.from("documents")
.select("id, user_id, project_id")
.eq("id", documentId)
.single();
if (!doc)
return void res.status(404).json({ detail: "Document not found" });
const access = await ensureDocAccess(doc, userId, userEmail, db);
if (!access.ok || !access.isOwner)
return void res.status(404).json({ detail: "Document not found" });
const { data: target, error: targetErr } = await db
.from("document_versions")
.select("id, storage_path, pdf_storage_path, file_type, deleted_at")
.eq("id", versionId)
.eq("document_id", documentId)
.single();
if (targetErr || !target)
return void res.status(404).json({ detail: "Version not found" });
if (target.deleted_at)
return void res.status(400).json({ detail: "Version is deleted." });
const suffix = file.originalname.includes(".")
? file.originalname.split(".").pop()!.toLowerCase()
: "";
if (!ALLOWED_TYPES.has(suffix)) {
return void res.status(400).json({
detail: `Unsupported file type: ${suffix}. Allowed: pdf, docx, doc`,
});
}
if (target.file_type && target.file_type !== suffix) {
return void res.status(400).json({
detail: `Uploaded file type (${suffix}) does not match version type (${target.file_type}).`,
});
}
const versionSlug = crypto.randomUUID().replace(/-/g, "");
const key = versionStorageKey(
userId,
documentId,
versionSlug,
file.originalname,
);
const contentType =
suffix === "pdf"
? "application/pdf"
: "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
try {
await uploadFile(
key,
file.buffer.buffer.slice(
file.buffer.byteOffset,
file.buffer.byteOffset + file.buffer.byteLength,
) as ArrayBuffer,
contentType,
);
} catch (e) {
console.error("[versions/replace] storage write failed", e);
return void res
.status(500)
.json({ detail: "Failed to upload replacement version." });
}
let pdfStoragePath: string | null = null;
if (suffix === "docx" || suffix === "doc") {
try {
const pdfBuf = await docxToPdf(file.buffer);
const pdfKey = `converted-pdfs/${userId}/${documentId}/${versionSlug}.pdf`;
await uploadFile(
pdfKey,
pdfBuf.buffer.slice(
pdfBuf.byteOffset,
pdfBuf.byteOffset + pdfBuf.byteLength,
) as ArrayBuffer,
"application/pdf",
);
pdfStoragePath = pdfKey;
} catch (err) {
console.error(
`[versions/replace] DOCX→PDF conversion failed for ${file.originalname}:`,
err,
);
}
} else if (suffix === "pdf") {
pdfStoragePath = key;
}
const rawBuf = file.buffer.buffer.slice(
file.buffer.byteOffset,
file.buffer.byteOffset + file.buffer.byteLength,
) as ArrayBuffer;
const pageCount = suffix === "pdf" ? await countPdfPages(rawBuf) : null;
const requestedFilename =
typeof req.body?.filename === "string" && req.body.filename.trim()
? req.body.filename.trim().slice(0, 200)
: file.originalname;
const uploadedAt = new Date().toISOString();
const { data: updated, error: updateErr } = await db
.from("document_versions")
.update({
storage_path: key,
pdf_storage_path: pdfStoragePath,
filename: requestedFilename,
file_type: suffix,
size_bytes: file.buffer.byteLength,
page_count: pageCount,
created_at: uploadedAt,
})
.eq("id", versionId)
.eq("document_id", documentId)
.select(
"id, version_number, source, created_at, filename, file_type, size_bytes, page_count",
)
.single();
if (updateErr || !updated) {
await Promise.all(
[key, pdfStoragePath]
.filter((path): path is string => !!path)
.map((path) => deleteFile(path).catch(() => {})),
);
return void res.status(500).json({
detail: updateErr?.message ?? "Failed to replace version.",
});
}
await Promise.all(
[target.storage_path, target.pdf_storage_path]
.filter((path): path is string => !!path)
.map((path) => deleteFile(path).catch(() => {})),
);
res.json(updated);
},
);
// DELETE /single-documents/:documentId/versions/:versionId
// Delete one version. The last remaining version cannot be deleted; if the
// deleted version is current, the newest remaining version becomes current.
@ -813,8 +951,11 @@ documentsRouter.delete(
const { data: versions, error: versionsErr } = await db
.from("document_versions")
.select("id, storage_path, pdf_storage_path, version_number, created_at")
.eq("document_id", documentId);
.select(
"id, storage_path, pdf_storage_path, version_number, created_at, deleted_at",
)
.eq("document_id", documentId)
.is("deleted_at", null);
if (versionsErr) {
return void res.status(500).json({ detail: versionsErr.message });
}
@ -825,6 +966,7 @@ documentsRouter.delete(
pdf_storage_path: string | null;
version_number: number | null;
created_at: string | null;
deleted_at?: string | null;
}[];
const target = rows.find((row) => row.id === versionId);
if (!target)
@ -850,6 +992,7 @@ documentsRouter.delete(
doc.current_version_id === versionId
? (remaining[0]?.id ?? null)
: doc.current_version_id;
const deletedAt = new Date().toISOString();
if (doc.current_version_id === versionId) {
const { error: updateErr } = await db
@ -866,9 +1009,15 @@ documentsRouter.delete(
const { error: deleteErr } = await db
.from("document_versions")
.delete()
.update({
storage_path: null,
pdf_storage_path: null,
deleted_at: deletedAt,
deleted_by: userId,
})
.eq("id", versionId)
.eq("document_id", documentId);
.eq("document_id", documentId)
.is("deleted_at", null);
if (deleteErr) {
return void res.status(500).json({ detail: deleteErr.message });
}
@ -882,6 +1031,7 @@ documentsRouter.delete(
res.json({
deleted_version_id: versionId,
current_version_id: nextCurrentVersionId,
deleted_at: deletedAt,
});
},
);

View file

@ -37,6 +37,7 @@ downloadsRouter.get("/:token", requireAuth, async (req, res) => {
.from("document_versions")
.select("id, document_id")
.eq("storage_path", info.path)
.is("deleted_at", null)
.maybeSingle();
if (byStoragePath) {
version = byStoragePath as { id: string; document_id: string };

View file

@ -15,7 +15,10 @@ import {
PROJECT_EXTRA_TOOLS,
type ChatMessage,
} from "../lib/chatTools";
import { getUserApiKeys } from "../lib/userSettings";
import {
getLegalResearchUsEnabled,
getUserApiKeys,
} from "../lib/userSettings";
import { checkProjectAccess } from "../lib/access";
import { safeErrorLog, safeErrorMessage } from "../lib/safeError";
@ -141,10 +144,13 @@ projectChatRouter.post("/", requireAuth, async (req, res) => {
systemPromptExtra += `\n\nUSER-ATTACHED DOCUMENTS FOR THIS TURN:\nThe user has attached the following document(s) directly to their latest message. Treat these as the primary focus of the request unless their message clearly says otherwise.\n${lines.join("\n")}`;
}
const legalResearchUs = await getLegalResearchUsEnabled(userId, db);
const apiMessages = buildMessages(
messagesForLLM,
docAvailability,
systemPromptExtra,
undefined,
legalResearchUs,
);
const workflowStore = await buildWorkflowStore(userId, userEmail, db);
@ -176,6 +182,7 @@ projectChatRouter.post("/", requireAuth, async (req, res) => {
write,
extraTools: PROJECT_EXTRA_TOOLS,
workflowStore,
includeResearchTools: legalResearchUs,
model,
apiKeys,
signal: streamAbort.signal,

View file

@ -60,6 +60,59 @@ async function deleteProjectDocumentsAndVersionFiles(
return error ?? null;
}
async function attachDocumentOwnerLabels(
db: ReturnType<typeof createServerSupabase>,
docs: { user_id?: string | null }[],
) {
const ownerIds = docs
.map((doc) => doc.user_id)
.filter((id): id is string => typeof id === "string" && id.length > 0)
.filter((id, index, arr) => arr.indexOf(id) === index);
if (ownerIds.length === 0) return;
const emailByUserId = new Map<string, string>();
const userResults = await Promise.allSettled(
ownerIds.map(async (id) => {
const { data, error } = await db.auth.admin.getUserById(id);
if (error) throw error;
return { id, email: data.user?.email ?? null };
}),
);
for (const result of userResults) {
if (result.status === "fulfilled" && result.value.email) {
emailByUserId.set(result.value.id, result.value.email);
}
}
const displayNameByUserId = new Map<string, string>();
const { data: profiles, error: profilesError } = await db
.from("user_profiles")
.select("user_id, display_name")
.in("user_id", ownerIds);
if (profilesError) {
console.warn("[projects] failed to load document owner profiles", profilesError);
}
for (const profile of profiles ?? []) {
const displayName =
typeof profile.display_name === "string"
? profile.display_name.trim()
: "";
if (displayName) {
displayNameByUserId.set(profile.user_id as string, displayName);
}
}
for (const doc of docs as ({
user_id?: string | null;
owner_email?: string | null;
owner_display_name?: string | null;
})[]) {
if (!doc.user_id) continue;
doc.owner_email = emailByUserId.get(doc.user_id) ?? null;
doc.owner_display_name = displayNameByUserId.get(doc.user_id) ?? null;
}
}
// GET /projects
projectsRouter.get("/", requireAuth, async (req, res) => {
const userId = res.locals.userId as string;
@ -190,10 +243,12 @@ projectsRouter.get("/:projectId", requireAuth, async (req, res) => {
]);
const docsTyped = (docs ?? []) as unknown as {
id: string;
user_id?: string | null;
current_version_id?: string | null;
}[];
await attachLatestVersionNumbers(db, docsTyped);
await attachActiveVersionPaths(db, docsTyped);
await attachDocumentOwnerLabels(db, docsTyped);
res.json({
...project,
is_owner: project.user_id === userId,
@ -335,9 +390,11 @@ projectsRouter.patch("/:projectId", requireAuth, async (req, res) => {
]);
const docsTyped = (docs ?? []) as unknown as {
id: string;
user_id?: string | null;
current_version_id?: string | null;
}[];
await attachActiveVersionPaths(db, docsTyped);
await attachDocumentOwnerLabels(db, docsTyped);
res.json({ ...data, documents: docsTyped, folders: folderData ?? [] });
});

View file

@ -41,6 +41,7 @@ type UserProfileRow = {
title_model: string | null;
tabular_model: string;
mfa_on_login: boolean | null;
legal_research_us: boolean | null;
};
function errorMessage(error: unknown): string {
@ -65,6 +66,8 @@ function errorMessage(error: unknown): string {
}
const PROFILE_SELECT =
"display_name, organisation, message_credits_used, credits_reset_date, tier, title_model, tabular_model, mfa_on_login, legal_research_us";
const PROFILE_SELECT_NO_LEGAL =
"display_name, organisation, message_credits_used, credits_reset_date, tier, title_model, tabular_model, mfa_on_login";
const LEGACY_PROFILE_SELECT =
"display_name, organisation, message_credits_used, credits_reset_date, tier, tabular_model";
@ -80,14 +83,43 @@ function isMissingProfileColumn(error: unknown, column: string): boolean {
return record.code === "42703" && message.includes(column);
}
// Loads a profile while tolerating older databases that lack the
// legal_research_us column. Tries the full select first, then falls back to
// the legacy cascade (which also handles missing title_model / mfa_on_login)
// and defaults the feature flag to enabled.
async function selectProfile(
db: ReturnType<typeof createServerSupabase>,
userId: string,
mode: "maybe" | "single",
) {
const fullQuery = db
.from("user_profiles")
.select(PROFILE_SELECT)
.eq("user_id", userId);
const full =
mode === "single"
? await fullQuery.single()
: await fullQuery.maybeSingle();
if (!full.error) return full;
const legacy = await selectProfileLegacy(db, userId, mode);
if (legacy.data && typeof legacy.data === "object") {
const row = legacy.data as Record<string, unknown>;
if (!("legal_research_us" in row)) {
Object.assign(row, { legal_research_us: true });
}
}
return legacy;
}
async function selectProfileLegacy(
db: ReturnType<typeof createServerSupabase>,
userId: string,
mode: "maybe" | "single",
) {
const query = db
.from("user_profiles")
.select(PROFILE_SELECT)
.select(PROFILE_SELECT_NO_LEGAL)
.eq("user_id", userId);
const result =
mode === "single" ? await query.single() : await query.maybeSingle();
@ -166,6 +198,7 @@ function serializeProfile(row: UserProfileRow, apiKeyStatus?: ApiKeyStatus) {
titleModel: resolveModel(row.title_model, titleFallback),
tabularModel: resolveModel(row.tabular_model, DEFAULT_TABULAR_MODEL),
mfaOnLogin: row.mfa_on_login === true,
legalResearchUs: row.legal_research_us !== false,
...(apiKeyStatus ? { apiKeyStatus } : {}),
};
}
@ -178,6 +211,7 @@ function validateProfilePayload(body: unknown):
organisation?: string | null;
title_model?: string;
tabular_model?: string;
legal_research_us?: boolean;
updated_at: string;
};
}
@ -192,6 +226,7 @@ function validateProfilePayload(body: unknown):
"organisation",
"titleModel",
"tabularModel",
"legalResearchUs",
]);
const invalidField = Object.keys(raw).find(
(key) => !allowedFields.has(key),
@ -208,6 +243,7 @@ function validateProfilePayload(body: unknown):
organisation?: string | null;
title_model?: string;
tabular_model?: string;
legal_research_us?: boolean;
updated_at: string;
} = { updated_at: new Date().toISOString() };
@ -253,6 +289,16 @@ function validateProfilePayload(body: unknown):
update.title_model = resolved;
}
if ("legalResearchUs" in raw) {
if (typeof raw.legalResearchUs !== "boolean") {
return {
ok: false,
detail: "legalResearchUs must be a boolean",
};
}
update.legal_research_us = raw.legalResearchUs;
}
return { ok: true, update };
}

View file

@ -32,6 +32,6 @@ export function accountTabButtonClassName(active: boolean) {
"flex h-9 w-full items-center rounded-lg px-3 text-left text-sm font-medium whitespace-nowrap transition-colors",
active
? "bg-gray-100 text-gray-900"
: "text-gray-500 hover:bg-white/55 hover:text-gray-900",
: "text-gray-500 hover:bg-gray-50 hover:text-gray-900",
);
}

View file

@ -0,0 +1,120 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Check } from "lucide-react";
import { useUserProfile } from "@/contexts/UserProfileContext";
import { accountGlassSectionClassName } from "../accountStyles";
export default function FeaturesPage() {
const { profile, updateLegalResearchUs } = useUserProfile();
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [draftLegalResearchUs, setDraftLegalResearchUs] = useState<
boolean | null
>(null);
const savedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (savedTimerRef.current) clearTimeout(savedTimerRef.current);
};
}, []);
const persistedLegalResearchUs = profile?.legalResearchUs ?? true;
const usEnabled = draftLegalResearchUs ?? persistedLegalResearchUs;
const hasChanges =
draftLegalResearchUs !== null &&
draftLegalResearchUs !== persistedLegalResearchUs;
const handleUpdateLegalResearch = async () => {
if (saving) return;
setSaved(false);
setSaveError(null);
setSaving(true);
const ok = await updateLegalResearchUs(usEnabled);
setSaving(false);
if (ok) {
setDraftLegalResearchUs(null);
setSaved(true);
if (savedTimerRef.current) clearTimeout(savedTimerRef.current);
savedTimerRef.current = setTimeout(() => setSaved(false), 1600);
} else {
setSaveError("Could not update. Try again.");
}
};
return (
<div className="space-y-8">
<section className="space-y-3">
<div className="flex items-center gap-2">
<h2 className="text-2xl font-medium font-serif text-gray-900">
Legal Research
</h2>
</div>
<div className={accountGlassSectionClassName}>
<div className="px-4 py-5">
<div className="space-y-1">
<p className="text-sm font-medium text-gray-900">
Jurisdiction
</p>
<p className="text-sm text-gray-500">
Choose which jurisdictions the assistant can
research. When a jurisdiction is enabled, its
case-law research tools are available in chat.
</p>
</div>
<div className="mt-4 flex items-start justify-between gap-3 px-3 bg-gray-50 py-3 rounded-md">
<label
htmlFor="jurisdiction-us"
className="min-w-0 cursor-pointer select-none"
>
<p className="text-sm text-gray-900">US</p>
<p className="text-sm text-gray-500">
Enable US case law research (CourtListener)
in chat.
</p>
</label>
<button
id="jurisdiction-us"
type="button"
role="checkbox"
aria-checked={usEnabled}
onClick={() => {
setDraftLegalResearchUs(!usEnabled);
setSaved(false);
setSaveError(null);
}}
disabled={saving}
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-sm border transition-colors ${
usEnabled
? "border-gray-950 bg-gray-950 text-white"
: "border-gray-300 bg-white text-transparent"
} disabled:cursor-not-allowed disabled:opacity-45`}
>
<Check className="h-3.5 w-3.5" />
</button>
</div>
<div className="mt-5 flex items-center justify-between gap-3">
<p className="text-sm text-red-600">
{saveError ?? ""}
</p>
<button
type="button"
onClick={() => void handleUpdateLegalResearch()}
disabled={saving || !hasChanges}
className="text-sm font-medium text-gray-700 transition-colors hover:text-gray-950 disabled:cursor-not-allowed disabled:text-gray-300"
>
{saving
? "Updating..."
: saved
? "Updated"
: "Update"}
</button>
</div>
</div>
</div>
</section>
</div>
);
}

View file

@ -14,6 +14,7 @@ interface TabDef {
const TABS: TabDef[] = [
{ id: "general", label: "General", href: "/account" },
{ id: "features", label: "Features", href: "/account/features" },
{
id: "privacy-data",
label: "Privacy & Data",

View file

@ -15,6 +15,8 @@ import {
ChevronRight,
FileText,
Loader2,
Pencil,
Trash2,
Upload,
X,
} from "lucide-react";
@ -45,6 +47,7 @@ import { useAuth } from "@/contexts/AuthContext";
import { useUserProfile } from "@/contexts/UserProfileContext";
import { useSidebar } from "@/app/contexts/SidebarContext";
import { PageHeader } from "@/app/components/shared/PageHeader";
import { HeaderActionsMenu } from "@/app/components/shared/HeaderActionsMenu";
import type {
CitationQuote,
CitationAnnotation,
@ -252,6 +255,7 @@ export default function ProjectAssistantChatPage({ params }: Props) {
setNewChatMessages,
chats,
saveChat,
renameChat: renameChatInHistory,
} = useChatHistoryContext();
const [initialMessages] = useState<Message[]>(newChatMessages ?? []);
const { messages, isResponseLoading, handleChat, setMessages, cancel } =
@ -591,6 +595,21 @@ export default function ProjectAssistantChatPage({ params }: Props) {
}
}
async function handleRenameChat() {
if (chatOwnerId && user?.id && chatOwnerId !== user.id) {
setOwnerOnlyAction("rename this chat");
return;
}
const nextTitle = window.prompt(
"Rename chat",
chatTitle ?? "Untitled New Chat",
);
const trimmed = nextTitle?.trim();
if (!trimmed || trimmed === chatTitle) return;
setChatTitle(trimmed);
await renameChatInHistory(chatId, trimmed);
}
// ── Upload ────────────────────────────────────────────────────────────────
async function uploadFiles(files: File[]) {
if (!files.length) return;
@ -794,10 +813,29 @@ export default function ProjectAssistantChatPage({ params }: Props) {
title: "New chat",
},
{
type: "delete",
onClick: handleDeleteChat,
loading: deletingChat,
title: "Delete chat",
type: "custom",
render: (
<HeaderActionsMenu
items={[
{
label: "Rename",
icon: Pencil,
onSelect: () =>
void handleRenameChat(),
},
{
label: deletingChat
? "Deleting..."
: "Delete",
icon: Trash2,
onSelect: () =>
void handleDeleteChat(),
disabled: deletingChat,
variant: "danger",
},
]}
/>
),
},
]}
/>

View file

@ -269,6 +269,7 @@ export default function TabularReviewsPage() {
<div className="flex-1 overflow-y-auto">
{/* Page header */}
<PageHeader
loading={loading}
actions={[
{
type: "search",

View file

@ -1195,11 +1195,11 @@ function MarkdownContent({
onClick={() =>
onCitationClick?.(annotation)
}
data-citation-ref={idx + 1}
data-citation-ref={annotation.ref}
className={`${RESPONSE_GLASS_ANNOTATION} mx-0.5 align-super`}
title={tooltipText}
>
{idx + 1}
{annotation.ref}
</button>
);
}
@ -1380,7 +1380,7 @@ function ensureTerminalPeriod(value: string): string {
function buildCitationAppendix(citations: CitationAnnotation[]) {
if (citations.length === 0) return { html: "", text: "" };
let previousSourceKey: string | null = null;
const entries = citations.map((annotation, index) => {
const entries = citations.map((annotation) => {
const sourceKey = citationSourceKey(annotation);
const label =
sourceKey === previousSourceKey
@ -1388,7 +1388,7 @@ function buildCitationAppendix(citations: CitationAnnotation[]) {
: citationSourceLabel(annotation);
previousSourceKey = sourceKey;
return {
number: index + 1,
number: annotation.ref,
label,
quote: displayCitationQuote(annotation).trim(),
};
@ -1484,7 +1484,7 @@ function CitationsBlock({
}
title={`${formatCitationPage(annotation)}: "${displayCitationQuote(annotation)}"`}
>
{index + 1}
{annotation.ref}
</button>
),
)}

View file

@ -30,30 +30,25 @@ export function AssistantWorkflowModal({
const [loading, setLoading] = useState(false);
const [selected, setSelected] = useState<Workflow | null>(null);
const [search, setSearch] = useState("");
const [rightVisible, setRightVisible] = useState(false);
useEffect(() => {
if (!selected) {
setRightVisible(false);
return;
}
const frame = requestAnimationFrame(() => setRightVisible(true));
return () => cancelAnimationFrame(frame);
}, [selected]);
useEffect(() => {
if (!open) {
setSelected(null);
setSearch("");
return;
}
if (!open) return;
let cancelled = false;
const builtins = BUILT_IN_WORKFLOWS.filter(
(w) => w.type === "assistant",
);
setWorkflows(builtins);
setLoading(true);
const frame = requestAnimationFrame(() => {
if (cancelled) return;
setWorkflows(builtins);
setLoading(true);
if (initialWorkflowId) {
const match = builtins.find((w) => w.id === initialWorkflowId);
if (match) setSelected(match);
}
});
listWorkflows("assistant")
.then((custom) => {
if (cancelled) return;
const all = [...builtins, ...custom];
setWorkflows(all);
if (initialWorkflowId) {
@ -62,17 +57,19 @@ export function AssistantWorkflowModal({
}
})
.catch(() => {
if (cancelled) return;
if (initialWorkflowId) {
const match = builtins.find((w) => w.id === initialWorkflowId);
if (match) setSelected(match);
}
})
.finally(() => setLoading(false));
// Pre-select from builtins immediately if possible
if (initialWorkflowId) {
const match = builtins.find((w) => w.id === initialWorkflowId);
if (match) setSelected(match);
}
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
cancelAnimationFrame(frame);
};
}, [open, initialWorkflowId]);
if (!open) return null;
@ -81,10 +78,16 @@ export function AssistantWorkflowModal({
? workflows.filter((w) => w.title.toLowerCase().includes(search.toLowerCase()))
: workflows;
function handleClose() {
setSelected(null);
setSearch("");
onClose();
}
function handleUse() {
if (!selected) return;
onSelect(selected);
onClose();
handleClose();
}
const breadcrumbs = projectName
@ -99,7 +102,7 @@ export function AssistantWorkflowModal({
return (
<Modal
open={open}
onClose={onClose}
onClose={handleClose}
size={selected ? "xl" : "lg"}
breadcrumbs={breadcrumbs}
primaryAction={{
@ -110,13 +113,13 @@ export function AssistantWorkflowModal({
}}
>
{/* Content */}
<div className="flex flex-row flex-1 min-h-0 overflow-hidden">
<div className="flex flex-row flex-1 min-h-0 overflow-hidden gap-3">
{/* Left panel — workflow list */}
<div
className={`overflow-y-auto ${selected ? "w-80 shrink-0" : "flex-1"}`}
className={`flex flex-col overflow-hidden ${selected ? "w-80 shrink-0" : "flex-1"}`}
>
{/* Search */}
<div className="pt-3 pb-2 shrink-0">
<div className="px-2 pt-3 pb-2 shrink-0">
<div className="flex items-center gap-1.5 rounded-md border border-gray-200 bg-gray-50 px-2.5 py-1">
<Search className="h-3 w-3 text-gray-400 shrink-0" />
<input
@ -135,11 +138,11 @@ export function AssistantWorkflowModal({
</div>
{loading ? (
<div className="space-y-px pt-1">
<div className="space-y-1 px-2 pb-2 pt-1">
{[60, 45, 75, 50, 65, 40, 55].map((w, i) => (
<div
key={i}
className="flex items-center justify-between gap-3 py-3 border-b border-gray-50"
className="flex items-center justify-between gap-3 rounded-lg px-3 py-2.5"
>
<div
className="h-3 rounded bg-gray-100 animate-pulse"
@ -154,35 +157,37 @@ export function AssistantWorkflowModal({
{search ? "No matches found" : "No assistant workflows found"}
</p>
) : (
filteredWorkflows.map((wf) => (
<button
key={wf.id}
type="button"
onClick={() =>
setSelected((prev) =>
prev?.id === wf.id ? null : wf,
)
}
className={`w-full flex items-center gap-3 px-4 py-3 text-xs text-left transition-colors border-b border-gray-50 ${
selected?.id === wf.id
? "bg-gray-50"
: "hover:bg-gray-50"
}`}
>
<span className="flex-1 truncate text-gray-800">
{wf.title}
</span>
<span className="shrink-0 text-xs text-gray-400">
{wf.is_system ? "Built-in" : "Custom"}
</span>
</button>
))
<div className="overflow-y-auto">
{filteredWorkflows.map((wf) => (
<button
key={wf.id}
type="button"
onClick={() =>
setSelected((prev) =>
prev?.id === wf.id ? null : wf,
)
}
className={`w-full flex items-center gap-3 rounded-lg px-3 py-2.5 text-xs text-left transition-colors ${
selected?.id === wf.id
? "bg-gray-100"
: "hover:bg-gray-50"
}`}
>
<span className="flex-1 truncate text-gray-800">
{wf.title}
</span>
<span className="shrink-0 text-xs text-gray-400">
{wf.is_system ? "Built-in" : "Custom"}
</span>
</button>
))}
</div>
)}
</div>
{/* Right panel — prompt preview */}
{selected && (
<div className={`flex-1 border-l border-gray-100 flex flex-col overflow-hidden px-3 pb-3 transition-opacity duration-200 ${rightVisible ? "opacity-100" : "opacity-0"}`}>
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex items-center justify-between py-3 shrink-0">
<p className="text-xs font-medium text-gray-700">
Workflow Prompt

View file

@ -20,8 +20,11 @@ export interface ModelOption {
}
export const MODELS: ModelOption[] = [
{ id: "claude-fable-5", label: "Claude Fable 5", group: "Anthropic" },
{ id: "claude-opus-4-8", label: "Claude Opus 4.8", group: "Anthropic" },
{ id: "claude-opus-4-7", label: "Claude Opus 4.7", group: "Anthropic" },
{ id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6", group: "Anthropic" },
{ id: "gemini-3.5-flash", label: "Gemini 3.5 Flash", group: "Google" },
{ id: "gemini-3.1-pro-preview", label: "Gemini 3.1 Pro", group: "Google" },
{ id: "gemini-3-flash-preview", label: "Gemini 3 Flash", group: "Google" },
{ id: "gpt-5.5", label: "GPT-5.5", group: "OpenAI" },

View file

@ -29,11 +29,11 @@ export function UserMessage({ content, files, workflow }: Props) {
return (
<div
key={i}
className="inline-flex items-center gap-1 pl-2 pr-2.5 py-0.5 rounded-full text-xs text-white shadow border border-black bg-black"
className="inline-flex items-center gap-1 rounded-[10px] border border-white/70 bg-white py-0.5 pl-2 pr-2.5 text-xs text-gray-800 shadow-[0_2px_6px_rgba(15,23,42,0.08),inset_0_1px_0_rgba(255,255,255,0.9)] backdrop-blur-xl"
>
{isPdf
? <FileText className="h-2.5 w-2.5 shrink-0 text-red-400" />
: <File className="h-2.5 w-2.5 shrink-0 text-blue-400" />
? <FileText className="h-2.5 w-2.5 shrink-0 text-red-500" />
: <File className="h-2.5 w-2.5 shrink-0 text-blue-500" />
}
<span className="max-w-[140px] truncate">{f.filename}</span>
</div>

View file

@ -18,7 +18,7 @@ import { WarningPopup } from "@/app/components/shared/WarningPopup";
import type { Document } from "@/app/components/shared/types";
import type { DocumentVersion } from "@/app/lib/mikeApi";
import { cn } from "@/lib/utils";
import { formatBytes, formatDate } from "./ProjectPageParts";
import { formatBytes } from "./ProjectPageParts";
const MIN_DOC_COLUMN_WIDTH = 420;
const DEFAULT_DOC_COLUMN_WIDTH = 620;
@ -27,7 +27,7 @@ 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";
"inline-flex h-8 items-center justify-center gap-1.5 rounded-full border border-blue-800/35 bg-blue-700/90 px-3 text-xs font-medium text-white shadow-[0_3px_9px_rgba(30,64,175,0.16),inset_0_1px_0_rgba(255,255,255,0.22),inset_0_-4px_9px_rgba(30,64,175,0.18)] backdrop-blur-xl transition-all hover:bg-blue-700 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";
@ -57,6 +57,14 @@ interface DocumentSidePanelProps {
file: File,
filename: string,
) => Promise<void>;
onReplaceVersion: (
docId: string,
versionId: string,
file: File,
filename: string,
) => Promise<void> | void;
canDelete?: boolean;
onOwnerOnlyAction?: (action: string) => void;
onDelete: (doc: Document) => Promise<void> | void;
}
@ -73,6 +81,9 @@ export function DocumentSidePanel({
onRenameVersion,
onDeleteVersion,
onUploadNewVersion,
onReplaceVersion,
canDelete = true,
onOwnerOnlyAction,
onDelete,
}: DocumentSidePanelProps) {
const [mounted, setMounted] = useState(false);
@ -86,6 +97,13 @@ export function DocumentSidePanel({
const [deletingVersionId, setDeletingVersionId] = useState<string | null>(
null,
);
const [replaceTargetVersion, setReplaceTargetVersion] =
useState<DocumentVersion | null>(null);
const [replaceFile, setReplaceFile] = useState<File | null>(null);
const [replaceConfirmOpen, setReplaceConfirmOpen] = useState(false);
const [replacingVersionId, setReplacingVersionId] = useState<string | null>(
null,
);
const [deletingDocument, setDeletingDocument] = useState(false);
const [confirmDeleteDocumentOpen, setConfirmDeleteDocumentOpen] =
useState(false);
@ -98,8 +116,13 @@ export function DocumentSidePanel({
const [panelWidth, setPanelWidth] = useState(
DEFAULT_DOC_COLUMN_WIDTH + RESIZER_WIDTH + DEFAULT_DATA_COLUMN_WIDTH,
);
const [isMobile, setIsMobile] = useState(false);
const [mobilePane, setMobilePane] = useState<"document" | "details">(
"document",
);
const panelRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const replaceFileInputRef = useRef<HTMLInputElement>(null);
const dragStartX = useRef(0);
const dragStartDataWidth = useRef(DEFAULT_DATA_COLUMN_WIDTH);
const dragStartPanelWidth = useRef(
@ -111,6 +134,7 @@ export function DocumentSidePanel({
useEffect(() => {
if (!mounted) return;
function handleWindowResize() {
setIsMobile(window.innerWidth < 768);
setPanelWidth((width) => clampPanelWidth(width, dataColumnWidth));
}
handleWindowResize();
@ -129,14 +153,21 @@ export function DocumentSidePanel({
setNameDraft("");
setNameError(null);
setExtensionWarningOpen(false);
setReplaceTargetVersion(null);
setReplaceFile(null);
setReplaceConfirmOpen(false);
setMobilePane("document");
}, [doc?.id, versionId, currentVersionId]);
if (!mounted || !doc) return null;
const activeDoc = doc;
const documentId = activeDoc.id;
const accept = doc.file_type === "pdf" ? ".pdf" : ".docx,.doc";
const newVersionAccept = ".pdf,.docx,.doc";
const orderedVersions = [...versions].reverse();
const activeVersionCount = versions.filter(
(version) => version.deleted_at == null,
).length;
const selectedVersion =
versions.find((version) => version.id === versionId) ??
versions.find((version) => version.id === currentVersionId) ??
@ -158,8 +189,19 @@ export function DocumentSidePanel({
: selectedVersion.page_count;
const selectedVersionNumber =
selectedVersion?.version_number ?? doc.active_version_number ?? null;
const selectedVersionTag =
selectedVersionNumber != null ? `V${selectedVersionNumber}` : null;
const selectedUploadedAt = selectedVersion?.created_at ?? doc.created_at;
const selectedExtension = filenameExtension(selectedFilename);
const replaceFileType = replaceTargetVersion
? fileTypeForVersion(replaceTargetVersion, selectedFileType)
: selectedFileType;
const replaceVersionAccept =
replaceFileType === "pdf" ? ".pdf" : ".docx,.doc";
const ownerLabel =
doc.owner_display_name?.trim() ||
doc.owner_email?.trim() ||
"—";
async function handleSaveName() {
if (!selectedVersionId) return;
@ -208,6 +250,10 @@ export function DocumentSidePanel({
}
async function handleDeleteVersion(versionIdToDelete: string) {
if (!canDelete) {
onOwnerOnlyAction?.("delete this document version");
return;
}
setDeletingVersionId(versionIdToDelete);
try {
await onDeleteVersion(documentId, versionIdToDelete);
@ -218,6 +264,46 @@ export function DocumentSidePanel({
}
}
function requestReplaceVersion(version: DocumentVersion) {
setUploadError(null);
setReplaceTargetVersion(version);
setReplaceFile(null);
window.setTimeout(() => replaceFileInputRef.current?.click(), 0);
}
function handleReplaceFileInputChange(
e: React.ChangeEvent<HTMLInputElement>,
) {
const file = e.target.files?.[0] ?? null;
e.target.value = "";
if (!file || !replaceTargetVersion) return;
setReplaceFile(file);
setReplaceConfirmOpen(true);
}
async function handleConfirmReplaceVersion() {
if (!replaceTargetVersion || !replaceFile) return;
const targetId = replaceTargetVersion.id;
setReplacingVersionId(targetId);
setUploadError(null);
try {
await onReplaceVersion(
documentId,
targetId,
replaceFile,
replaceFile.name,
);
setReplaceConfirmOpen(false);
setReplaceTargetVersion(null);
setReplaceFile(null);
} catch (err) {
console.error("replace version failed", err);
setUploadError("Could not replace this version.");
} finally {
setReplacingVersionId(null);
}
}
async function handleDeleteDocument() {
if (deleteDocumentStatus === "deleting") return;
setDeleteDocumentStatus("deleting");
@ -239,6 +325,10 @@ export function DocumentSidePanel({
}
function requestDeleteDocument() {
if (!canDelete) {
onOwnerOnlyAction?.("delete this document");
return;
}
if (versions.length > 1) {
setDeleteDocumentStatus("idle");
setConfirmDeleteDocumentOpen(true);
@ -313,27 +403,53 @@ export function DocumentSidePanel({
ref={panelRef}
className={cn(
"fixed z-[190] flex flex-col",
"inset-y-3 right-3 rounded-2xl border border-white/70 bg-white/72 shadow-[0_8px_24px_rgba(15,23,42,0.12),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-10px_24px_rgba(255,255,255,0.18),inset_1px_0_0_rgba(255,255,255,0.5)] backdrop-blur-2xl overflow-hidden",
"inset-3 md:left-auto rounded-2xl border border-white/70 bg-gray-50/80 shadow-[0_8px_24px_rgba(15,23,42,0.12),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-10px_24px_rgba(255,255,255,0.18),inset_1px_0_0_rgba(255,255,255,0.5)] backdrop-blur-2xl overflow-hidden",
)}
style={{ width: panelWidth }}
style={isMobile ? undefined : { width: panelWidth }}
>
<div
onMouseDown={handlePanelResizeMouseDown}
className="absolute inset-y-0 left-0 z-20 w-1 cursor-col-resize bg-transparent transition-colors hover:bg-blue-400/60"
className="absolute inset-y-0 left-0 z-20 hidden w-1 cursor-col-resize bg-transparent transition-colors hover:bg-blue-400/60 md:block"
title="Resize document view"
/>
<div
className={cn(
"flex h-11 shrink-0 items-center justify-between px-4",
"border-b border-white/60 bg-white/35",
)}
>
<div className="min-w-0">
<div className="truncate text-sm font-medium text-gray-700">
<div className="flex min-h-11 shrink-0 items-center justify-between gap-3 px-4 py-2 md:h-11 md:py-0">
<div className="flex min-w-0 items-center gap-2">
{selectedVersionTag && (
<span className="inline-flex h-5 shrink-0 items-center justify-center rounded-md border border-gray-200 bg-white/75 px-2 text-[10px] font-semibold text-gray-600">
{selectedVersionTag}
</span>
)}
<div className="min-w-0 truncate text-sm font-medium text-gray-700">
{selectedFilename}
</div>
</div>
<div className="flex items-center gap-1">
<div className="flex shrink-0 items-center gap-1.5">
<div className="flex h-7 items-center rounded-full bg-gray-200/70 p-0.5 md:hidden">
<button
type="button"
onClick={() => setMobilePane("document")}
className={cn(
"h-6 rounded-full px-2 text-[11px] font-medium transition-colors",
mobilePane === "document"
? "bg-white text-gray-900 shadow-[0_1px_3px_rgba(15,23,42,0.08)]"
: "text-gray-500 hover:text-gray-800",
)}
>
Document
</button>
<button
type="button"
onClick={() => setMobilePane("details")}
className={cn(
"h-6 rounded-full px-2 text-[11px] font-medium transition-colors",
mobilePane === "details"
? "bg-white text-gray-900 shadow-[0_1px_3px_rgba(15,23,42,0.08)]"
: "text-gray-500 hover:text-gray-800",
)}
>
Details
</button>
</div>
<button
type="button"
onClick={onClose}
@ -348,27 +464,31 @@ export function DocumentSidePanel({
<div
className="grid min-h-0 flex-1"
style={{
gridTemplateColumns: `minmax(${MIN_DOC_COLUMN_WIDTH}px, 1fr) ${RESIZER_WIDTH}px ${dataColumnWidth}px`,
gridTemplateColumns: isMobile
? "minmax(0, 1fr)"
: `minmax(${MIN_DOC_COLUMN_WIDTH}px, 1fr) ${RESIZER_WIDTH}px ${dataColumnWidth}px`,
}}
>
<section
className={cn(
"flex min-h-0 min-w-0 pb-3 pl-3",
"bg-white/20",
"min-h-0 min-w-0 p-3 pt-0 md:flex md:pr-0",
mobilePane === "document" ? "flex" : "hidden",
)}
>
<div
className={cn(
"flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden",
"rounded-xl border border-white/60 bg-white/55 shadow-[inset_0_1px_0_rgba(255,255,255,0.8)] backdrop-blur-xl",
"rounded-xl border border-gray-200 bg-white/55 shadow-[inset_0_1px_0_rgba(255,255,255,0.8)] backdrop-blur-xl",
)}
>
<DocView
key={selectedVersionId ?? "current"}
key={`${selectedVersionId ?? "current"}:${selectedUploadedAt ?? ""}:${selectedSizeBytes ?? ""}`}
doc={{
document_id: doc.id,
version_id: selectedVersionId,
}}
rounded={false}
bordered={false}
/>
</div>
</section>
@ -376,7 +496,7 @@ export function DocumentSidePanel({
<div
onMouseDown={handleResizeMouseDown}
className={cn(
"relative cursor-col-resize transition-colors",
"relative hidden cursor-col-resize transition-colors md:block",
"bg-white/25 hover:bg-blue-400/60",
)}
title="Resize document panel"
@ -384,16 +504,12 @@ export function DocumentSidePanel({
<aside
className={cn(
"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",
"mx-3 mb-3 min-h-0 flex-col overflow-hidden rounded-xl md:ml-2 md:mr-3",
mobilePane === "details" ? "flex" : "hidden md:flex",
"border border-white/70 bg-white shadow-[0_3px_9px_rgba(15,23,42,0.045),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 py-3",
"border-b border-white/60",
)}
>
<div className={cn("shrink-0 p-4")}>
<div className="mb-4">
<div className="mb-3 text-xs font-medium text-gray-900">
Name
@ -490,25 +606,30 @@ export function DocumentSidePanel({
: "—"
}
/>
<DataRow label="Owner" value={ownerLabel} />
<DataRow
label="Uploaded"
value={
selectedUploadedAt
? formatDate(selectedUploadedAt)
? formatDateTime(
selectedUploadedAt,
)
: "—"
}
/>
<DataRow
label="Pages"
value={
selectedPageCount != null
? String(selectedPageCount)
: "—"
}
/>
{selectedPageCount != null && (
<DataRow
label="Pages"
value={String(selectedPageCount)}
/>
)}
</div>
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col px-4 pb-3 pt-0">
<div className="flex min-h-0 flex-1 flex-col px-4 pt-0">
<div className="mb-2 text-xs font-medium text-gray-900">
Versions
</div>
@ -520,9 +641,16 @@ export function DocumentSidePanel({
>
<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" />
Loading versions
<div className="space-y-1.5">
{Array.from({
length: versionSkeletonCount(
doc.active_version_number,
),
}).map((_, index) => (
<VersionUploadSkeleton
key={`version-skeleton-${index}`}
/>
))}
</div>
) : orderedVersions.length === 0 ? (
<div className="py-2 text-xs text-gray-400">
@ -530,6 +658,9 @@ export function DocumentSidePanel({
</div>
) : (
<div className="space-y-1.5">
{uploading && (
<VersionUploadSkeleton />
)}
{orderedVersions.map((version) => {
const title =
versionTitleFor(version);
@ -538,9 +669,14 @@ export function DocumentSidePanel({
const selected =
selectedVersionId ===
version.id;
const deleted =
version.deleted_at != null;
const versionDeleting =
deletingVersionId ===
version.id;
const versionReplacing =
replacingVersionId ===
version.id;
const fileType = fileTypeForVersion(
version,
doc.file_type,
@ -554,25 +690,34 @@ export function DocumentSidePanel({
key={version.id}
role="button"
tabIndex={0}
onClick={() =>
onClick={() => {
if (deleted) return;
onSelectVersion(
version.id,
filename,
)
}
);
}}
onKeyDown={(event) => {
if (deleted) return;
if (
event.key !==
"Enter" &&
event.key !== " "
) return;
)
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"
aria-disabled={deleted}
className={cn(
"group relative flex w-full 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",
deleted
? "cursor-not-allowed opacity-55"
: "cursor-pointer",
)}
>
{selected && (
<span className="absolute inset-y-0 left-0 w-[3px] bg-blue-500" />
@ -588,8 +733,10 @@ export function DocumentSidePanel({
<span
className={cn(
"shrink-0 text-[10px] font-semibold tracking-normal",
typeLabel ===
"PDF"
deleted
? "text-gray-300"
: typeLabel ===
"PDF"
? "text-red-600"
: "text-blue-600",
)}
@ -611,51 +758,99 @@ export function DocumentSidePanel({
<div
className={cn(
"flex h-5 shrink-0 items-center gap-0.5 transition-opacity",
selected
deleted ||
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>
{deleted ? (
<span className="text-[11px] font-medium text-gray-800">
Deleted
</span>
) : (
<>
<button
type="button"
onClick={(
event,
) => {
event.stopPropagation();
requestReplaceVersion(
version,
);
}}
disabled={
replacingVersionId !=
null ||
deletingVersionId !=
null
}
className="inline-flex h-5 w-5 items-center justify-center rounded-full text-blue-500 transition-colors hover:bg-blue-50 hover:text-blue-600 disabled:cursor-not-allowed disabled:opacity-40"
aria-label={`Replace ${title}`}
title="Replace version file"
>
{versionReplacing ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Upload className="h-3 w-3" />
)}
</button>
<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={
(canDelete &&
activeVersionCount <=
1) ||
deletingVersionId !=
null
}
className={cn(
"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",
!canDelete &&
"cursor-not-allowed opacity-40 hover:bg-transparent hover:text-red-500",
)}
aria-label={`Delete ${title}`}
title={
canDelete
? "Delete version"
: "Only the document owner can delete versions"
}
>
{versionDeleting ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Trash2 className="h-3 w-3" />
)}
</button>
</>
)}
</div>
</div>
</div>
@ -677,21 +872,37 @@ export function DocumentSidePanel({
<div
className={cn(
"flex shrink-0 items-center justify-between px-4 py-3",
"border-t border-white/60 bg-white/25",
"bg-white/25",
)}
>
<input
ref={fileInputRef}
type="file"
accept={accept}
accept={newVersionAccept}
className="hidden"
onChange={handleUpload}
/>
<input
ref={replaceFileInputRef}
type="file"
accept={replaceVersionAccept}
className="hidden"
onChange={handleReplaceFileInputChange}
/>
<button
type="button"
onClick={requestDeleteDocument}
disabled={deletingDocument}
className={dangerGlassButtonClass}
className={cn(
dangerGlassButtonClass,
!canDelete &&
"cursor-not-allowed opacity-45 hover:bg-red-600/90 active:scale-100",
)}
title={
canDelete
? "Delete document"
: "Only the document owner can delete this document"
}
>
{deletingDocument ? (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" />
@ -725,6 +936,23 @@ export function DocumentSidePanel({
: "File extensions cannot be changed here."
}
/>
<ConfirmPopup
open={replaceConfirmOpen}
title="Replace version?"
message={`This will wipe ${versionTitleFor(replaceTargetVersion)} and replace it with ${replaceFile?.name ?? "the selected file"}. Save as a new version instead if you want to keep both copies.`}
confirmLabel="Replace"
confirmStatus={
replacingVersionId != null ? "loading" : "idle"
}
cancelLabel="Cancel"
onCancel={() => {
if (replacingVersionId != null) return;
setReplaceConfirmOpen(false);
setReplaceTargetVersion(null);
setReplaceFile(null);
}}
onConfirm={() => void handleConfirmReplaceVersion()}
/>
<ConfirmPopup
open={confirmDeleteDocumentOpen}
title="Delete document?"
@ -759,6 +987,32 @@ function DataRow({ label, value }: { label: string; value: string }) {
);
}
function VersionUploadSkeleton() {
return (
<div className="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)]">
<div className="animate-pulse space-y-2">
<div className="flex items-center justify-between gap-3">
<div className="h-3 w-20 rounded-full bg-gray-200" />
<div className="h-3 w-9 rounded-full bg-blue-100" />
</div>
<div className="h-2.5 w-4/5 rounded-full bg-gray-200" />
<div className="h-2.5 w-2/5 rounded-full bg-gray-200" />
</div>
</div>
);
}
function versionSkeletonCount(activeVersionNumber: number | null | undefined) {
if (
typeof activeVersionNumber === "number" &&
Number.isFinite(activeVersionNumber) &&
activeVersionNumber > 0
) {
return Math.min(activeVersionNumber, 8);
}
return 2;
}
function clampPanelWidth(width: number, dataColumnWidth: number) {
const minWidth = MIN_DOC_COLUMN_WIDTH + RESIZER_WIDTH + dataColumnWidth;
const maxWidth =
@ -768,7 +1022,8 @@ function clampPanelWidth(width: number, dataColumnWidth: number) {
return Math.min(maxWidth, Math.max(minWidth, width));
}
function versionTitleFor(version: DocumentVersion) {
function versionTitleFor(version: DocumentVersion | null) {
if (!version) return "this version";
if (
typeof version.version_number === "number" &&
version.version_number >= 1
@ -805,3 +1060,13 @@ function hasExtensionChange(previous: string, next: string) {
previousExtension.toLowerCase()
);
}
function formatDateTime(iso: string) {
return new Date(iso).toLocaleString(undefined, {
day: "numeric",
month: "short",
year: "numeric",
hour: "numeric",
minute: "2-digit",
});
}

View file

@ -14,6 +14,7 @@ import {
} from "lucide-react";
import {
getProject,
deleteProject,
deleteDocument,
createTabularReview,
updateProject,
@ -33,6 +34,7 @@ import {
renameProjectDocument,
listDocumentVersions,
uploadDocumentVersion,
replaceDocumentVersionFile,
copyDocumentVersionFromDocument,
deleteDocumentVersion,
uploadProjectDocument,
@ -76,7 +78,6 @@ import {
formatBytes,
formatDate,
ProjectPageHeader,
ProjectPageSkeleton,
treeNameCellStyle,
type ProjectContextMenu,
type ProjectTab,
@ -108,6 +109,157 @@ function apiErrorDetail(error: unknown): string | null {
return error.message || null;
}
function ProjectTableLoading({
tab,
stickyCellBg,
}: {
tab: ProjectTab;
stickyCellBg: string;
}) {
if (tab === "assistant") {
return (
<>
<div className="flex items-center h-8 pr-8 border-b border-gray-200 text-xs text-gray-500 font-medium select-none">
<div
className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} ${stickyCellBg} flex items-center gap-4 self-stretch pl-4 pr-2 text-left`}
>
<div className="h-2.5 w-2.5 rounded bg-gray-100 animate-pulse" />
<span>Chats</span>
</div>
<div className="ml-auto w-32 shrink-0 text-left">
Created
</div>
<div className="w-8 shrink-0" />
</div>
{[1, 2, 3, 4, 5].map((i) => (
<div
key={i}
className="flex items-center h-10 pr-8 border-b border-gray-50"
>
<div
className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} ${stickyCellBg} py-2 pl-4 pr-2`}
>
<div className="flex items-center gap-4">
<div className="h-2.5 w-2.5 shrink-0 rounded bg-gray-100 animate-pulse" />
<div
className="h-3.5 rounded bg-gray-100 animate-pulse"
style={{ width: `${44 + i * 7}px` }}
/>
</div>
</div>
<div className="ml-auto w-32 shrink-0">
<div className="h-3 w-16 rounded bg-gray-100 animate-pulse" />
</div>
<div className="w-8 shrink-0" />
</div>
))}
</>
);
}
if (tab === "reviews") {
return (
<>
<div className="flex items-center h-8 pr-8 border-b border-gray-200 text-xs text-gray-500 font-medium select-none">
<div
className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} ${stickyCellBg} flex items-center gap-4 self-stretch pl-4 pr-2 text-left`}
>
<div className="h-2.5 w-2.5 rounded bg-gray-100 animate-pulse" />
<span>Name</span>
</div>
<div className="ml-auto w-24 shrink-0 text-left">
Columns
</div>
<div className="w-24 shrink-0 text-left">Documents</div>
<div className="w-32 shrink-0 text-left">Created</div>
<div className="w-8 shrink-0" />
</div>
{[1, 2, 3, 4, 5].map((i) => (
<div
key={i}
className="flex items-center h-10 pr-8 border-b border-gray-50"
>
<div
className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} ${stickyCellBg} py-2 pl-4 pr-2`}
>
<div className="flex items-center gap-4">
<div className="h-2.5 w-2.5 shrink-0 rounded bg-gray-100 animate-pulse" />
<div
className="h-3.5 rounded bg-gray-100 animate-pulse"
style={{ width: `${180 + i * 18}px` }}
/>
</div>
</div>
<div className="ml-auto w-24 shrink-0">
<div className="h-3 w-8 rounded bg-gray-100 animate-pulse" />
</div>
<div className="w-24 shrink-0">
<div className="h-3 w-8 rounded bg-gray-100 animate-pulse" />
</div>
<div className="w-32 shrink-0">
<div className="h-3 w-16 rounded bg-gray-100 animate-pulse" />
</div>
<div className="w-8 shrink-0" />
</div>
))}
</>
);
}
return (
<div className="flex-1 flex flex-col min-h-0">
<div className="flex items-center h-8 pr-8 border-b border-gray-200 text-xs text-gray-500 font-medium select-none shrink-0">
<div
className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} ${stickyCellBg} flex items-center gap-4 self-stretch pl-4 pr-2 text-left`}
>
<div className="h-2.5 w-2.5 rounded bg-gray-100 animate-pulse" />
<span>Name</span>
</div>
<div className="ml-auto w-20 shrink-0 text-left">Type</div>
<div className="w-24 shrink-0 text-left">Size</div>
<div className="w-20 shrink-0 text-left">Version</div>
<div className="w-32 shrink-0 text-left">Created</div>
<div className="w-32 shrink-0 text-left">Updated</div>
<div className="w-8 shrink-0" />
</div>
{[1, 2, 3, 4, 5].map((i) => (
<div
key={i}
className="flex items-center h-10 pr-8 border-b border-gray-50"
>
<div
className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} ${stickyCellBg} py-2 pl-4 pr-2`}
>
<div className="flex items-center gap-4">
<div className="h-2.5 w-2.5 shrink-0 rounded bg-gray-100 animate-pulse" />
<div
className="h-3.5 rounded bg-gray-100 animate-pulse"
style={{ width: `${210 + i * 16}px` }}
/>
</div>
</div>
<div className="ml-auto w-20 shrink-0">
<div className="h-3 w-8 rounded bg-gray-100 animate-pulse" />
</div>
<div className="w-24 shrink-0">
<div className="h-3 w-12 rounded bg-gray-100 animate-pulse" />
</div>
<div className="w-20 shrink-0">
<div className="h-3 w-5 rounded bg-gray-100 animate-pulse" />
</div>
<div className="w-32 shrink-0">
<div className="h-3 w-16 rounded bg-gray-100 animate-pulse" />
</div>
<div className="w-32 shrink-0">
<div className="h-3 w-16 rounded bg-gray-100 animate-pulse" />
</div>
<div className="w-8 shrink-0" />
</div>
))}
</div>
);
}
export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
const [project, setProject] = useState<Project | null>(null);
const [folders, setFolders] = useState<ProjectFolder[]>([]);
@ -248,16 +400,31 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
}
}
async function replaceVersionFile(
docId: string,
versionId: string,
file: File,
filename: string,
) {
await replaceDocumentVersionFile(docId, versionId, file, filename);
const res = await refreshDocumentVersionState(docId);
const replaced = res.versions.find(
(version) => version.id === versionId,
);
if (replaced) {
setViewingDocVersion({
id: replaced.id,
label: replaced.filename?.trim() || "Version",
});
}
}
async function refreshDocumentVersionState(docId: string) {
// Refresh project so doc.active_version_number and filename advance.
const updated = await getProject(projectId);
setProject(updated);
// Re-fetch versions for this doc (invalidate cache first).
setVersionsByDocId((prev) => {
const next = new Map(prev);
next.delete(docId);
return next;
});
// Re-fetch versions while keeping the previous rows visible until the
// updated list arrives.
const res = await listDocumentVersions(docId);
setVersionsByDocId((prev) => {
const next = new Map(prev);
@ -318,11 +485,14 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
try {
await deleteDocumentVersion(docId, versionId);
const res = await refreshDocumentVersionState(docId);
const activeVersions = res.versions.filter(
(version) => version.deleted_at == null,
);
const nextVersion =
res.versions.find(
activeVersions.find(
(version) => version.id === res.current_version_id,
) ??
res.versions[res.versions.length - 1] ??
activeVersions[activeVersions.length - 1] ??
null;
setViewingDocVersion(
nextVersion
@ -385,6 +555,9 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
const [uploadingDroppedFilenames, setUploadingDroppedFilenames] = useState<
string[]
>([]);
const [deletingDocIds, setDeletingDocIds] = useState<Set<string>>(
() => new Set(),
);
const [documentUploadWarning, setDocumentUploadWarning] = useState<
string | null
>(null);
@ -413,6 +586,11 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
const [pendingDeleteFolderStatus, setPendingDeleteFolderStatus] = useState<
"idle" | "deleting" | "deleted"
>("idle");
const [deleteProjectConfirmOpen, setDeleteProjectConfirmOpen] =
useState(false);
const [deleteProjectStatus, setDeleteProjectStatus] = useState<
"idle" | "deleting" | "deleted"
>("idle");
// Actions dropdown
const [actionsOpen, setActionsOpen] = useState(false);
@ -860,16 +1038,26 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
setOwnerOnlyAction("delete this document");
return;
}
await deleteDocument(docId);
setProject((prev) =>
prev
? {
...prev,
documents:
prev.documents?.filter((d) => d.id !== docId) || [],
}
: prev,
);
setDeletingDocIds((prev) => new Set([...prev, docId]));
try {
await deleteDocument(docId);
setProject((prev) =>
prev
? {
...prev,
documents:
prev.documents?.filter((d) => d.id !== docId) ||
[],
}
: prev,
);
} finally {
setDeletingDocIds((prev) => {
const next = new Set(prev);
next.delete(docId);
return next;
});
}
}
function requestRemoveDoc(doc: Document) {
@ -877,6 +1065,14 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
setOwnerOnlyAction("delete this document");
return;
}
const versionCount =
versionsByDocId.get(doc.id)?.versions.length ??
currentVersionNumber(doc) ??
1;
if (versionCount <= 1) {
void handleRemoveDoc(doc.id);
return;
}
setPendingDeleteStatus("idle");
setPendingDeleteDoc(doc);
}
@ -949,6 +1145,48 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
await updateProject(projectId, { name: newName });
}
async function handleCmNumberCommit(newCmNumber: string) {
if (project && project.is_owner === false) {
setOwnerOnlyAction("rename this project's CM number");
return;
}
const trimmed = newCmNumber.trim();
if (trimmed === (project?.cm_number ?? "")) return;
setProject((prev) =>
prev ? { ...prev, cm_number: trimmed || null } : prev,
);
const updated = await updateProject(projectId, {
cm_number: trimmed,
});
setProject((prev) =>
prev ? { ...prev, cm_number: updated.cm_number } : prev,
);
}
function requestProjectDelete() {
if (project?.is_owner === false) {
setOwnerOnlyAction("delete this project");
return;
}
setDeleteProjectStatus("idle");
setDeleteProjectConfirmOpen(true);
}
async function confirmProjectDelete() {
if (deleteProjectStatus === "deleting") return;
setDeleteProjectStatus("deleting");
try {
await deleteProject(projectId);
setDeleteProjectStatus("deleted");
setTimeout(() => {
router.push("/projects");
}, 250);
} catch (err) {
setDeleteProjectStatus("idle");
console.error("Failed to delete project", err);
}
}
async function submitChatRename(chatId: string) {
const trimmed = renameChatValue.trim();
setRenamingChatId(null);
@ -1168,6 +1406,10 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
return doc.active_version_number ?? doc.latest_version_number ?? null;
}
function isSharedDocument(doc: Document | null | undefined): boolean {
return !!(doc?.user_id && user?.id && doc.user_id !== user.id);
}
async function handleDropProjectFiles(files: File[]) {
if (files.length === 0) return;
const { supported, unsupported } =
@ -1414,10 +1656,22 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
);
}
function renderUploadingDocumentRows(depth: number) {
return uploadingDroppedFilenames.map((filename) => (
function renderDocumentActivityRow({
key,
filename,
fileType,
depth,
statusLabel,
}: {
key: string;
filename: string;
fileType: string | null;
depth: number;
statusLabel: string;
}) {
return (
<div
key={`uploading-doc-${filename}`}
key={key}
className="group flex items-center h-10 pr-8 border-b border-gray-50"
>
<div
@ -1425,31 +1679,40 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
style={treeNameCellStyle(depth)}
>
<div className="flex items-center gap-4">
<input
type="checkbox"
disabled
className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-default accent-black disabled:opacity-100"
/>
<Loader2 className="h-4 w-4 animate-spin text-gray-400 shrink-0" />
<Loader2 className="h-2.5 w-2.5 animate-spin text-gray-400 shrink-0" />
<DocIcon fileType={fileType} />
<span className="text-sm text-gray-400 truncate">
{filename}
</span>
</div>
</div>
<div className="ml-auto w-20 shrink-0 text-xs text-gray-300 uppercase truncate">
{filename.includes(".")
? filename.split(".").pop()
: "file"}
{fileType ??
(filename.includes(".")
? filename.split(".").pop()
: "file")}
</div>
<div className="w-24 shrink-0 text-sm text-gray-300">
Uploading
{statusLabel}
</div>
<div className="w-20 shrink-0 text-sm text-gray-300"></div>
<div className="w-32 shrink-0 text-sm text-gray-300"></div>
<div className="w-32 shrink-0 text-sm text-gray-300"></div>
<div className="w-8 shrink-0" />
</div>
));
);
}
function renderUploadingDocumentRows(depth: number) {
return uploadingDroppedFilenames.map((filename) =>
renderDocumentActivityRow({
key: `uploading-doc-${filename}`,
filename,
fileType: null,
depth,
statusLabel: "Uploading",
}),
);
}
function renderLevel(parentId: string | null, depth: number) {
@ -1477,6 +1740,16 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
const isUploadingVersion = uploadingVersionDocIds.has(
doc.id,
);
const isDeletingDoc = deletingDocIds.has(doc.id);
if (isDeletingDoc) {
return renderDocumentActivityRow({
key: `deleting-doc-${doc.id}`,
filename: doc.filename,
fileType: doc.file_type,
depth,
statusLabel: "Deleting...",
});
}
return (
<div key={`doc-${doc.id}`}>
<div
@ -1535,39 +1808,41 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
style={treeNameCellStyle(depth)}
>
<div className="flex items-center gap-4">
<input
type="checkbox"
checked={selectedDocIds.includes(
doc.id,
)}
onChange={() =>
setSelectedDocIds(
(prev) =>
prev.includes(
doc.id,
)
? prev.filter(
(
x,
) =>
x !==
doc.id,
)
: [
...prev,
doc.id,
],
)
}
onClick={(e) =>
e.stopPropagation()
}
className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-pointer accent-black"
/>
{isProcessing ||
isUploadingVersion ? (
<Loader2 className="h-4 w-4 animate-spin text-gray-400 shrink-0" />
) : isError ? (
<Loader2 className="h-2.5 w-2.5 animate-spin text-gray-400 shrink-0" />
) : (
<input
type="checkbox"
checked={selectedDocIds.includes(
doc.id,
)}
onChange={() =>
setSelectedDocIds(
(prev) =>
prev.includes(
doc.id,
)
? prev.filter(
(
x,
) =>
x !==
doc.id,
)
: [
...prev,
doc.id,
],
)
}
onClick={(e) =>
e.stopPropagation()
}
className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-pointer accent-black"
/>
)}
{isError ? (
<AlertCircle className="h-4 w-4 text-red-500 shrink-0" />
) : (
<DocIcon
@ -1738,6 +2013,9 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
doc,
)
}
deleteDisabled={isSharedDocument(
doc,
)}
/>
)}
</div>
@ -1749,7 +2027,6 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
<DocVersionHistory
docId={doc.id}
filename={docName}
fileType={doc.file_type}
activeVersionNumber={versionNumber}
loading={loadingVersionDocIds.has(doc.id)}
versions={
@ -1944,9 +2221,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
// ── Loading skeleton ──────────────────────────────────────────────────────
if (loading) return <ProjectPageSkeleton />;
if (!project) {
if (!loading && !project) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-gray-400">Project not found</p>
@ -1954,12 +2229,11 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
);
}
const docs = project.documents || [];
const docs = project?.documents || [];
const sidePanelDoc = viewingDoc
? (docs.find((doc) => doc.id === viewingDoc.id) ?? viewingDoc)
: null;
const versionUploadAccept =
versionUploadTargetDoc?.file_type === "pdf" ? ".pdf" : ".docx,.doc";
const versionUploadAccept = ".pdf,.docx,.doc";
const q = search.toLowerCase();
const filteredDocs = q
? docs.filter((d) => d.filename.toLowerCase().includes(q))
@ -2057,17 +2331,20 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
<>
<button
onClick={() => {
if (loading) return;
setCreatingFolderIn(null);
setNewFolderName("");
}}
className="flex items-center gap-1 text-xs font-medium text-gray-500 hover:text-gray-700 transition-colors"
disabled={loading}
className="flex items-center gap-1 text-xs font-medium text-gray-500 hover:text-gray-700 transition-colors disabled:cursor-default disabled:text-gray-300 disabled:hover:text-gray-300"
>
<FolderPlus className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Add Subfolder</span>
</button>
<button
onClick={() => setAddDocsOpen(true)}
className="flex items-center gap-1 text-xs font-medium text-gray-500 hover:text-gray-700 transition-colors"
disabled={loading}
className="flex items-center gap-1 text-xs font-medium text-gray-500 hover:text-gray-700 transition-colors disabled:cursor-default disabled:text-gray-300 disabled:hover:text-gray-300"
>
<Upload className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Add Documents</span>
@ -2102,7 +2379,9 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
</div>
) : undefined;
const pendingDeleteDocVersionCount = pendingDeleteDoc
? currentVersionNumber(pendingDeleteDoc)
? (versionsByDocId.get(pendingDeleteDoc.id)?.versions.length ??
currentVersionNumber(pendingDeleteDoc) ??
1)
: 0;
const pendingDeleteDocMessage = pendingDeleteDoc ? (
<div className="space-y-2">
@ -2234,13 +2513,16 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
/>
<ProjectPageHeader
project={project}
tab={tab}
search={search}
creatingChat={creatingChat}
creatingReview={creatingReview}
docsCount={docs.length}
isOwner={project?.is_owner !== false}
onBackToProjects={() => router.push("/projects")}
onTitleCommit={handleTitleCommit}
onRenameProject={handleTitleCommit}
onRenameCmNumber={handleCmNumberCommit}
onOwnerOnly={setOwnerOnlyAction}
onDeleteProject={requestProjectDelete}
onSearchChange={setSearch}
onOpenPeople={() => setPeopleModalOpen(true)}
onNewChat={handleNewChat}
@ -2261,6 +2543,13 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
{/* Table content */}
<div className="w-full flex-1 min-h-0 overflow-x-auto">
<div className="min-w-max flex min-h-full flex-col">
{loading ? (
<ProjectTableLoading
tab={tab}
stickyCellBg={stickyCellBg}
/>
) : (
<>
{/* Tab: Documents */}
{tab === "documents" && (
<div className="flex-1 flex flex-col min-h-0">
@ -2437,6 +2726,24 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
uploadingVersionDocIds.has(
doc.id,
);
const isDeletingDoc =
deletingDocIds.has(
doc.id,
);
if (isDeletingDoc) {
return renderDocumentActivityRow(
{
key: `deleting-doc-${doc.id}`,
filename:
doc.filename,
fileType:
doc.file_type,
depth: 0,
statusLabel:
"Deleting...",
},
);
}
return (
<div key={doc.id}>
<div
@ -2520,43 +2827,45 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} ${isVersionDragOver ? "bg-blue-50" : selectedDocIds.includes(doc.id) ? "bg-gray-50" : stickyCellBg} py-2 pl-4 pr-2 transition-colors ${isVersionDragOver ? "" : "group-hover:bg-gray-100"}`}
>
<div className="flex items-center gap-4">
<input
type="checkbox"
checked={selectedDocIds.includes(
doc.id,
)}
onChange={() =>
setSelectedDocIds(
(
prev,
) =>
prev.includes(
doc.id,
)
? prev.filter(
(
x,
) =>
x !==
doc.id,
)
: [
...prev,
doc.id,
],
)
}
onClick={(
e,
) =>
e.stopPropagation()
}
className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-pointer accent-black"
/>
{isProcessing ||
isUploadingVersion ? (
<Loader2 className="h-4 w-4 animate-spin text-gray-400 shrink-0" />
) : isError ? (
<Loader2 className="h-2.5 w-2.5 animate-spin text-gray-400 shrink-0" />
) : (
<input
type="checkbox"
checked={selectedDocIds.includes(
doc.id,
)}
onChange={() =>
setSelectedDocIds(
(
prev,
) =>
prev.includes(
doc.id,
)
? prev.filter(
(
x,
) =>
x !==
doc.id,
)
: [
...prev,
doc.id,
],
)
}
onClick={(
e,
) =>
e.stopPropagation()
}
className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-pointer accent-black"
/>
)}
{isError ? (
<AlertCircle className="h-4 w-4 text-red-500 shrink-0" />
) : (
<DocIcon
@ -2741,6 +3050,9 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
doc,
)
}
deleteDisabled={isSharedDocument(
doc,
)}
/>
)}
</div>
@ -2753,9 +3065,6 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
filename={
docName
}
fileType={
doc.file_type
}
activeVersionNumber={
versionNumber
}
@ -2907,6 +3216,9 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
menuDoc,
)
}
deleteDisabled={isSharedDocument(
menuDoc,
)}
/>
) : (
<RowActionMenuItems
@ -3035,21 +3347,27 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
setRenameReviewValue={setRenameReviewValue}
/>
)}
</>
)}
</div>
</div>
<AddDocumentsModal
open={addDocsOpen}
onClose={() => setAddDocsOpen(false)}
onSelect={handleDocsSelected}
breadcrumb={[
"Projects",
project.name +
(project.cm_number ? ` (${project.cm_number})` : ""),
"Add Documents",
]}
projectId={projectId}
/>
{project && (
<AddDocumentsModal
open={addDocsOpen}
onClose={() => setAddDocsOpen(false)}
onSelect={handleDocsSelected}
breadcrumb={[
"Projects",
project.name +
(project.cm_number
? ` (${project.cm_number})`
: ""),
"Add Documents",
]}
projectId={projectId}
/>
)}
<DocumentSidePanel
doc={sidePanelDoc}
@ -3083,6 +3401,9 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
onRenameVersion={handleRenameVersion}
onDeleteVersion={handleDeleteVersion}
onUploadNewVersion={submitNewVersion}
onReplaceVersion={replaceVersionFile}
canDelete={!isSharedDocument(sidePanelDoc)}
onOwnerOnlyAction={setOwnerOnlyAction}
onDelete={async (doc) => {
await handleRemoveDoc(doc.id);
}}
@ -3105,41 +3426,64 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
onClose={() => setOwnerOnlyAction(null)}
/>
<PeopleModal
open={peopleModalOpen}
onClose={() => setPeopleModalOpen(false)}
resource={project}
fetchPeople={getProjectPeople}
currentUserEmail={user?.email ?? null}
breadcrumb={[
"Projects",
project
? project.name +
(project.cm_number ? ` (${project.cm_number})` : "")
: "",
"People",
]}
// Only owners may modify the member list. Without this prop
// PeopleModal renders read-only — non-owners can still see
// who has access but the add/remove controls are hidden.
onSharedWithChange={
project.is_owner === false
? undefined
: async (next) => {
const updated = await updateProject(projectId, {
shared_with: next,
});
setProject((prev) =>
prev
? {
...prev,
shared_with: updated.shared_with,
}
: prev,
);
}
<ConfirmPopup
open={deleteProjectConfirmOpen}
title="Delete project?"
message="This will permanently delete the project and its related documents, chats, and tabular reviews."
confirmLabel="Delete"
confirmStatus={
deleteProjectStatus === "deleting"
? "loading"
: deleteProjectStatus === "deleted"
? "complete"
: "idle"
}
cancelLabel="Cancel"
onCancel={() => {
if (deleteProjectStatus === "deleting") return;
setDeleteProjectConfirmOpen(false);
setDeleteProjectStatus("idle");
}}
onConfirm={() => void confirmProjectDelete()}
/>
{project && (
<PeopleModal
open={peopleModalOpen}
onClose={() => setPeopleModalOpen(false)}
resource={project}
fetchPeople={getProjectPeople}
currentUserEmail={user?.email ?? null}
breadcrumb={[
"Projects",
project.name +
(project.cm_number
? ` (${project.cm_number})`
: ""),
"People",
]}
// Only owners may modify the member list. Without this prop
// PeopleModal renders read-only — non-owners can still see
// who has access but the add/remove controls are hidden.
onSharedWithChange={
project.is_owner === false
? undefined
: async (next) => {
const updated = await updateProject(projectId, {
shared_with: next,
});
setProject((prev) =>
prev
? {
...prev,
shared_with: updated.shared_with,
}
: prev,
);
}
}
/>
)}
</div>
);
}

View file

@ -1,21 +1,23 @@
"use client";
import { type CSSProperties, useState } from "react";
import { type CSSProperties, type KeyboardEvent, useState } from "react";
import {
CornerDownRight,
File,
FileText,
Hash,
Loader2,
MessageSquare,
Search,
Pencil,
Table2,
Trash2,
Users,
} from "lucide-react";
import { PageHeader } from "@/app/components/shared/PageHeader";
import { RenameableTitle } from "@/app/components/shared/RenameableTitle";
import type { Project } from "@/app/components/shared/types";
import type { DocumentVersion } from "@/app/lib/mikeApi";
import { RowActions } from "@/app/components/shared/RowActions";
import { HeaderActionsMenu } from "@/app/components/shared/HeaderActionsMenu";
export type ProjectTab = "documents" | "assistant" | "reviews";
@ -55,7 +57,14 @@ export function formatDate(iso: string) {
});
}
export function DocIcon({ fileType }: { fileType: string | null }) {
export function DocIcon({
fileType,
muted = false,
}: {
fileType: string | null;
muted?: boolean;
}) {
if (muted) return <File className="h-4 w-4 text-gray-300 shrink-0" />;
if (fileType === "pdf")
return <FileText className="h-4 w-4 text-red-600 shrink-0" />;
if (fileType === "docx" || fileType === "doc")
@ -66,7 +75,6 @@ export function DocIcon({ fileType }: { fileType: string | null }) {
export function DocVersionHistory({
docId,
filename,
fileType,
activeVersionNumber,
currentVersionId,
loading,
@ -79,7 +87,6 @@ export function DocVersionHistory({
}: {
docId: string;
filename: string;
fileType: string | null;
activeVersionNumber: number | null;
currentVersionId: string | null;
loading: boolean;
@ -182,6 +189,8 @@ export function DocVersionHistory({
return (
<>
{ordered.map((v) => {
const versionFileType = v.file_type ?? null;
const isDeleted = v.deleted_at != null;
const numberLabel =
typeof v.version_number === "number" &&
v.version_number >= 1
@ -190,6 +199,7 @@ export function DocVersionHistory({
? "Original"
: "—";
const displayLabel = v.filename?.trim() || numberLabel;
const downloadFilename = v.filename?.trim() || filename;
const dt = new Date(v.created_at);
const dateLabel = Number.isNaN(dt.valueOf())
? ""
@ -201,28 +211,42 @@ export function DocVersionHistory({
minute: "2-digit",
});
const isEditing = editingVersionId === v.id;
const rowBg = "bg-gray-100";
const rowBg = isDeleted ? "bg-gray-50" : "bg-gray-100";
const hoverBg = isDeleted ? "hover:bg-gray-50" : "hover:bg-gray-200";
return (
<div
key={`ver-${docId}-${v.id}`}
onClick={() => {
if (isEditing) return;
if (isEditing || isDeleted) return;
onOpenVersion?.(v.id, displayLabel);
}}
className={`group flex h-10 cursor-pointer items-center pr-8 text-sm text-gray-500 transition-colors hover:bg-gray-200 ${rowBg}`}
className={`group flex h-10 items-center pr-8 text-sm transition-colors ${rowBg} ${hoverBg} ${
isDeleted
? "cursor-default text-gray-300"
: "cursor-pointer text-gray-500"
}`}
>
<div
className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} ${rowBg} py-2 pl-4 pr-2 transition-colors group-hover:bg-gray-200`}
className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} ${rowBg} py-2 pl-4 pr-2 transition-colors ${
isDeleted ? "group-hover:bg-gray-50" : "group-hover:bg-gray-200"
}`}
style={treeNameCellStyle(depth)}
>
<div className="flex items-center gap-4">
<span className="flex h-2.5 w-2.5 shrink-0 items-center justify-center">
<CornerDownRight
className="h-3.5 w-3.5 text-gray-400"
className={`h-3.5 w-3.5 ${
isDeleted
? "text-gray-300"
: "text-gray-400"
}`}
aria-hidden="true"
/>
</span>
<DocIcon fileType={fileType} />
<DocIcon
fileType={versionFileType}
muted={isDeleted}
/>
{isEditing ? (
<input
autoFocus
@ -243,22 +267,47 @@ export function DocVersionHistory({
className="min-w-0 flex-1 border-b border-gray-300 bg-transparent text-sm text-gray-800 outline-none focus:border-gray-500"
/>
) : (
<span className="truncate text-sm text-gray-700">
<span
className={`truncate text-sm ${
isDeleted
? "text-gray-300"
: "text-gray-700"
}`}
>
{isDeleted && (
<span className="font-medium text-gray-500">
[Deleted]{" "}
</span>
)}
{displayLabel}
</span>
)}
</div>
</div>
<div className="ml-auto w-20 shrink-0 truncate text-xs uppercase text-gray-500">
{fileType ?? <span className="text-gray-300"></span>}
<div
className={`ml-auto w-20 shrink-0 truncate text-xs uppercase ${
isDeleted ? "text-gray-300" : "text-gray-500"
}`}
>
{versionFileType ?? (
<span className="text-gray-300"></span>
)}
</div>
<div className="w-24 shrink-0 truncate text-sm text-gray-400">
</div>
<div className="w-20 shrink-0 truncate pl-1 text-sm text-gray-500">
<div
className={`w-20 shrink-0 truncate pl-1 text-sm ${
isDeleted ? "text-gray-300" : "text-gray-500"
}`}
>
{numberLabel}
</div>
<div className="w-32 shrink-0 truncate text-sm text-gray-500">
<div
className={`w-32 shrink-0 truncate text-sm ${
isDeleted ? "text-gray-300" : "text-gray-500"
}`}
>
{dateLabel ? formatDate(v.created_at) : <span className="text-gray-300"></span>}
</div>
<div className="w-32 shrink-0 truncate text-sm text-gray-400">
@ -268,20 +317,28 @@ export function DocVersionHistory({
className="w-8 shrink-0 flex justify-end"
onClick={(e) => e.stopPropagation()}
>
<RowActions
onRename={
onRenameVersion
? () => {
setEditingVersionId(v.id);
setEditingValue(v.filename ?? "");
}
: undefined
}
renameLabel="Rename version"
onDownload={() =>
onDownloadVersion(docId, v.id, filename)
}
/>
{!isDeleted && (
<RowActions
onRename={
onRenameVersion
? () => {
setEditingVersionId(v.id);
setEditingValue(
v.filename ?? "",
);
}
: undefined
}
renameLabel="Rename version"
onDownload={() =>
onDownloadVersion(
docId,
v.id,
downloadFilename,
)
}
/>
)}
</div>
</div>
);
@ -290,116 +347,125 @@ export function DocVersionHistory({
);
}
export function ProjectPageSkeleton() {
return (
<div className="flex-1 overflow-y-auto">
<PageHeader
align="start"
actionGap="lg"
breadcrumbs={[
{ label: "Projects" },
{ loading: true, skeletonClassName: "w-40" },
]}
actionGroups={[
[
{
disabled: true,
iconOnly: true,
title: "Search",
icon: <Search className="h-4 w-4" />,
},
{
disabled: true,
iconOnly: true,
title: "People with access",
icon: <Users className="h-4 w-4" />,
},
],
[
{
disabled: true,
icon: <MessageSquare className="h-4 w-4" />,
label: <span className="hidden sm:inline">New Chat</span>,
},
{
disabled: true,
icon: <Table2 className="h-4 w-4" />,
label: <span className="hidden sm:inline">New Review</span>,
},
],
]}
/>
<div className="flex items-center h-10 px-4 md:px-10 border-b border-gray-200 gap-5">
<div className="h-3 w-20 rounded bg-gray-100 animate-pulse" />
<div className="h-3 w-10 rounded bg-gray-100 animate-pulse" />
<div className="h-3 w-24 rounded bg-gray-100 animate-pulse" />
<div className="ml-auto flex items-center gap-5">
<div className="h-3 w-24 rounded bg-gray-100 animate-pulse" />
<div className="h-3 w-24 rounded bg-gray-100 animate-pulse" />
</div>
</div>
<div className="flex items-center h-8 pr-3 md:pr-10 border-b border-gray-200">
<div className={`${DOC_NAME_COL_W} flex shrink-0 items-center gap-4 pl-4 pr-2`}>
<div className="h-2.5 w-2.5 rounded bg-gray-100 animate-pulse" />
<div className="h-2.5 w-8 rounded bg-gray-100 animate-pulse" />
</div>
<div className="w-20 shrink-0">
<div className="h-2.5 w-8 rounded bg-gray-100 animate-pulse" />
</div>
<div className="w-24 shrink-0">
<div className="h-2.5 w-8 rounded bg-gray-100 animate-pulse" />
</div>
<div className="w-8 shrink-0" />
</div>
{[1, 2, 3, 4, 5].map((i) => (
<div
key={i}
className="flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50"
>
<div className={`${DOC_NAME_COL_W} flex shrink-0 items-center gap-4 pl-4 pr-2`}>
<div className="h-2.5 w-2.5 shrink-0 rounded bg-gray-100 animate-pulse" />
<div className="h-3.5 w-56 rounded bg-gray-100 animate-pulse" />
</div>
<div className="w-20 shrink-0">
<div className="h-3 w-8 rounded bg-gray-100 animate-pulse" />
</div>
<div className="w-24 shrink-0">
<div className="h-3 w-12 rounded bg-gray-100 animate-pulse" />
</div>
<div className="w-8 shrink-0" />
</div>
))}
</div>
);
}
export function ProjectPageHeader({
project,
tab,
search,
creatingChat,
creatingReview,
docsCount,
isOwner,
onBackToProjects,
onTitleCommit,
onRenameProject,
onRenameCmNumber,
onOwnerOnly,
onDeleteProject,
onSearchChange,
onOpenPeople,
onNewChat,
onNewReview,
}: {
project: Project;
tab: ProjectTab;
project: Project | null;
search: string;
creatingChat: boolean;
creatingReview: boolean;
docsCount: number;
isOwner: boolean;
onBackToProjects: () => void;
onTitleCommit: (newName: string) => void | Promise<void>;
onRenameProject: (name: string) => void;
onRenameCmNumber: (cmNumber: string) => void;
onOwnerOnly: (action: string) => void;
onDeleteProject: () => void;
onSearchChange: (search: string) => void;
onOpenPeople: () => void;
onNewChat: () => void;
onNewReview: () => void;
}) {
const [editingField, setEditingField] = useState<"name" | "cm" | null>(
null,
);
const [draft, setDraft] = useState("");
const startEdit = (field: "name" | "cm") => {
if (!project) return;
if (!isOwner) {
onOwnerOnly(
field === "name"
? "rename this project"
: "rename this project's CM number",
);
return;
}
setDraft(field === "name" ? project.name : project.cm_number ?? "");
setEditingField(field);
};
const commitEdit = () => {
if (!editingField) return;
const value = draft.trim();
if (editingField === "name") onRenameProject(value);
else onRenameCmNumber(value);
setEditingField(null);
};
const handleEditKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
commitEdit();
} else if (e.key === "Escape") {
e.preventDefault();
setEditingField(null);
}
};
const editInputClassName =
"min-w-0 cursor-text border-0 border-b border-gray-200 bg-transparent font-serif text-2xl font-medium outline-none transition-colors focus:border-gray-300";
const titleLabel = !project ? undefined : editingField === "name" ? (
<input
autoFocus
value={draft}
size={Math.max(draft.length + 1, 3)}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleEditKeyDown}
onBlur={commitEdit}
className={`${editInputClassName} text-gray-900`}
aria-label="Rename project"
/>
) : (
<span
onClick={() => startEdit("name")}
className="inline-block cursor-text"
title="Rename"
>
{project.name}
</span>
);
const cmSuffix = !project ? null : editingField === "cm" ? (
<span className="ml-1 inline-flex items-center text-gray-400">
(#
<input
autoFocus
value={draft}
size={Math.max(draft.length + 1, 3)}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleEditKeyDown}
onBlur={commitEdit}
className={`${editInputClassName} text-gray-400`}
aria-label="Rename CM number"
/>
)
</span>
) : project.cm_number ? (
<span
onClick={() => startEdit("cm")}
className="ml-1 inline-block cursor-text text-gray-400"
title="Rename CM"
>
(#{project.cm_number})
</span>
) : null;
return (
<PageHeader
breadcrumbs={[
@ -409,17 +475,16 @@ export function ProjectPageHeader({
title: "Back to Projects",
},
{
label: (
<RenameableTitle
value={project.name}
onCommit={onTitleCommit}
/>
),
suffix: project.cm_number ? (
<span className="ml-1 text-gray-400">
(#{project.cm_number})
</span>
) : null,
...(project
? {
label: titleLabel,
suffix: cmSuffix,
cursor: "text",
}
: {
loading: true,
skeletonClassName: "w-40",
}),
},
]}
align="start"
@ -438,34 +503,69 @@ export function ProjectPageHeader({
title: "People with access",
icon: <Users className="h-4 w-4" />,
},
],
[
{
onClick: onNewChat,
disabled: creatingChat,
icon: creatingChat ? (
type: "custom",
render: (
<HeaderActionsMenu
items={[
{
label: "Rename",
icon: Pencil,
onSelect: () => startEdit("name"),
},
{
label: "Rename CM",
icon: Hash,
onSelect: () => startEdit("cm"),
},
{
label: "Delete",
icon: Trash2,
onSelect: onDeleteProject,
variant: "danger",
},
]}
/>
),
},
],
{
gap: "xs",
actions: [
{
onClick: onNewChat,
disabled: creatingChat,
icon: creatingChat ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MessageSquare className="h-4 w-4" />
),
label: <span className="hidden sm:inline">New Chat</span>,
},
{
onClick: onNewReview,
disabled: docsCount === 0 || creatingReview,
icon: creatingReview ? (
label: (
<span className="hidden sm:inline">
New Chat
</span>
),
},
{
onClick: onNewReview,
disabled: docsCount === 0 || creatingReview,
icon: creatingReview ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Table2 className="h-4 w-4" />
),
label: (
<span className="hidden sm:inline">
New Review
</span>
),
tooltip: docsCount === 0 ? "Upload a document first" : null,
},
],
label: (
<span className="hidden sm:inline">
New Review
</span>
),
tooltip:
docsCount === 0
? "Upload a document first"
: null,
},
],
},
]}
/>
);

View file

@ -206,6 +206,7 @@ export function ProjectsOverview() {
<div className="flex-1 overflow-y-auto">
{/* Page header */}
<PageHeader
loading={loading}
actions={[
{
type: "search",

View file

@ -157,7 +157,7 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
onClick={onToggle}
className={cn(
"h-9 w-9 p-2.5 items-center flex transition-colors",
"rounded-xl hover:bg-gray-100",
"rounded-md hover:bg-gray-100",
)}
title={isOpen ? "Close sidebar" : "Open sidebar"}
>

View file

@ -536,8 +536,11 @@ export function DocView({
return (
<DocxView
documentId={doc.document_id}
versionId={doc.version_id ?? null}
quotes={quotes}
quoteFocusKey={quoteFocusKey}
rounded={rounded}
bordered={bordered}
/>
);
}

View file

@ -0,0 +1,69 @@
"use client";
import { MoreHorizontal, type LucideIcon } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
export type HeaderActionsMenuItem = {
label: string;
icon?: LucideIcon;
onSelect: () => void;
disabled?: boolean;
variant?: "default" | "danger";
};
export function HeaderActionsMenu({
items,
title = "Actions",
}: {
items: HeaderActionsMenuItem[];
title?: string;
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
"inline-flex h-7 w-7 items-center justify-center rounded-full text-gray-600 transition-all",
"hover:bg-gray-100 hover:text-gray-950 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-300",
)}
aria-label={title}
title={title}
>
<MoreHorizontal className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="z-[160] w-48 bg-white">
{items.map((item) => {
const Icon = item.icon;
return (
<DropdownMenuItem
key={item.label}
disabled={item.disabled}
variant={
item.variant === "danger"
? "destructive"
: "default"
}
onSelect={item.onSelect}
className={cn(
"cursor-pointer text-xs",
item.variant === "danger" &&
"text-red-600 focus:bg-red-50 focus:text-red-700",
)}
>
{Icon && <Icon className="h-3.5 w-3.5" />}
{item.label}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}

View file

@ -117,13 +117,13 @@ export function Modal({
</button>
</div>
)}
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4 pt-1 pb-2">
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4">
{children}
</div>
{hasFooter && (
<div
className={cn(
"flex items-center gap-3 p-4",
"flex items-center gap-3 p-3",
secondaryAction || footerInfo
? "justify-between"
: "justify-end",
@ -186,7 +186,7 @@ function ModalActionButton({
"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" &&
"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",
"rounded-full border border-blue-500/35 bg-blue-600/90 text-white shadow-[0_3px_9px_rgba(37,99,235,0.16),inset_0_1px_0_rgba(255,255,255,0.28),inset_0_-4px_9px_rgba(29,78,216,0.2)] backdrop-blur-xl hover:bg-blue-600 hover:text-white active:scale-[0.98] disabled:active:scale-100",
variant === "danger" &&
"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",
)}

View file

@ -16,6 +16,7 @@ export interface PageHeaderBreadcrumb {
label?: ReactNode;
suffix?: ReactNode;
onClick?: () => void;
cursor?: "text";
loading?: boolean;
skeletonClassName?: string;
title?: string;
@ -30,7 +31,6 @@ type PageHeaderButtonAction = {
title?: string;
variant?: "default" | "danger";
iconOnly?: boolean;
className?: string;
tooltip?: ReactNode;
};
@ -70,18 +70,28 @@ export type PageHeaderAction =
| PageHeaderCustomAction
| ReactNode;
type PageHeaderActionGap = "xs" | "sm" | "md" | "lg";
type PageHeaderActionGroup =
| PageHeaderAction[]
| {
actions: PageHeaderAction[];
gap?: PageHeaderActionGap;
};
interface PageHeaderProps {
children?: ReactNode;
actions?: PageHeaderAction[];
actionGroups?: PageHeaderAction[][];
actionGroups?: PageHeaderActionGroup[];
align?: "center" | "start";
shrink?: boolean;
className?: string;
actionGap?: "sm" | "md" | "lg";
actionGap?: PageHeaderActionGap;
breadcrumbs?: PageHeaderBreadcrumb[];
loading?: boolean;
}
const actionGapClassName = {
xs: "gap-1",
sm: "gap-2.5",
md: "gap-2.5",
lg: "gap-2.5",
@ -96,18 +106,24 @@ export function PageHeader({
className,
actionGap = "sm",
breadcrumbs,
loading = false,
}: PageHeaderProps) {
const headerContent = breadcrumbs?.length ? (
<PageHeaderBreadcrumbs items={breadcrumbs} />
) : (
children
);
const actionsDisabled =
loading || !!breadcrumbs?.some((item) => item.loading);
const actionItems = actions?.filter(Boolean) ?? [];
const groupedActionItems =
const groupedActionItems = (
actionGroups
?.map((group) => group.filter(Boolean))
.filter((group) => group.length > 0) ??
(actionItems.length > 0 ? [actionItems] : []);
?.map((group) => normalizeActionGroup(group, actionGap))
.filter((group) => group.actions.length > 0) ??
(actionItems.length > 0
? [{ actions: actionItems, gap: actionGap }]
: [])
);
return (
<div
@ -128,13 +144,16 @@ export function PageHeader({
key={groupIndex}
className={cn(
"flex shrink-0 items-center",
actionGapClassName[actionGap],
actionGapClassName[group.gap],
"rounded-full border border-white/70 bg-white px-1 py-1 shadow-[0_-1px_3px_rgba(15,23,42,0.03),0_2px_7px_rgba(15,23,42,0.08),inset_0_1px_0_rgba(255,255,255,0.82),inset_0_-3px_7px_rgba(255,255,255,0.13)] backdrop-blur-2xl",
)}
>
{group.map((action, index) => (
{group.actions.map((action, index) => (
<Fragment key={index}>
<PageHeaderActionRenderer action={action} />
<PageHeaderActionRenderer
action={action}
disabled={actionsDisabled}
/>
</Fragment>
))}
</div>
@ -145,21 +164,80 @@ export function PageHeader({
);
}
function PageHeaderActionRenderer({ action }: { action: PageHeaderAction }) {
if (!isPageHeaderActionObject(action)) return <>{action}</>;
function normalizeActionGroup(
group: PageHeaderActionGroup,
fallbackGap: PageHeaderActionGap,
) {
if (Array.isArray(group)) {
return {
actions: group.filter(Boolean),
gap: fallbackGap,
};
}
return {
actions: group.actions.filter(Boolean),
gap: group.gap ?? fallbackGap,
};
}
function PageHeaderActionRenderer({
action,
disabled,
}: {
action: PageHeaderAction;
disabled: boolean;
}) {
if (!isPageHeaderActionObject(action)) {
return disabled ? (
<span className="inline-flex h-7 items-center opacity-40 pointer-events-none">
{action}
</span>
) : (
<>{action}</>
);
}
switch (action.type) {
case "search":
return <PageHeaderSearchActionControl action={action} />;
return (
<PageHeaderSearchActionControl
action={action}
disabled={disabled}
/>
);
case "delete":
return <PageHeaderDeleteActionControl action={action} />;
return (
<PageHeaderDeleteActionControl
action={action}
disabled={disabled}
/>
);
case "new":
return <PageHeaderNewActionControl action={action} />;
return (
<PageHeaderNewActionControl
action={action}
disabled={disabled}
/>
);
case "custom":
return <>{action.render}</>;
return (
<span
className={cn(
"inline-flex h-7 items-center",
disabled && "pointer-events-none opacity-40",
)}
>
{action.render}
</span>
);
case "button":
default:
return <PageHeaderButtonActionControl action={action} />;
return (
<PageHeaderButtonActionControl
action={action}
disabled={disabled}
/>
);
}
}
@ -171,20 +249,21 @@ function isPageHeaderActionObject(
function PageHeaderButtonActionControl({
action,
disabled,
}: {
action: PageHeaderButtonAction;
disabled: boolean;
}) {
const iconOnly = action.iconOnly ?? !action.label;
return (
<div className={action.tooltip ? "relative group" : undefined}>
<PageHeaderActionButton
onClick={action.onClick}
disabled={action.disabled}
disabled={disabled || action.disabled}
title={action.title}
aria-label={action.title}
variant={action.variant}
iconOnly={iconOnly}
className={action.className}
>
{action.icon}
{action.label}
@ -200,14 +279,16 @@ function PageHeaderButtonActionControl({
function PageHeaderNewActionControl({
action,
disabled,
}: {
action: PageHeaderNewAction;
disabled: boolean;
}) {
const title = action.title ?? "New";
return (
<PageHeaderActionButton
onClick={action.onClick}
disabled={action.disabled || action.loading}
disabled={disabled || action.disabled || action.loading}
title={title}
aria-label={title}
iconOnly
@ -223,14 +304,16 @@ function PageHeaderNewActionControl({
function PageHeaderDeleteActionControl({
action,
disabled,
}: {
action: PageHeaderDeleteAction;
disabled: boolean;
}) {
const title = action.title ?? "Delete";
return (
<PageHeaderActionButton
onClick={action.onClick}
disabled={action.disabled || action.loading}
disabled={disabled || action.disabled || action.loading}
title={title}
aria-label={title}
iconOnly
@ -247,8 +330,10 @@ function PageHeaderDeleteActionControl({
function PageHeaderSearchActionControl({
action,
disabled,
}: {
action: PageHeaderSearchAction;
disabled: boolean;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
@ -280,6 +365,7 @@ function PageHeaderSearchActionControl({
<Search className="h-3.5 w-3.5 text-gray-400 shrink-0" />
<input
autoFocus
disabled={disabled}
type="text"
placeholder={placeholder}
value={action.value}
@ -290,6 +376,7 @@ function PageHeaderSearchActionControl({
) : (
<PageHeaderActionButton
onClick={() => setOpen(true)}
disabled={disabled}
iconOnly
title={placeholder}
aria-label={placeholder}
@ -301,7 +388,10 @@ function PageHeaderSearchActionControl({
);
}
type PageHeaderActionButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
type PageHeaderActionButtonProps = Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
"className"
> & {
variant?: "default" | "danger";
iconOnly?: boolean;
};
@ -333,7 +423,6 @@ function pageHeaderActionControlClassName({
function PageHeaderActionButton({
children,
className,
variant = "default",
iconOnly = false,
disabled,
@ -346,7 +435,6 @@ function PageHeaderActionButton({
variant,
iconOnly,
disabled,
className,
})}
{...props}
>
@ -411,13 +499,21 @@ function BreadcrumbItem({
/>
) : (
<>
<span className="truncate">{item.label}</span>
<span
className={cn(
"truncate",
item.cursor === "text" && "cursor-text",
)}
>
{item.label}
</span>
{showSuffix && item.suffix}
</>
);
const className = cn(
"min-w-0 truncate transition-colors",
item.cursor === "text" && "cursor-text",
current
? "text-gray-900"
: item.onClick

View file

@ -8,6 +8,14 @@ interface Props {
suffix?: React.ReactNode;
}
type CaretDocument = Document & {
caretPositionFromPoint?: (
x: number,
y: number,
) => { offset: number } | null;
caretRangeFromPoint?: (x: number, y: number) => Range | null;
};
export function RenameableTitle({ value, onCommit, suffix }: Props) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState("");
@ -15,10 +23,14 @@ export function RenameableTitle({ value, onCommit, suffix }: Props) {
const escaped = useRef(false);
function startEditing(e: React.MouseEvent) {
const doc = document as any;
const doc = document as CaretDocument;
const caret = doc.caretPositionFromPoint?.(e.clientX, e.clientY);
const range = !caret && doc.caretRangeFromPoint?.(e.clientX, e.clientY);
caretPos.current = caret ? caret.offset : range ? range.startOffset : null;
caretPos.current = caret
? caret.offset
: range
? range.startOffset
: null;
escaped.current = false;
setDraft(value);
setEditing(true);
@ -61,7 +73,7 @@ export function RenameableTitle({ value, onCommit, suffix }: Props) {
return (
<span
className="text-gray-900 cursor-text hover:text-gray-600 transition-colors"
className="inline-block cursor-text text-gray-900 transition-colors hover:text-gray-600"
onClick={startEditing}
>
{value}

View file

@ -30,6 +30,7 @@ interface Props {
onUploadNewVersion?: () => void;
onNewSubfolder?: () => void;
deleting?: boolean;
deleteDisabled?: boolean;
onRename?: () => void;
onUpdateCmNumber?: () => void;
newSubfolderLabel?: string;
@ -47,6 +48,7 @@ export function RowActionMenuItems({
onUploadNewVersion,
onNewSubfolder,
deleting,
deleteDisabled = false,
onRename,
onUpdateCmNumber,
newSubfolderLabel = "New subfolder",
@ -141,7 +143,12 @@ export function RowActionMenuItems({
<button
onClick={() => { onClose(); onDelete(); }}
disabled={deleting}
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-red-500 hover:bg-red-50 transition-colors disabled:opacity-40"
aria-disabled={deleteDisabled}
className={`flex items-center gap-2 w-full px-3 py-2 text-xs text-red-500 transition-colors disabled:opacity-40 ${
deleteDisabled
? "cursor-not-allowed opacity-40 hover:bg-transparent"
: "hover:bg-red-50"
}`}
>
<Trash2 className="h-3.5 w-3.5" />
{deleteLabel}

View file

@ -52,7 +52,7 @@ export function UploadNewVersionModal({ open, onClose, doc, onSubmit }: Props) {
if (!open || !doc) return null;
const accept = doc.file_type === "pdf" ? ".pdf" : ".docx,.doc";
const accept = ".pdf,.docx,.doc";
function handleFilePick(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0] ?? null;

View file

@ -32,6 +32,8 @@ export interface Document {
project_id: string | null;
folder_id?: string | null;
filename: string;
owner_email?: string | null;
owner_display_name?: string | null;
file_type: string | null; // pdf | docx | doc
storage_path: string | null;
pdf_storage_path: string | null;

View file

@ -0,0 +1,155 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Check, Loader2, Search } from "lucide-react";
import { listWorkflows } from "@/app/lib/mikeApi";
import { Modal } from "@/app/components/shared/Modal";
import type { Workflow } from "@/app/components/shared/types";
import { BUILT_IN_WORKFLOWS } from "../workflows/builtinWorkflows";
import { cn } from "@/lib/utils";
interface Props {
open: boolean;
applying?: boolean;
onClose: () => void;
onApply: (workflow: Workflow) => Promise<void> | void;
}
export function ApplyWorkflowPresetModal({
open,
applying = false,
onClose,
onApply,
}: Props) {
const [workflows, setWorkflows] = useState<Workflow[]>([]);
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null>(
null,
);
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!open) return;
const builtinTabular = BUILT_IN_WORKFLOWS.filter(
(workflow) => workflow.type === "tabular",
);
setLoading(true);
setSearch("");
setSelectedWorkflowId(null);
listWorkflows("tabular")
.then((custom) => setWorkflows([...builtinTabular, ...custom]))
.catch(() => setWorkflows(builtinTabular))
.finally(() => setLoading(false));
}, [open]);
const filteredWorkflows = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return workflows;
return workflows.filter((workflow) =>
[workflow.title, workflow.practice ?? ""]
.join(" ")
.toLowerCase()
.includes(q),
);
}, [search, workflows]);
const selectedWorkflow =
workflows.find((workflow) => workflow.id === selectedWorkflowId) ?? null;
const canApply =
!!selectedWorkflow &&
!applying &&
!loading &&
!!selectedWorkflow.columns_config?.length;
return (
<Modal
open={open}
onClose={onClose}
title="Apply preset workflow"
size="md"
primaryAction={{
label: applying ? "Applying..." : "Apply",
onClick: () => {
if (selectedWorkflow) void onApply(selectedWorkflow);
},
disabled: !canApply,
icon: applying ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : undefined,
}}
cancelAction={{ label: "Cancel", onClick: onClose }}
>
<div className="flex min-h-0 flex-1 flex-col gap-3">
<p className="text-sm text-gray-500">
Choose a tabular review workflow. Applying it will replace
the current review columns with the workflow preset.
</p>
<div className="flex h-9 items-center gap-2 rounded-xl bg-gray-100 px-3">
<Search className="h-3.5 w-3.5 shrink-0 text-gray-400" />
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search workflows..."
className="min-w-0 flex-1 bg-transparent text-sm text-gray-800 outline-none placeholder:text-gray-400"
/>
</div>
<div className="min-h-0 flex-1 overflow-y-auto rounded-xl bg-gray-50 p-1.5">
{loading ? (
<div className="space-y-2 p-1">
{[1, 2, 3, 4].map((index) => (
<div
key={index}
className="h-14 animate-pulse rounded-xl bg-white"
/>
))}
</div>
) : filteredWorkflows.length === 0 ? (
<div className="flex h-32 items-center justify-center text-sm text-gray-400">
No workflows found
</div>
) : (
filteredWorkflows.map((workflow) => {
const selected = workflow.id === selectedWorkflowId;
const columnCount =
workflow.columns_config?.length ?? 0;
return (
<button
key={workflow.id}
type="button"
onClick={() =>
setSelectedWorkflowId(workflow.id)
}
disabled={columnCount === 0}
className={cn(
"flex w-full items-center justify-between gap-3 rounded-xl px-3 py-2.5 text-left transition-colors",
selected
? "bg-white text-gray-950 shadow-[0_1px_4px_rgba(15,23,42,0.06)]"
: "text-gray-700 hover:bg-white/75",
columnCount === 0 &&
"cursor-not-allowed opacity-45",
)}
>
<span className="min-w-0">
<span className="block truncate text-sm font-medium">
{workflow.title}
</span>
<span className="mt-0.5 block truncate text-xs text-gray-400">
{workflow.practice ?? "Tabular"} ·{" "}
{columnCount}{" "}
{columnCount === 1
? "column"
: "columns"}
</span>
</span>
{selected && (
<Check className="h-4 w-4 shrink-0 text-green-600" />
)}
</button>
);
})
)}
</div>
</div>
</Modal>
);
}

View file

@ -472,7 +472,7 @@ function TRAssistantMessage({
title={`${cit.col_name} · ${cit.doc_name.replace(/\.[^.]+$/, "")}`}
className="mx-0.5 inline-flex items-center justify-center rounded-full w-4 h-4 text-[10px] font-medium bg-gray-100 text-gray-900 hover:bg-gray-200 transition-colors align-super font-serif"
>
{idx + 1}
{cit.ref}
</button>
);
}

View file

@ -2,10 +2,24 @@
import { useEffect, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Plus, Loader2, Play, ChevronDown, MessageSquare, Download, Users, Upload, X } from "lucide-react";
import {
Plus,
Loader2,
Play,
ChevronDown,
MessageSquare,
Download,
Users,
Upload,
X,
Pencil,
Trash2,
WandSparkles,
} from "lucide-react";
import {
clearTabularCells,
deleteTabularReview,
getTabularReview,
getProject,
getTabularReviewPeople,
@ -20,14 +34,17 @@ import type {
Project,
TabularCell,
TabularReview,
Workflow,
} from "../shared/types";
import { AddColumnModal } from "./AddColumnModal";
import { ApplyWorkflowPresetModal } from "./ApplyWorkflowPresetModal";
import { AddDocumentsModal } from "../shared/AddDocumentsModal";
import { AddProjectDocsModal } from "../shared/AddProjectDocsModal";
import { PeopleModal } from "../shared/PeopleModal";
import { OwnerOnlyModal } from "../shared/OwnerOnlyModal";
import { ApiKeyMissingModal } from "../shared/ApiKeyMissingModal";
import { RenameableTitle } from "../shared/RenameableTitle";
import { ConfirmPopup } from "../shared/ConfirmPopup";
import { HeaderActionsMenu } from "../shared/HeaderActionsMenu";
import { useAuth } from "@/contexts/AuthContext";
import { useUserProfile } from "@/contexts/UserProfileContext";
import {
@ -62,6 +79,14 @@ export function TRView({ reviewId, projectId }: Props) {
const [addColOpen, setAddColOpen] = useState(false);
const [addDocsOpen, setAddDocsOpen] = useState(false);
const [peopleModalOpen, setPeopleModalOpen] = useState(false);
const [workflowPresetModalOpen, setWorkflowPresetModalOpen] =
useState(false);
const [applyingWorkflowPreset, setApplyingWorkflowPreset] = useState(false);
const [deleteReviewConfirmOpen, setDeleteReviewConfirmOpen] =
useState(false);
const [deleteReviewStatus, setDeleteReviewStatus] = useState<
"idle" | "deleting" | "deleted"
>("idle");
const [ownerOnlyAction, setOwnerOnlyAction] = useState<string | null>(null);
const { user } = useAuth();
const [expandedCell, setExpandedCell] = useState<TabularCell | null>(null);
@ -516,10 +541,97 @@ export function TRView({ reviewId, projectId }: Props) {
async function handleTitleCommit(newTitle: string) {
if (!newTitle || newTitle === review?.title) return;
if (review?.is_owner === false) {
setOwnerOnlyAction("rename this tabular review");
return;
}
setReview((prev) => (prev ? { ...prev, title: newTitle } : prev));
await updateTabularReview(reviewId, { title: newTitle });
}
function requestReviewRename() {
if (review?.is_owner === false) {
setOwnerOnlyAction("rename this tabular review");
return;
}
const nextTitle = window.prompt(
"Rename tabular review",
review?.title ?? "Untitled Review",
);
const trimmed = nextTitle?.trim();
if (!trimmed) return;
void handleTitleCommit(trimmed);
}
function requestReviewDelete() {
if (review?.is_owner === false) {
setOwnerOnlyAction("delete this tabular review");
return;
}
setDeleteReviewStatus("idle");
setDeleteReviewConfirmOpen(true);
}
async function confirmReviewDelete() {
if (deleteReviewStatus === "deleting") return;
setDeleteReviewStatus("deleting");
try {
await deleteTabularReview(reviewId);
setDeleteReviewStatus("deleted");
setTimeout(() => {
router.push(
projectId
? `/projects/${projectId}?tab=reviews`
: "/tabular-reviews",
);
}, 250);
} catch (err) {
setDeleteReviewStatus("idle");
console.error("Failed to delete tabular review", err);
}
}
function requestWorkflowPreset() {
if (review?.is_owner === false) {
setOwnerOnlyAction("apply a preset workflow");
return;
}
setWorkflowPresetModalOpen(true);
}
async function handleApplyWorkflowPreset(workflow: Workflow) {
if (!workflow.columns_config?.length) return;
const nextColumns = workflow.columns_config.map((column, index) => ({
...column,
index,
}));
const previousColumns = columns;
const previousCells = cells;
setApplyingWorkflowPreset(true);
setColumns(nextColumns);
setCells([]);
try {
await saveColumnsConfig(nextColumns);
if (documents.length > 0) {
try {
await clearTabularCells(
reviewId,
documents.map((document) => document.id),
);
} catch (err) {
console.error("Failed to clear old tabular cells", err);
}
}
setWorkflowPresetModalOpen(false);
} catch (err) {
setColumns(previousColumns);
setCells(previousCells);
console.error("Failed to apply workflow preset", err);
} finally {
setApplyingWorkflowPreset(false);
}
}
const q = search.toLowerCase();
const filteredDocuments = q
? documents.filter((d) => d.filename.toLowerCase().includes(q))
@ -573,75 +685,94 @@ export function TRView({ reviewId, projectId }: Props) {
skeletonClassName: "w-40",
}
: {
label: (
<RenameableTitle
value={review?.title || "Untitled Review"}
onCommit={handleTitleCommit}
/>
),
label: review?.title || "Untitled Review",
},
]}
actions={
!loading
? [
{
type: "search",
value: search,
onChange: setSearch,
placeholder: "Search documents…",
},
!projectId
? {
onClick: () =>
setPeopleModalOpen(true),
disabled: loading,
iconOnly: true,
title: "People with access",
icon: <Users className="h-4 w-4" />,
}
: null,
{
onClick: () =>
exportTabularReviewToExcel({
reviewTitle:
review?.title ||
"Tabular Review",
columns,
documents,
cells,
}),
disabled:
columns.length === 0 ||
documents.length === 0,
title: "Export to Excel",
icon: <Download className="h-4 w-4" />,
label: (
<span className="hidden sm:inline">
Export
</span>
),
},
{
onClick: handleGenerate,
disabled:
generating ||
columns.length === 0 ||
documents.length === 0 ||
savingColumnsConfig,
icon: generating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Play className="h-4 w-4" />
),
label: (
<span className="hidden sm:inline">
{generating ? "Running…" : "Run"}
</span>
),
},
]
: undefined
}
actionGroups={[
[
{
type: "search",
value: search,
onChange: setSearch,
placeholder: "Search documents…",
},
!projectId
? {
onClick: () => setPeopleModalOpen(true),
disabled: loading,
iconOnly: true,
title: "People with access",
icon: <Users className="h-4 w-4" />,
}
: null,
{
type: "custom",
render: (
<HeaderActionsMenu
items={[
{
label: "Rename",
icon: Pencil,
onSelect: requestReviewRename,
},
{
label: "Apply preset workflow",
icon: WandSparkles,
onSelect:
requestWorkflowPreset,
},
{
label: "Delete",
icon: Trash2,
onSelect: requestReviewDelete,
variant: "danger",
},
]}
/>
),
},
],
[
{
onClick: () =>
exportTabularReviewToExcel({
reviewTitle:
review?.title || "Tabular Review",
columns,
documents,
cells,
}),
disabled:
columns.length === 0 ||
documents.length === 0,
title: "Export to Excel",
icon: <Download className="h-4 w-4" />,
label: (
<span className="hidden sm:inline">
Export
</span>
),
},
{
onClick: handleGenerate,
disabled:
generating ||
columns.length === 0 ||
documents.length === 0 ||
savingColumnsConfig,
icon: generating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Play className="h-4 w-4" />
),
label: (
<span className="hidden sm:inline">
{generating ? "Running…" : "Run"}
</span>
),
},
],
]}
/>
{/* Toolbar */}
@ -926,6 +1057,37 @@ export function TRView({ reviewId, projectId }: Props) {
}
/>
<ApplyWorkflowPresetModal
open={workflowPresetModalOpen}
applying={applyingWorkflowPreset}
onClose={() => {
if (applyingWorkflowPreset) return;
setWorkflowPresetModalOpen(false);
}}
onApply={handleApplyWorkflowPreset}
/>
<ConfirmPopup
open={deleteReviewConfirmOpen}
title="Delete tabular review?"
message="This will permanently delete the tabular review and its generated cells."
confirmLabel="Delete"
confirmStatus={
deleteReviewStatus === "deleting"
? "loading"
: deleteReviewStatus === "deleted"
? "complete"
: "idle"
}
cancelLabel="Cancel"
onCancel={() => {
if (deleteReviewStatus === "deleting") return;
setDeleteReviewConfirmOpen(false);
setDeleteReviewStatus("idle");
}}
onConfirm={() => void confirmReviewDelete()}
/>
<OwnerOnlyModal
open={!!ownerOnlyAction}
action={ownerOnlyAction ?? undefined}

View file

@ -1,7 +1,6 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import {
ChevronDown,
Folder,
@ -23,6 +22,7 @@ import { useDirectoryData } from "../shared/useDirectoryData";
import { FileDirectory } from "../shared/FileDirectory";
import type { Project } from "../shared/types";
import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext";
import { Modal } from "../shared/Modal";
interface Props {
workflows: Workflow[];
@ -177,7 +177,7 @@ function MarkdownBody({ content }: { content: string }) {
// ---------------------------------------------------------------------------
function AssistantPanel({ workflow }: { workflow: Workflow }) {
return (
<div className="flex-1 border-l border-t border-gray-200 flex flex-col overflow-hidden px-3 pb-3">
<div className="flex-1 flex flex-col overflow-hidden">
<div className="py-3 shrink-0">
<p className="text-xs font-medium text-gray-700">
Workflow Prompt
@ -202,7 +202,7 @@ function TabularPanel({ workflow }: { workflow: Workflow }) {
);
return (
<div className="flex-1 border-l border-t border-gray-200 flex flex-col overflow-hidden px-3 pb-3">
<div className="flex-1 flex flex-col overflow-hidden">
<div className="py-3 shrink-0">
<p className="text-xs font-medium text-gray-700">Columns</p>
</div>
@ -450,60 +450,86 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
p.documents.length > 0,
);
const breadcrumbs =
screen === "select"
? ["Workflows", "Select workflow"]
: [
<button
key="workflows"
type="button"
onClick={() => setScreen("select")}
className="transition-colors hover:text-gray-700"
>
Workflows
</button>,
wf.title,
wf.type === "assistant" ? "New Chat" : "New Review",
];
const selectPageAction = () => {
router.push(`/workflows/${wf.id}`);
handleClose();
};
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
return createPortal(
<div className="fixed inset-0 z-[101] flex items-center justify-center bg-black/20 backdrop-blur-xs">
<div
className={`w-full rounded-2xl bg-white shadow-2xl flex flex-col h-[600px] transition-all duration-200 ${screen === "select" ? "max-w-4xl" : "max-w-2xl"}`}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 shrink-0">
<div className="flex items-center gap-1.5 text-xs text-gray-400">
{screen === "select" ? (
<>
<span>Workflows</span>
<span></span>
<span>Select workflow</span>
</>
) : (
<>
<button
onClick={() => setScreen("select")}
className="hover:text-gray-700 transition-colors"
>
Workflows
</button>
<span></span>
<span className="truncate max-w-[160px]">
{wf.title}
</span>
<span></span>
<span>
{wf.type === "assistant"
? "New Chat"
: "New Review"}
</span>
</>
)}
</div>
<button
onClick={onClose}
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
>
<X className="h-4 w-4" />
</button>
</div>
return (
<Modal
open={!!workflow}
onClose={handleClose}
size={screen === "select" ? "xl" : "lg"}
breadcrumbs={breadcrumbs}
secondaryAction={
screen === "select"
? {
label: wf.is_system ? "View Page" : "Edit",
onClick: selectPageAction,
}
: undefined
}
footerStatus={
screen === "configure" &&
(wf.type === "assistant"
? !inProject && selectedDocIds.size > 0
: selectedDocIds.size > 0) ? (
<span className="text-xs text-gray-400">
{selectedDocIds.size} selected
</span>
) : null
}
primaryAction={
screen === "select"
? {
label: "Use",
onClick: () => setScreen("configure"),
}
: wf.type === "assistant"
? {
label: saving ? "Starting…" : "Start Chat",
onClick: handleStartChat,
disabled:
saving || (inProject && !selectedProjectId),
}
: {
label: saving ? "Creating…" : "Create Review",
onClick: handleCreateReview,
disabled:
saving ||
selectedDocIds.size === 0 ||
(inProject && !selectedProjectId),
}
}
cancelAction={false}
>
{/* ── SELECT SCREEN ── */}
{screen === "select" && (
<>
<div className="flex flex-row flex-1 min-h-0 overflow-hidden">
<div className="flex flex-row flex-1 min-h-0 overflow-hidden gap-3">
{/* Left: workflow list */}
<div className="w-80 shrink-0 flex flex-col border-t border-gray-200">
<div className="w-80 shrink-0 flex flex-col overflow-hidden">
{/* Search */}
<div className="px-3 py-2 shrink-0 border-b border-gray-100">
<div className="px-2 py-3 shrink-0">
<div className="flex items-center gap-1.5 rounded-md border border-gray-200 bg-gray-50 px-2.5 py-1">
<Search className="h-3 w-3 text-gray-400 shrink-0" />
<input
@ -533,7 +559,7 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
ref={isSelected ? selectedRowRef : null}
type="button"
onClick={() => setSelected(wfItem)}
className={`w-full flex items-center gap-3 px-4 py-3 text-xs text-left border-b border-gray-200 transition-colors ${isSelected ? "bg-gray-100" : "hover:bg-gray-50"}`}
className={`w-full flex items-center gap-3 rounded-lg px-3 py-2.5 text-xs text-left transition-colors ${isSelected ? "bg-gray-100 text-gray-900" : "hover:bg-gray-50"}`}
>
<span className={`flex-1 truncate ${isSelected ? "text-gray-900 font-medium" : "text-gray-700"}`}>
{wfItem.title}
@ -551,46 +577,14 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
) : (
<TabularPanel key={wf.id} workflow={wf} />
)}
</div>
<div className="border-t border-gray-200 px-5 py-3 flex items-center justify-between shrink-0">
{wf.is_system ? (
<button
onClick={() => {
router.push(`/workflows/${wf.id}`);
handleClose();
}}
className="rounded-lg border border-gray-200 px-3 py-1.5 text-sm text-gray-500 hover:bg-gray-50 transition-colors"
>
View Page
</button>
) : (
<button
onClick={() => {
router.push(`/workflows/${wf.id}`);
handleClose();
}}
className="rounded-lg border border-gray-200 px-3 py-1.5 text-sm text-gray-500 hover:bg-gray-50 transition-colors"
>
Edit
</button>
)}
<button
onClick={() => setScreen("configure")}
className="rounded-lg bg-gray-900 px-5 py-2 text-sm font-medium text-white hover:bg-gray-700"
>
Use
</button>
</div>
</>
</div>
)}
{/* ── ASSISTANT CONFIGURE SCREEN ── */}
{screen === "configure" && wf.type === "assistant" && (
<>
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
{/* Add-on prompt */}
<div className="px-5 pb-3 shrink-0">
<div className="pb-3 shrink-0">
<p className="text-xs font-medium text-gray-700 mb-2">
Message (optional)
</p>
@ -606,7 +600,7 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
</div>
{/* Toggle row */}
<div className="px-5 py-3 flex flex-col gap-2 shrink-0">
<div className="py-3 flex flex-col gap-2 shrink-0">
<span className="text-xs font-medium text-gray-700">
Create in a project
</span>
@ -623,12 +617,12 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
{inProject ? (
<>
<div className="px-5 pt-1 pb-1 shrink-0">
<div className="pt-1 pb-1 shrink-0">
<p className="text-xs font-medium text-gray-700">
Select project
</p>
</div>
<div className="px-5 pb-2 shrink-0">
<div className="pb-2 shrink-0">
<SimpleProjectPicker
projects={projects}
selectedId={selectedProjectId}
@ -638,14 +632,14 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
</>
) : (
<>
<div className="px-5 pt-1 pb-1 shrink-0">
<div className="pt-1 pb-1 shrink-0">
<p className="text-xs font-medium text-gray-700">
Select documents
</p>
</div>
{/* Search */}
<div className="px-4 pt-1.5 pb-1 shrink-0">
<div className="pt-1.5 pb-1 shrink-0">
<div className="flex items-center gap-1.5 rounded-md border border-gray-200 bg-gray-50 px-2.5 py-1">
<Search className="h-3 w-3 text-gray-400 shrink-0" />
<input
@ -671,7 +665,7 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
</div>
{/* File browser */}
<div className="flex-1 overflow-y-auto px-4 pb-2">
<div className="flex-1 overflow-y-auto pb-2">
<FileDirectory
standaloneDocs={filteredStandalone}
directoryProjects={
@ -691,33 +685,14 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
</div>
</>
)}
</div>
<div className="border-t border-gray-200 px-5 py-3 flex items-center justify-between shrink-0">
<span className="text-xs text-gray-400">
{!inProject && selectedDocIds.size > 0
? `${selectedDocIds.size} selected`
: ""}
</span>
<button
onClick={handleStartChat}
disabled={
saving || (inProject && !selectedProjectId)
}
className="rounded-lg bg-gray-900 px-5 py-2 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-50"
>
{saving ? "Starting…" : "Start Chat"}
</button>
</div>
</>
</div>
)}
{/* ── TABULAR CONFIGURE SCREEN ── */}
{screen === "configure" && wf.type === "tabular" && (
<>
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
{/* Toggle stacked */}
<div className="px-5 pb-3 flex flex-col gap-2 shrink-0">
<div className="pb-3 flex flex-col gap-2 shrink-0">
<span className="text-xs font-medium text-gray-700">
Create in a project
</span>
@ -735,12 +710,12 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
{/* Project section */}
{inProject && (
<>
<div className="px-5 pt-1 pb-1 shrink-0">
<div className="pt-1 pb-1 shrink-0">
<p className="text-xs font-medium text-gray-700">
Select Project
</p>
</div>
<div className="px-5 pb-2 shrink-0">
<div className="pb-2 shrink-0">
<SimpleProjectPicker
projects={projects}
selectedId={selectedProjectId}
@ -757,14 +732,14 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
)}
{/* Documents section */}
<div className="px-5 pt-3 pb-1 shrink-0">
<div className="pt-3 pb-1 shrink-0">
<p className="text-xs font-medium text-gray-700">
Select Documents
</p>
</div>
{/* Search */}
<div className="px-4 pt-1.5 pb-1 shrink-0">
<div className="pt-1.5 pb-1 shrink-0">
<div className="flex items-center gap-1.5 rounded-md border border-gray-200 bg-gray-50 px-2.5 py-1">
<Search className="h-3 w-3 text-gray-400 shrink-0" />
<input
@ -788,7 +763,7 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
</div>
{/* File browser */}
<div className="flex-1 overflow-y-auto px-4 pb-2">
<div className="flex-1 overflow-y-auto pb-2">
<FileDirectory
standaloneDocs={
inProject
@ -812,30 +787,8 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
}
/>
</div>
</div>
<div className="border-t border-gray-200 px-5 py-3 flex items-center justify-between shrink-0">
<span className="text-xs text-gray-400">
{selectedDocIds.size > 0
? `${selectedDocIds.size} selected`
: ""}
</span>
<button
onClick={handleCreateReview}
disabled={
saving ||
selectedDocIds.size === 0 ||
(inProject && !selectedProjectId)
}
className="rounded-lg bg-gray-900 px-5 py-2 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-50"
>
{saving ? "Creating…" : "Create Review"}
</button>
</div>
</>
</div>
)}
</div>
</div>,
document.body,
</Modal>
);
}

View file

@ -361,6 +361,7 @@ export function WorkflowList() {
{/* Page header */}
<PageHeader
shrink
loading={loading}
actions={[
{
type: "search",
@ -421,11 +422,13 @@ export function WorkflowList() {
key={i}
className="flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50"
>
<div className={`${NAME_COL_W} flex shrink-0 items-center gap-4 pl-4 pr-2`}>
<div className="h-2.5 w-2.5 shrink-0 rounded bg-gray-100 animate-pulse" />
<div className="h-3.5 w-48 rounded bg-gray-100 animate-pulse" />
<div className={`sticky left-0 z-[60] ${NAME_COL_W} ${stickyCellBg} py-2 pl-4 pr-2`}>
<div className="flex items-center gap-4">
<div className="h-2.5 w-2.5 shrink-0 rounded bg-gray-100 animate-pulse" />
<div className="h-3.5 w-48 rounded bg-gray-100 animate-pulse" />
</div>
</div>
<div className="w-28 shrink-0">
<div className="ml-auto w-28 shrink-0">
<div className="h-3 w-16 rounded bg-gray-100 animate-pulse" />
</div>
<div className="w-40 shrink-0">

View file

@ -222,6 +222,7 @@ export interface UserProfile {
titleModel: string;
tabularModel: string;
mfaOnLogin: boolean;
legalResearchUs: boolean;
apiKeyStatus: ApiKeyStatus;
}
@ -234,6 +235,7 @@ export async function updateUserProfile(payload: {
organisation?: string | null;
titleModel?: string;
tabularModel?: string;
legalResearchUs?: boolean;
}): Promise<UserProfile> {
return apiRequest<UserProfile>("/user/profile", {
method: "PATCH",
@ -435,6 +437,8 @@ export interface DocumentVersion {
file_type?: string | null;
size_bytes?: number | null;
page_count?: number | null;
deleted_at?: string | null;
deleted_by?: string | null;
}
export async function listDocumentVersions(documentId: string): Promise<{
@ -465,6 +469,28 @@ export async function uploadDocumentVersion(
return response.json() as Promise<DocumentVersion>;
}
export async function replaceDocumentVersionFile(
documentId: string,
versionId: string,
file: File,
filename?: string,
): Promise<DocumentVersion> {
const authHeaders = await getAuthHeader();
const form = new FormData();
form.append("file", file);
if (filename) form.append("filename", filename);
const response = await fetch(
`${API_BASE}/single-documents/${documentId}/versions/${versionId}/file`,
{
method: "PUT",
headers: { ...authHeaders },
body: form,
},
);
if (!response.ok) throw new Error(await response.text());
return response.json() as Promise<DocumentVersion>;
}
export async function copyDocumentVersionFromDocument(
documentId: string,
sourceDocumentId: string,

View file

@ -30,6 +30,7 @@ interface UserProfile {
titleModel: string;
tabularModel: string;
mfaOnLogin: boolean;
legalResearchUs: boolean;
apiKeys: ApiKeyState;
}
@ -43,6 +44,7 @@ interface UserProfileContextType {
value: string,
) => Promise<boolean>;
updateMfaOnLogin: (enabled: boolean) => Promise<boolean>;
updateLegalResearchUs: (enabled: boolean) => Promise<boolean>;
updateApiKey: (
provider: ApiKeyProvider,
value: string | null,
@ -118,6 +120,7 @@ export function UserProfileProvider({ children }: { children: ReactNode }) {
titleModel: "gemini-3.1-flash-lite-preview",
tabularModel: "gemini-3-flash-preview",
mfaOnLogin: false,
legalResearchUs: true,
apiKeys: emptyApiKeys(),
});
} finally {
@ -209,6 +212,24 @@ export function UserProfileProvider({ children }: { children: ReactNode }) {
[user],
);
const updateLegalResearchUs = useCallback(
async (enabled: boolean): Promise<boolean> => {
if (!user) return false;
try {
const updated = await updateUserProfile({
legalResearchUs: enabled,
});
setProfile((prev) =>
prev ? { ...prev, ...toProfile(updated) } : null,
);
return true;
} catch {
return false;
}
},
[user],
);
const updateApiKey = useCallback(
async (
provider: ApiKeyProvider,
@ -269,6 +290,7 @@ export function UserProfileProvider({ children }: { children: ReactNode }) {
updateOrganisation,
updateModelPreference,
updateMfaOnLogin,
updateLegalResearchUs,
updateApiKey,
reloadProfile,
incrementMessageCredits,