From 44e868eb4235b131dd3f3169042e5436ce792a7a Mon Sep 17 00:00:00 2001 From: willchen96 Date: Sat, 6 Jun 2026 15:48:47 +0800 Subject: [PATCH] Add courtlistener intergration, liquid glass redesign, UI improvements, version control, various fixes --- README.md | 38 +- backend/.env.example | 3 + .../20260606_oss_schema_diff.sql | 162 + backend/schema.sql | 54 +- backend/src/index.ts | 20 +- backend/src/lib/chatTools.ts | 6946 ++++++++++------- backend/src/lib/courtlistener.ts | 1008 +++ backend/src/lib/documentVersions.ts | 42 +- .../legalSourcesTools/courtlistenerTools.ts | 197 + backend/src/lib/llm/claude.ts | 120 +- backend/src/lib/llm/gemini.ts | 252 +- backend/src/lib/llm/models.ts | 6 +- backend/src/lib/llm/openai.ts | 68 +- backend/src/lib/llm/rawStreamLog.ts | 19 + backend/src/lib/llm/types.ts | 3 + backend/src/lib/storage.ts | 32 +- backend/src/lib/userApiKeys.ts | 27 +- backend/src/lib/userSettings.ts | 6 +- backend/src/routes/caseLaw.ts | 84 + backend/src/routes/chat.ts | 69 +- backend/src/routes/documents.ts | 655 +- backend/src/routes/projectChat.ts | 48 +- backend/src/routes/projects.ts | 290 +- backend/src/routes/tabular.ts | 149 +- backend/src/routes/user.ts | 140 +- frontend/.env.local.example | 1 - frontend/bun.lock | 5 + frontend/package-lock.json | 17 + frontend/package.json | 1 + .../src/app/(pages)/account/api-keys/page.tsx | 222 + frontend/src/app/(pages)/account/layout.tsx | 5 +- .../src/app/(pages)/account/models/page.tsx | 345 +- frontend/src/app/(pages)/account/page.tsx | 307 +- .../app/(pages)/assistant/chat/[id]/page.tsx | 1 + frontend/src/app/(pages)/assistant/page.tsx | 15 +- frontend/src/app/(pages)/layout.tsx | 11 +- .../[id]/assistant/chat/[chatId]/page.tsx | 151 +- .../src/app/(pages)/tabular-reviews/page.tsx | 151 +- .../src/app/(pages)/workflows/[id]/page.tsx | 165 +- .../app/components/assistant/AddDocButton.tsx | 14 +- .../components/assistant/AssistantMessage.tsx | 1072 ++- .../assistant/AssistantSidePanel.tsx | 118 +- .../assistant/AssistantWorkflowModal.tsx | 97 +- .../app/components/assistant/CaseLawPanel.tsx | 623 ++ .../app/components/assistant/ChatInput.tsx | 69 +- .../src/app/components/assistant/ChatView.tsx | 255 +- .../src/app/components/assistant/EditCard.tsx | 28 +- .../app/components/assistant/InitialView.tsx | 10 +- .../app/components/assistant/ModelToggle.tsx | 15 +- .../assistant/SelectAssistantProjectModal.tsx | 69 +- .../components/projects/DocumentSidePanel.tsx | 769 ++ .../components/projects/NewProjectModal.tsx | 204 +- .../projects/ProjectAssistantTab.tsx | 98 +- .../components/projects/ProjectExplorer.tsx | 24 +- .../app/components/projects/ProjectPage.tsx | 939 ++- .../components/projects/ProjectPageParts.tsx | 463 +- .../components/projects/ProjectReviewsTab.tsx | 102 +- .../components/projects/ProjectsOverview.tsx | 136 +- .../components/shared/AddDocumentsModal.tsx | 190 +- .../components/shared/AddProjectDocsModal.tsx | 324 +- .../components/shared/ApiKeyMissingModal.tsx | 65 +- .../src/app/components/shared/AppSidebar.tsx | 125 +- .../app/components/shared/ConfirmPopup.tsx | 104 + .../src/app/components/shared/DocPanel.tsx | 272 +- .../src/app/components/shared/DocView.tsx | 14 +- .../app/components/shared/DocViewModal.tsx | 6 +- .../app/components/shared/DocumentCard.tsx | 11 +- .../src/app/components/shared/DocxView.tsx | 18 +- .../app/components/shared/FileDirectory.tsx | 18 +- .../app/components/shared/HeaderSearchBtn.tsx | 57 - frontend/src/app/components/shared/Modal.tsx | 199 + .../app/components/shared/OwnerOnlyModal.tsx | 69 +- .../src/app/components/shared/PageHeader.tsx | 442 ++ .../src/app/components/shared/PeopleModal.tsx | 194 +- .../components/shared/PreResponseWrapper.tsx | 4 +- .../app/components/shared/ProjectPicker.tsx | 8 +- .../app/components/shared/RelevantQuotes.tsx | 297 + .../app/components/shared/SidebarChatItem.tsx | 14 +- .../shared/UploadNewVersionModal.tsx | 139 +- .../app/components/shared/WarningPopup.tsx | 108 + .../components/shared/highlightDocxQuote.ts | 3 + frontend/src/app/components/shared/types.ts | 360 +- .../app/components/shared/useDirectoryData.ts | 10 +- .../app/components/tabular/AddNewTRModal.tsx | 178 +- .../app/components/tabular/TRChatPanel.tsx | 487 +- .../components/tabular/TREditColumnMenu.tsx | 2 - .../app/components/tabular/TRSidePanel.tsx | 29 +- .../src/app/components/tabular/TRTable.tsx | 61 +- .../components/tabular/TabularReviewView.tsx | 264 +- .../app/components/tabular/exportToExcel.ts | 8 +- .../workflows/DisplayWorkflowModal.tsx | 35 +- .../components/workflows/NewWorkflowModal.tsx | 226 +- .../workflows/ShareWorkflowModal.tsx | 151 +- .../workflows/WFColumnViewModal.tsx | 82 +- .../app/components/workflows/WorkflowList.tsx | 97 +- .../components/workflows/builtinWorkflows.ts | 4 +- .../src/app/contexts/ChatHistoryContext.tsx | 18 +- frontend/src/app/globals.css | 10 +- frontend/src/app/hooks/useAssistantChat.ts | 2071 ++--- frontend/src/app/hooks/useFetchDocxBytes.ts | 8 - .../src/app/lib/documentUploadValidation.ts | 27 + frontend/src/app/lib/mikeApi.ts | 194 +- frontend/src/app/lib/modelAvailability.ts | 4 +- frontend/src/contexts/UserProfileContext.tsx | 22 +- frontend/src/lib/auth.ts | 2 - frontend/src/lib/storage.ts | 132 - 106 files changed, 16350 insertions(+), 7753 deletions(-) create mode 100644 backend/oss-migrations/20260606_oss_schema_diff.sql create mode 100644 backend/src/lib/courtlistener.ts create mode 100644 backend/src/lib/legalSourcesTools/courtlistenerTools.ts create mode 100644 backend/src/lib/llm/rawStreamLog.ts create mode 100644 backend/src/routes/caseLaw.ts create mode 100644 frontend/src/app/(pages)/account/api-keys/page.tsx create mode 100644 frontend/src/app/components/assistant/CaseLawPanel.tsx create mode 100644 frontend/src/app/components/projects/DocumentSidePanel.tsx create mode 100644 frontend/src/app/components/shared/ConfirmPopup.tsx delete mode 100644 frontend/src/app/components/shared/HeaderSearchBtn.tsx create mode 100644 frontend/src/app/components/shared/Modal.tsx create mode 100644 frontend/src/app/components/shared/PageHeader.tsx create mode 100644 frontend/src/app/components/shared/RelevantQuotes.tsx create mode 100644 frontend/src/app/components/shared/WarningPopup.tsx create mode 100644 frontend/src/app/lib/documentUploadValidation.ts delete mode 100644 frontend/src/lib/storage.ts diff --git a/README.md b/README.md index 9e70f9a..9fc24a9 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Website: [mikeoss.com](https://mikeoss.com) - `frontend/` - Next.js application - `backend/` - Express API, Supabase access, document processing, and database schema - `backend/schema.sql` - Supabase schema for fresh databases -- `backend/migrations/` - incremental database updates for existing deployments +- `backend/oss-migrations/` - OSS-specific migrations that should be applied to existing open-source deployments ## Prerequisites @@ -19,6 +19,7 @@ Website: [mikeoss.com](https://mikeoss.com) - A Supabase project - A Cloudflare R2 bucket, MinIO bucket, or another S3-compatible bucket - At least one supported model provider API key: Anthropic, Google Gemini, or OpenAI +- Optional: a CourtListener API token for case law lookup and citation verification - LibreOffice installed locally if you need DOC/DOCX to PDF conversion ## Database Setup @@ -30,9 +31,9 @@ For a new Supabase database, open the Supabase SQL editor and run: -- backend/schema.sql ``` -The schema file is based on `supabase-migration.sql` and folds in the later files in `backend/migrations/`. +The schema file is for fresh deployments and already includes the latest database shape. -For an existing database, do not run the full schema file over production data. Apply the incremental files in `backend/migrations/` instead. +For an existing database, do not run the full schema file over production data. Apply the relevant incremental files in `backend/oss-migrations/` instead; these capture schema changes for open-source deployments. ## Environment @@ -62,6 +63,12 @@ ANTHROPIC_API_KEY=your-anthropic-key OPENAI_API_KEY=your-openai-key RESEND_API_KEY=your-resend-key USER_API_KEYS_ENCRYPTION_SECRET=your-long-random-secret + +# Optional: enables CourtListener case law and citation tools. +COURTLISTENER_API_TOKEN=your-courtlistener-token + +# Optional: use locally imported CourtListener bulk data for faster case reads. +COURTLISTENER_BULK_DATA_ENABLED=false ``` Create `frontend/.env.local`: @@ -74,7 +81,23 @@ NEXT_PUBLIC_API_BASE_URL=http://localhost:3001 Supabase values come from the project dashboard. Use the project URL for `SUPABASE_URL` / `NEXT_PUBLIC_SUPABASE_URL`, the service role key for the backend `SUPABASE_SECRET_KEY`, and the anon/public key for `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY`. If your Supabase project shows multiple key formats, use the legacy JWT-style anon and service role keys expected by the Supabase client libraries. -Provider keys are only needed for the models and email features you plan to use. Model provider keys can be configured in `backend/.env` for the whole instance, or per user in **Account > Models & API Keys**. If a provider key is present in `backend/.env`, that provider is available by default and the matching browser API key field is read-only. +Provider keys are only needed for the models, legal research, and email features you plan to use. Model provider keys and the CourtListener token can be configured in `backend/.env` for the whole instance, or per user in **Account > Models & API Keys**. If a provider key is present in `backend/.env`, that provider is available by default and the matching browser API key field is read-only. + +## CourtListener Integration + +Mike can use CourtListener for US case law citation verification, case fetching, targeted opinion search, and case-law panels in assistant responses. + +To enable live CourtListener access, set `COURTLISTENER_API_TOKEN` in `backend/.env` and restart the backend. Users can also add their own CourtListener token from **Account > Models & API Keys** when the instance does not provide one globally. + +Fresh databases created from `backend/schema.sql` already include the CourtListener support tables. Existing OSS deployments should apply the matching migration in `backend/oss-migrations/` before enabling the feature. + +Bulk data is optional. When `COURTLISTENER_BULK_DATA_ENABLED=true`, Mike first tries local Supabase/R2 data before falling back to CourtListener's API: + +- citation metadata is read from `public.courtlistener_citation_index` +- case cluster metadata is read from `public.courtlistener_opinion_cluster_index` +- cached opinion JSON is read from the R2 prefix `courtlistener/opinions/by-cluster/{clusterId}/{opinionId}.json` + +If you do not import bulk data, leave `COURTLISTENER_BULK_DATA_ENABLED=false`; live CourtListener tools still work with a valid token, subject to CourtListener rate limits. ## Install @@ -105,7 +128,8 @@ Open `http://localhost:3000`. 1. Sign up in the app. 2. If you did not set provider keys in `backend/.env`, open **Account > Models & API Keys** and add an Anthropic, Gemini, or OpenAI API key. -3. Create or open a project and start chatting with documents. +3. To use legal research tools, add a CourtListener token in `backend/.env` or **Account > Models & API Keys**. +4. Create or open a project and start chatting with documents. ## Troubleshooting @@ -113,6 +137,10 @@ Open `http://localhost:3000`. **The model picker shows a missing-key warning.** Add a key for that provider in **Account > Models & API Keys**, or configure the provider key in `backend/.env` and restart the backend. +**CourtListener tools say the API token is missing.** Set `COURTLISTENER_API_TOKEN` in `backend/.env`, or add a CourtListener token in **Account > Models & API Keys** for the signed-in user. Restart the backend after changing `.env`. + +**CourtListener bulk lookup is not returning local results.** Confirm `COURTLISTENER_BULK_DATA_ENABLED=true`, the two CourtListener tables have been populated, and opinion JSON exists in R2 under `courtlistener/opinions/by-cluster/`. If bulk data is unavailable, Mike falls back to the live API when a token is configured. + **DOC or DOCX conversion fails.** Install LibreOffice locally and restart the backend so document conversion commands are available on the process path. ## Useful Checks diff --git a/backend/.env.example b/backend/.env.example index 6b4d561..d006aa6 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -18,3 +18,6 @@ ANTHROPIC_API_KEY=your-anthropic-key OPENAI_API_KEY=your-openai-key RESEND_API_KEY=your-resend-key USER_API_KEYS_ENCRYPTION_SECRET=your-long-random-secret + +# Optional: enables higher-rate CourtListener case law/citation lookup tools. +COURTLISTENER_API_TOKEN=your-courtlistener-token diff --git a/backend/oss-migrations/20260606_oss_schema_diff.sql b/backend/oss-migrations/20260606_oss_schema_diff.sql new file mode 100644 index 0000000..3ff2c71 --- /dev/null +++ b/backend/oss-migrations/20260606_oss_schema_diff.sql @@ -0,0 +1,162 @@ +-- OSS migration for the current backend/schema.sql diff. +-- +-- This brings existing OSS Supabase databases in line with the updated fresh +-- schema: model preference columns, BYO provider expansion, per-version +-- document metadata, and CourtListener bulk lookup tables. + +-- --------------------------------------------------------------------------- +-- User profiles +-- --------------------------------------------------------------------------- + +alter table public.user_profiles + add column if not exists title_model text, + add column if not exists quote_model text; + +-- --------------------------------------------------------------------------- +-- User API keys +-- --------------------------------------------------------------------------- + +alter table public.user_api_keys + drop constraint if exists user_api_keys_provider_check; + +alter table public.user_api_keys + add constraint user_api_keys_provider_check + check (provider in ('claude', 'gemini', 'openai', 'openrouter', 'courtlistener')); + +-- --------------------------------------------------------------------------- +-- Document metadata now lives on document_versions +-- --------------------------------------------------------------------------- + +alter table public.document_versions + add column if not exists filename text, + add column if not exists file_type text, + add column if not exists size_bytes integer, + add column if not exists page_count integer; + +do $$ +begin + if exists ( + select 1 + from information_schema.columns + where table_schema = 'public' + and table_name = 'document_versions' + and column_name = 'display_name' + ) then + update public.document_versions dv + set filename = dv.display_name + where (dv.filename is null or btrim(dv.filename) = '') + and dv.display_name is not null + and btrim(dv.display_name) <> ''; + end if; + + if exists ( + select 1 + from information_schema.columns + where table_schema = 'public' + and table_name = 'documents' + and column_name = 'filename' + ) then + update public.document_versions dv + set filename = d.filename + from public.documents d + where dv.document_id = d.id + and (dv.filename is null or btrim(dv.filename) = '') + and d.filename is not null + and btrim(d.filename) <> ''; + end if; +end $$; + +do $$ +begin + if exists ( + select 1 + from information_schema.columns + where table_schema = 'public' + and table_name = 'documents' + and column_name = 'file_type' + ) then + update public.document_versions dv + set file_type = coalesce(nullif(btrim(dv.file_type), ''), d.file_type) + from public.documents d + where dv.document_id = d.id + and (dv.file_type is null or btrim(dv.file_type) = ''); + end if; + + if exists ( + select 1 + from information_schema.columns + where table_schema = 'public' + and table_name = 'documents' + and column_name = 'size_bytes' + ) then + update public.document_versions dv + set size_bytes = d.size_bytes + from public.documents d + where dv.document_id = d.id + and dv.size_bytes is null + and d.size_bytes is not null; + end if; + + if exists ( + select 1 + from information_schema.columns + where table_schema = 'public' + and table_name = 'documents' + and column_name = 'page_count' + ) then + update public.document_versions dv + set page_count = d.page_count + from public.documents d + where dv.document_id = d.id + and dv.page_count is null + and d.page_count is not null; + end if; +end $$; + +alter table public.document_versions + drop column if exists display_name; + +alter table public.documents + drop column if exists filename, + drop column if exists file_type, + drop column if exists size_bytes, + drop column if exists page_count, + drop column if exists structure_tree; + +-- --------------------------------------------------------------------------- +-- CourtListener bulk-data indexes +-- --------------------------------------------------------------------------- + +create table if not exists public.courtlistener_citation_index ( + id bigint primary key, + volume text not null, + reporter text not null, + page text not null, + type integer, + cluster_id bigint not null, + date_created timestamptz, + date_modified timestamptz +); + +create index if not exists courtlistener_citation_lookup_idx + on public.courtlistener_citation_index(volume, reporter, page); + +create index if not exists courtlistener_citation_cluster_idx + on public.courtlistener_citation_index(cluster_id); + +create table if not exists public.courtlistener_opinion_cluster_index ( + id bigint primary key, + case_name text, + case_name_short text, + case_name_full text, + slug text, + date_filed date, + citation_count integer, + precedential_status text, + filepath_pdf_harvard text, + filepath_json_harvard text, + docket_id bigint +); + +revoke all on public.courtlistener_citation_index from anon, authenticated; +revoke all on public.courtlistener_opinion_cluster_index from anon, authenticated; diff --git a/backend/schema.sql b/backend/schema.sql index b6a4e93..83a4cf3 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -1,7 +1,6 @@ -- Mike Supabase schema --- Based on supabase-migration.sql plus the later backend/migrations/*.sql files. -- Use this for a fresh Supabase database. Existing deployments should continue --- to apply the incremental migration files instead. +-- to apply the incremental migration files in backend/oss-migrations instead. create extension if not exists "pgcrypto"; @@ -17,7 +16,9 @@ create table if not exists public.user_profiles ( tier text not null default 'Free', message_credits_used integer not null default 0, credits_reset_date timestamptz not null default (now() + interval '30 days'), + title_model text, tabular_model text not null default 'gemini-3-flash-preview', + quote_model text, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); @@ -50,7 +51,7 @@ create trigger on_auth_user_created create table if not exists public.user_api_keys ( id uuid primary key default gen_random_uuid(), user_id uuid not null references auth.users(id) on delete cascade, - provider text not null check (provider in ('claude', 'gemini', 'openai')), + provider text not null check (provider in ('claude', 'gemini', 'openai', 'openrouter', 'courtlistener')), encrypted_key text not null, iv text not null, auth_tag text not null, @@ -100,11 +101,6 @@ create table if not exists public.documents ( id uuid primary key default gen_random_uuid(), project_id uuid references public.projects(id) on delete cascade, user_id text not null, - filename text not null, - file_type text, - size_bytes integer not null default 0, - page_count integer, - structure_tree jsonb, status text not null default 'pending', folder_id uuid references public.project_subfolders(id) on delete set null, created_at timestamptz not null default now(), @@ -124,7 +120,10 @@ create table if not exists public.document_versions ( pdf_storage_path text, source text not null default 'upload', version_number integer, - display_name text, + filename text, + file_type text, + size_bytes integer, + page_count integer, created_at timestamptz not null default now(), constraint document_versions_source_check check (source = any (array[ @@ -341,6 +340,41 @@ create table if not exists public.tabular_review_chat_messages ( create index if not exists tabular_review_chat_messages_chat_idx on public.tabular_review_chat_messages(chat_id, created_at); +-- --------------------------------------------------------------------------- +-- CourtListener bulk-data indexes +-- --------------------------------------------------------------------------- + +create table if not exists public.courtlistener_citation_index ( + id bigint primary key, + volume text not null, + reporter text not null, + page text not null, + type integer, + cluster_id bigint not null, + date_created timestamptz, + date_modified timestamptz +); + +create index if not exists courtlistener_citation_lookup_idx + on public.courtlistener_citation_index(volume, reporter, page); + +create index if not exists courtlistener_citation_cluster_idx + on public.courtlistener_citation_index(cluster_id); + +create table if not exists public.courtlistener_opinion_cluster_index ( + id bigint primary key, + case_name text, + case_name_short text, + case_name_full text, + slug text, + date_filed date, + citation_count integer, + precedential_status text, + filepath_pdf_harvard text, + filepath_json_harvard text, + docket_id bigint +); + -- --------------------------------------------------------------------------- -- Direct client grant hardening -- --------------------------------------------------------------------------- @@ -366,3 +400,5 @@ revoke all on public.tabular_cells from anon, authenticated; revoke all on public.tabular_review_chats from anon, authenticated; revoke all on public.tabular_review_chat_messages from anon, authenticated; revoke all on public.user_api_keys from anon, authenticated; +revoke all on public.courtlistener_citation_index from anon, authenticated; +revoke all on public.courtlistener_opinion_cluster_index from anon, authenticated; diff --git a/backend/src/index.ts b/backend/src/index.ts index 07b3b84..e8732f2 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -11,6 +11,7 @@ import { tabularRouter } from "./routes/tabular"; import { workflowsRouter } from "./routes/workflows"; import { userRouter } from "./routes/user"; import { downloadsRouter } from "./routes/downloads"; +import { caseLawRouter } from "./routes/caseLaw"; const app = express(); const PORT = process.env.PORT ?? 3001; @@ -71,12 +72,22 @@ const uploadLimiter = makeLimiter({ message: "Too many upload requests. Please try again later.", }); +function jsonLimitForPath(path: string): string { + return "50mb"; +} + app.disable("x-powered-by"); app.set("trust proxy", envInt("TRUST_PROXY_HOPS", 1)); app.use( helmet({ - contentSecurityPolicy: false, + contentSecurityPolicy: { + directives: { + defaultSrc: ["'none'"], + baseUri: ["'none'"], + frameAncestors: ["'none'"], + }, + }, crossOriginEmbedderPolicy: false, hsts: isProduction ? { @@ -97,8 +108,6 @@ app.use( app.use(generalLimiter); -app.use(express.json({ limit: "50mb" })); - app.post("/chat", chatLimiter); app.post("/projects/:projectId/chat", chatLimiter); app.post("/tabular-review/:reviewId/chat", chatLimiter); @@ -109,6 +118,10 @@ app.post("/single-documents", uploadLimiter); app.post("/single-documents/:documentId/versions", uploadLimiter); app.post("/projects/:projectId/documents", uploadLimiter); +app.use((req, res, next) => + express.json({ limit: jsonLimitForPath(req.path) })(req, res, next), +); + app.use("/chat", chatRouter); app.use("/projects", projectsRouter); app.use("/projects/:projectId/chat", projectChatRouter); @@ -118,6 +131,7 @@ app.use("/workflows", workflowsRouter); app.use("/user", userRouter); app.use("/users", userRouter); app.use("/download", downloadsRouter); +app.use("/case-law", caseLawRouter); app.get("/health", (_req, res) => res.json({ ok: true })); diff --git a/backend/src/lib/chatTools.ts b/backend/src/lib/chatTools.ts index 6d85c6a..b16894f 100644 --- a/backend/src/lib/chatTools.ts +++ b/backend/src/lib/chatTools.ts @@ -1,80 +1,97 @@ import path from "path"; import { - downloadFile, - generatedDocKey, - storageKey, - uploadFile, + downloadFile, + generatedDocKey, + storageKey, + uploadFile, } from "./storage"; import { convertedPdfKey } from "./convert"; import { createServerSupabase } from "./supabase"; import { - applyTrackedEdits, - extractDocxBodyText, - type EditInput, + applyTrackedEdits, + extractDocxBodyText, + type EditInput, } from "./docxTrackedChanges"; import { buildDownloadUrl } from "./downloadTokens"; import { - attachActiveVersionPaths, - loadActiveVersion, + attachActiveVersionPaths, + loadActiveVersion, } from "./documentVersions"; import { - streamChatWithTools, - resolveModel, - DEFAULT_MAIN_MODEL, - type LlmMessage, - type OpenAIToolSchema, + getCourtlistenerCaseOpinions, + getCourtlistenerCases, + searchCourtlistenerCaseLaw, + verifyCourtlistenerCitations, +} from "./courtlistener"; +import { + COURTLISTENER_SYSTEM_PROMPT, + COURTLISTENER_TOOL_NAMES, + COURTLISTENER_TOOLS, + type CaseCitationEvent, + type CourtlistenerToolEvent, +} from "./legalSourcesTools/courtlistenerTools"; +import { + streamChatWithTools, + resolveModel, + DEFAULT_MAIN_MODEL, + type LlmMessage, + type OpenAIToolSchema, } from "./llm"; const STANDARD_FONT_DATA_URL = (() => { - try { - const pkgPath = require.resolve("pdfjs-dist/package.json"); - return path.join(path.dirname(pkgPath), "standard_fonts") + path.sep; - } catch { - return undefined; - } + try { + const pkgPath = require.resolve("pdfjs-dist/package.json"); + return path.join(path.dirname(pkgPath), "standard_fonts") + path.sep; + } catch { + return undefined; + } })(); +const isDev = process.env.NODE_ENV !== "production"; +const devLog = (...args: Parameters) => { + if (isDev) console.log(...args); +}; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export type DocStore = Map< - string, - { storage_path: string; file_type: string; filename: string } + string, + { storage_path: string; file_type: string; filename: string } >; export type WorkflowStore = Map; export type DocIndex = Record< - string, - { - document_id: string; - filename: string; - version_id?: string | null; - version_number?: number | null; - } + string, + { + document_id: string; + filename: string; + version_id?: string | null; + version_number?: number | null; + } >; export type TabularCellStore = { - columns: { index: number; name: string }[]; - documents: { id: string; filename: string }[]; - /** key: `${colIndex}:${docId}` */ - cells: Map< - string, - { summary: string; flag?: string; reasoning?: string } | null - >; + columns: { index: number; name: string }[]; + documents: { id: string; filename: string }[]; + /** key: `${colIndex}:${docId}` */ + cells: Map< + string, + { summary: string; flag?: string; reasoning?: string } | null + >; }; export type ToolCall = { - id: string; - function: { name: string; arguments: string }; + id: string; + function: { name: string; arguments: string }; }; export type ChatMessage = { - role: string; - content: string | null; - files?: { filename: string; document_id?: string }[]; - workflow?: { id: string; title: string }; + role: string; + content: string | null; + files?: { filename: string; document_id?: string }[]; + workflow?: { id: string; title: string }; }; // --------------------------------------------------------------------------- @@ -84,7 +101,8 @@ export type ChatMessage = { export const SYSTEM_PROMPT = `You are Mike, an AI legal assistant that helps lawyers and legal professionals analyze documents, answer legal questions, and draft legal documents. DOCUMENT CITATION INSTRUCTIONS: -When you reference specific content from a document, place a numbered marker [1], [2], etc. inline in your prose at the point of reference. +When you reference specific content from an uploaded/generated document, place a numbered marker [1], [2], etc. inline in your prose at the point of reference. +These numbered [N] markers and the block are for evidence passages that the UI can open. Uploaded/generated document citations use the document entry shape below. Research tools may define additional source-specific citation entry shapes in their own instructions. After your complete response, append a block containing a JSON array with one entry per marker: @@ -99,30 +117,28 @@ CRITICAL: The number inside the [N] marker in your prose is the "ref" value of a Rules: - Only cite text that appears verbatim in the provided documents -- In every 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" -- 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 its own citation +- In every document 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 "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 citations for text on different pages +- 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 block at the very end of the response. Omit it entirely if 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. Only fall back to generate_docx if the user explicitly asks for a brand-new document or the change is so sweeping that an edit would not be coherent. -After calling generate_docx, do NOT include any download links, URLs, or markdown links to the document in your prose response — the download card is presented automatically by the UI. Do not describe formatting choices such as orientation or layout. -After calling generate_docx, you MUST call read_document on the returned doc_id before writing your prose response. Base your description on the generated document's actual text, not on memory of what you intended to generate. -Your prose response MUST include a short description of the generated document: what it is, its structure (key sections/clauses), and — if the draft was informed by any provided source documents — which sources you drew from and how. Keep it concise (typically 3–8 sentences or a short bulleted list). Refer to the document by filename, never by a download link. -When the description makes factual claims about the contents of the newly generated document, cite the generated document with [N] markers and a block exactly as specified in the DOCUMENT CITATION INSTRUCTIONS above. If you also make factual claims about provided source documents, cite those source documents separately. In every citation entry, use the exact chat-local doc_id label for the cited document. Omit the block if the description makes no such claims. +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. +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. +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. 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. +- 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. @@ -130,360 +146,468 @@ When using edit_document, any edit that adds, removes, or reorders a numbered cl WORKFLOWS: When a user message begins with a [Workflow: (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. GENERAL GUIDANCE: - Be precise and professional -- Cite the specific document and quote when making claims about document content +- 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. `; export const PROJECT_EXTRA_TOOLS = [ - { - type: "function", - function: { - name: "list_documents", - description: - "List all documents available in the project. Returns each document's ID, filename, and file type. Call this to discover what documents are available before deciding which ones to read.", - parameters: { type: "object", properties: {} }, - }, + { + type: "function", + function: { + name: "list_documents", + description: + "List all documents available in the project. Returns each document's ID, filename, and file type. Call this to discover what documents are available before deciding which ones to read.", + parameters: { type: "object", properties: {} }, }, - { - type: "function", - function: { - name: "fetch_documents", + }, + { + type: "function", + function: { + name: "fetch_documents", + description: + "Read the full text content of multiple documents in a single call. Use this instead of calling read_document repeatedly when you need to read several documents at once.", + parameters: { + type: "object", + properties: { + doc_ids: { + type: "array", + items: { type: "string" }, description: - "Read the full text content of multiple documents in a single call. Use this instead of calling read_document repeatedly when you need to read several documents at once.", - parameters: { - type: "object", - properties: { - doc_ids: { - type: "array", - items: { type: "string" }, - description: - "Array of document IDs to read (e.g. ['doc-0', 'doc-2'])", - }, - }, - required: ["doc_ids"], - }, + "Array of document IDs to read (e.g. ['doc-0', 'doc-2'])", + }, }, + required: ["doc_ids"], + }, }, - { - type: "function", - function: { - name: "replicate_document", + }, + { + type: "function", + function: { + name: "replicate_document", + description: + "Make byte-for-byte copies of an existing project document as new project documents. Use when the user wants standalone copies to edit (e.g. 'use this NDA as a template', 'give me three drafts I can adapt') without modifying the original. Pass `count` to create multiple copies in a single call rather than calling the tool repeatedly. Returns the new doc_id slugs so you can immediately call edit_document / read_document on them.", + parameters: { + type: "object", + properties: { + doc_id: { + type: "string", + description: "ID of the source document to copy (e.g. 'doc-0').", + }, + count: { + type: "integer", description: - "Make byte-for-byte copies of an existing project document as new project documents. Use when the user wants standalone copies to edit (e.g. 'use this NDA as a template', 'give me three drafts I can adapt') without modifying the original. Pass `count` to create multiple copies in a single call rather than calling the tool repeatedly. Returns the new doc_id slugs so you can immediately call edit_document / read_document on them.", - parameters: { - type: "object", - properties: { - doc_id: { - type: "string", - description: - "ID of the source document to copy (e.g. 'doc-0').", - }, - count: { - type: "integer", - description: - "How many copies to create. Defaults to 1. Maximum 20.", - minimum: 1, - maximum: 20, - }, - new_filename: { - type: "string", - description: - "Optional base filename. With count > 1, copies are suffixed (e.g. 'Foo (1).docx', 'Foo (2).docx'). Extension is forced to match the source.", - }, - }, - required: ["doc_id"], - }, + "How many copies to create. Defaults to 1. Maximum 20.", + minimum: 1, + maximum: 20, + }, + new_filename: { + type: "string", + description: + "Optional base filename. With count > 1, copies are suffixed (e.g. 'Foo (1).docx', 'Foo (2).docx'). Extension is forced to match the source.", + }, }, + required: ["doc_id"], + }, }, + }, ]; export const TABULAR_TOOLS = [ - { - type: "function", - function: { - name: "read_table_cells", + { + type: "function", + function: { + name: "read_table_cells", + description: + "Read the extracted cell content from the tabular review. Each cell contains the value extracted for a specific column from a specific document. Pass col_indices and/or row_indices (0-based) to read a subset; omit either to read all columns or all rows.", + parameters: { + type: "object", + properties: { + col_indices: { + type: "array", + items: { type: "integer" }, description: - "Read the extracted cell content from the tabular review. Each cell contains the value extracted for a specific column from a specific document. Pass col_indices and/or row_indices (0-based) to read a subset; omit either to read all columns or all rows.", - parameters: { - type: "object", - properties: { - col_indices: { - type: "array", - items: { type: "integer" }, - description: - "0-based column indices to read (e.g. [0, 2]). Omit to read all columns.", - }, - row_indices: { - type: "array", - items: { type: "integer" }, - description: - "0-based document (row) indices to read (e.g. [0, 1]). Omit to read all rows.", - }, - }, - }, + "0-based column indices to read (e.g. [0, 2]). Omit to read all columns.", + }, + row_indices: { + type: "array", + items: { type: "integer" }, + description: + "0-based document (row) indices to read (e.g. [0, 1]). Omit to read all rows.", + }, }, + }, }, + }, ]; export const WORKFLOW_TOOLS = [ - { - type: "function", - function: { - name: "list_workflows", - description: - "List all workflows available to the user. Returns each workflow's ID and title. Call this when the user asks to run a workflow, apply a template, or you need to discover what workflows exist.", - parameters: { type: "object", properties: {} }, - }, + { + type: "function", + function: { + name: "list_workflows", + description: + "List all workflows available to the user. Returns each workflow's ID and title. Call this when the user asks to run a workflow, apply a template, or you need to discover what workflows exist.", + parameters: { type: "object", properties: {} }, }, - { - type: "function", - function: { - name: "read_workflow", - description: - "Read the full instructions (prompt) of a workflow by its ID. Call this after list_workflows to load a specific workflow's prompt, then follow those instructions.", - parameters: { - type: "object", - properties: { - workflow_id: { - type: "string", - description: "The workflow ID to read", - }, - }, - required: ["workflow_id"], - }, + }, + { + type: "function", + function: { + name: "read_workflow", + description: + "Read the full instructions (prompt) of a workflow by its ID. Call this after list_workflows to load a specific workflow's prompt, then follow those instructions.", + parameters: { + type: "object", + properties: { + workflow_id: { + type: "string", + description: "The workflow ID to read", + }, }, + required: ["workflow_id"], + }, }, + }, ]; export const TOOLS = [ - { - type: "function", - function: { - name: "read_document", - description: - "Read the full text content of a document attached by the user. Always call this before answering questions about, summarising, or citing from a document.", - parameters: { - type: "object", - properties: { - doc_id: { - type: "string", - description: - "The document ID to read (e.g. 'doc-0', 'doc-1')", - }, - }, - required: ["doc_id"], - }, + { + type: "function", + function: { + name: "read_document", + description: + "Read the full text content of a document attached by the user. Always call this before answering questions about, summarising, or citing from a document.", + parameters: { + type: "object", + properties: { + doc_id: { + type: "string", + description: "The document ID to read (e.g. 'doc-0', 'doc-1')", + }, }, + required: ["doc_id"], + }, }, - { - type: "function", - function: { - name: "find_in_document", + }, + { + type: "function", + function: { + name: "find_in_document", + description: + "Search for specific strings inside a document — a Ctrl+F equivalent. Returns each match with surrounding context so you can locate and quote the exact text without reading the whole document. Matching is case-insensitive and whitespace-tolerant. Use this for targeted lookups (e.g. finding a clause title, party name, or a specific phrase) rather than reading the whole document.", + parameters: { + type: "object", + properties: { + doc_id: { + type: "string", + description: "The document ID to search (e.g. 'doc-0').", + }, + query: { + type: "string", description: - "Search for specific strings inside a document — a Ctrl+F equivalent. Returns each match with surrounding context so you can locate and quote the exact text without reading the whole document. Matching is case-insensitive and whitespace-tolerant. Use this for targeted lookups (e.g. finding a clause title, party name, or a specific phrase) rather than reading the whole document.", - parameters: { - type: "object", - properties: { - doc_id: { - type: "string", - description: - "The document ID to search (e.g. 'doc-0').", - }, - query: { - type: "string", - description: - "The string to search for. Matching is case-insensitive and collapses runs of whitespace, so 'Section 4.2' matches 'section 4.2'.", - }, - max_results: { - type: "integer", - description: - "Maximum number of matches to return (default 20). Use a smaller value for common terms.", - }, - context_chars: { - type: "integer", - description: - "Characters of surrounding context to include on each side of a match (default 80).", - }, - }, - required: ["doc_id", "query"], - }, + "The string to search for. Matching is case-insensitive and collapses runs of whitespace, so 'Section 4.2' matches 'section 4.2'.", + }, + max_results: { + type: "integer", + description: + "Maximum number of matches to return (default 20). Use a smaller value for common terms.", + }, + context_chars: { + type: "integer", + description: + "Characters of surrounding context to include on each side of a match (default 80).", + }, }, + required: ["doc_id", "query"], + }, }, - { - type: "function", - function: { - name: "generate_docx", + }, + { + type: "function", + function: { + name: "generate_docx", + description: + "Generate a Word (.docx) document from structured content. Use this when the user asks you to draft, create, or produce a legal document. Returns a download URL for the generated file.", + parameters: { + type: "object", + properties: { + title: { + type: "string", + description: "Document title (used as filename and heading)", + }, + landscape: { + type: "boolean", description: - "Generate a Word (.docx) document from structured content. Use this when the user asks you to draft, create, or produce a legal document. Returns a download URL for the generated file.", - parameters: { - type: "object", - properties: { - title: { - type: "string", - description: - "Document title (used as filename and heading)", + "Set to true for landscape page orientation. Default is portrait.", + }, + sections: { + type: "array", + description: + "List of document sections. Each section may contain a heading, prose content, or a table.", + items: { + type: "object", + properties: { + heading: { + type: "string", + description: "Optional section heading", + }, + level: { + type: "integer", + description: "Heading level: 1, 2, or 3", + }, + content: { + type: "string", + description: + "Prose text content (paragraphs separated by double newlines)", + }, + pageBreak: { + type: "boolean", + description: + "Set to true to start this section on a new page. Use for contract signature pages.", + }, + table: { + type: "object", + description: "Optional table to render in this section", + properties: { + headers: { + type: "array", + items: { type: "string" }, + description: "Column header labels", }, - landscape: { - type: "boolean", - description: - "Set to true for landscape page orientation. Default is portrait.", - }, - sections: { + rows: { + type: "array", + items: { type: "array", - description: - "List of document sections. Each section may contain a heading, prose content, or a table.", - items: { - type: "object", - properties: { - heading: { - type: "string", - description: "Optional section heading", - }, - level: { - type: "integer", - description: "Heading level: 1, 2, or 3", - }, - content: { - type: "string", - description: - "Prose text content (paragraphs separated by double newlines)", - }, - pageBreak: { - type: "boolean", - description: - "Set to true to start this section on a new page. Use for contract signature pages.", - }, - table: { - type: "object", - description: - "Optional table to render in this section", - properties: { - headers: { - type: "array", - items: { type: "string" }, - description: "Column header labels", - }, - rows: { - type: "array", - items: { - type: "array", - items: { type: "string" }, - }, - description: - "Array of rows, each row is an array of cell strings matching the headers order", - }, - }, - required: ["headers", "rows"], - }, - }, - }, + items: { type: "string" }, + }, + description: + "Array of rows, each row is an array of cell strings matching the headers order", }, + }, + required: ["headers", "rows"], }, - required: ["title", "sections"], + }, }, + }, }, + required: ["title", "sections"], + }, }, - { - type: "function", - function: { - name: "edit_document", - description: - "Propose edits to a user-attached .docx as tracked changes. Each edit is a precise, minimal substitution of specific words/characters, NOT a whole-line or paragraph replacement. Use read_document first. Anchor each edit with short before/after context so it can be located unambiguously. Returns per-edit annotations the UI will render as Accept/Reject cards and a download link to the edited document.", - parameters: { - type: "object", - properties: { - doc_id: { - type: "string", - description: "Document slug (e.g. 'doc-0').", - }, - edits: { - type: "array", - description: "List of precise substitutions.", - items: { - type: "object", - properties: { - find: { - type: "string", - description: - "Exact substring to replace (keep it as short as possible — ideally just the words/chars being changed).", - }, - replace: { - type: "string", - description: - "Replacement text. Empty string = pure deletion.", - }, - context_before: { - type: "string", - description: - "~40 chars immediately preceding `find`, used to disambiguate.", - }, - context_after: { - type: "string", - description: - "~40 chars immediately following `find`.", - }, - reason: { - type: "string", - description: - "Short explanation shown to the user on the card.", - }, - }, - required: [ - "find", - "replace", - "context_before", - "context_after", - ], - }, - }, + }, + { + type: "function", + function: { + name: "edit_document", + description: + "Propose edits to a user-attached .docx as tracked changes. Each edit is a precise, minimal substitution of specific words/characters, NOT a whole-line or paragraph replacement. Use read_document first. Anchor each edit with short before/after context so it can be located unambiguously. Returns per-edit annotations the UI will render as Accept/Reject cards and a download link to the edited document.", + parameters: { + type: "object", + properties: { + doc_id: { + type: "string", + description: "Document slug (e.g. 'doc-0').", + }, + edits: { + type: "array", + description: "List of precise substitutions.", + items: { + type: "object", + properties: { + find: { + type: "string", + description: + "Exact substring to replace (keep it as short as possible — ideally just the words/chars being changed).", }, - required: ["doc_id", "edits"], + replace: { + type: "string", + description: + "Replacement text. Empty string = pure deletion.", + }, + context_before: { + type: "string", + description: + "~40 chars immediately preceding `find`, used to disambiguate.", + }, + context_after: { + type: "string", + description: "~40 chars immediately following `find`.", + }, + reason: { + type: "string", + description: + "Short explanation shown to the user on the card.", + }, + }, + required: ["find", "replace", "context_before", "context_after"], }, + }, }, + required: ["doc_id", "edits"], + }, }, + }, ]; -type ParsedCitation = { - ref: number; - doc_id: string; +type ParsedDocumentCitation = { + kind: "document"; + ref: number; + doc_id: string; + page: number | string; + quote: string; + quotes: { page: number | string; quote: string; + }[]; }; +type ParsedCaseCitation = { + kind: "case"; + ref: number; + cluster_id: number; + quotes: { + opinionId: number | null; + type: string | null; + author: string | null; + quote: string; + }[]; +}; + +type ParsedCitation = ParsedDocumentCitation | ParsedCaseCitation; + function normalizeCitation(raw: unknown): ParsedCitation | null { - if (!raw || typeof raw !== "object") return null; - const c = raw as Record<string, unknown>; - const markerRef = - typeof c.marker === "string" - ? Number(c.marker.match(/^\[(\d+)\]$/)?.[1]) + if (!raw || typeof raw !== "object") return null; + const c = raw as Record<string, unknown>; + const markerRef = + typeof c.marker === "string" + ? Number(c.marker.match(/^\[(\d+)\]$/)?.[1]) + : NaN; + const ref = + typeof c.ref === "number" + ? c.ref + : Number.isFinite(markerRef) + ? markerRef + : null; + if (typeof ref !== "number") return null; + const quote = typeof c.quote === "string" ? c.quote : c.text; + + const rawClusterId = + typeof c.cluster_id === "number" + ? c.cluster_id + : typeof c.clusterId === "number" + ? c.clusterId + : typeof c.cluster_id === "string" + ? Number.parseInt(c.cluster_id, 10) + : typeof c.clusterId === "string" + ? Number.parseInt(c.clusterId, 10) : NaN; - const ref = - typeof c.ref === "number" - ? c.ref - : Number.isFinite(markerRef) - ? markerRef - : null; - if (typeof ref !== "number" || typeof c.doc_id !== "string") return null; - const quote = typeof c.quote === "string" ? c.quote : c.text; - if (typeof quote !== "string" || !quote) return null; - let page: number | string; - if (typeof c.page === "number") { - page = c.page; - } else if (typeof c.page === "string" && /^\d+\s*-\s*\d+$/.test(c.page)) { - page = c.page; - } else { - const n = parseInt(String(c.page ?? ""), 10); - if (!Number.isFinite(n)) page = 1; - else page = n; + if (Number.isFinite(rawClusterId) && rawClusterId > 0) { + const quotes = normalizeCaseCitationQuotes(c); + if (!quotes.length) { + if (typeof quote !== "string" || !quote) return null; + quotes.push({ + opinionId: null, + type: null, + author: null, + quote, + }); } - return { ref, doc_id: c.doc_id, page, quote }; + return { + kind: "case", + ref, + cluster_id: Math.floor(rawClusterId), + quotes, + }; + } + + if (typeof c.doc_id !== "string") return null; + const quotes = normalizeDocumentCitationQuotes(c); + if (!quotes.length) { + if (typeof quote !== "string" || !quote) return null; + quotes.push({ page: normalizeCitationPage(c.page), quote }); + } + return { + kind: "document", + ref, + doc_id: c.doc_id, + page: quotes[0].page, + quote: quotes[0].quote, + quotes, + }; +} + +function normalizeCitationPage(value: unknown): number | string { + if (typeof value === "number") { + return value; + } else if (typeof value === "string" && /^\d+\s*-\s*\d+$/.test(value)) { + return value; + } else { + const n = parseInt(String(value ?? ""), 10); + if (!Number.isFinite(n)) return 1; + return n; + } +} + +function normalizeDocumentCitationQuotes(c: Record<string, unknown>) { + if (!Array.isArray(c.quotes)) return []; + return c.quotes + .slice(0, 3) + .map((raw) => { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return null; + } + const row = raw as Record<string, unknown>; + const text = typeof row.quote === "string" ? row.quote : row.text; + if (typeof text !== "string" || !text.trim()) return null; + return { + page: normalizeCitationPage(row.page ?? c.page), + quote: text, + }; + }) + .filter( + (quote): quote is { page: number | string; quote: string } => !!quote, + ); +} + +function normalizeCaseCitationQuotes(c: Record<string, unknown>) { + if (!Array.isArray(c.quotes)) return []; + return c.quotes + .slice(0, 3) + .map((raw) => { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return null; + } + const row = raw as Record<string, unknown>; + const text = typeof row.quote === "string" ? row.quote : row.text; + if (typeof text !== "string" || !text.trim()) return null; + const opinionId = + typeof row.opinion_id === "number" && Number.isFinite(row.opinion_id) + ? Math.floor(row.opinion_id) + : typeof row.opinionId === "number" && Number.isFinite(row.opinionId) + ? Math.floor(row.opinionId) + : null; + return { + opinionId, + type: typeof row.type === "string" ? row.type : null, + author: typeof row.author === "string" ? row.author : null, + quote: text, + }; + }) + .filter( + (quote): quote is { + opinionId: number | null; + type: string | null; + author: string | null; + quote: string; + } => !!quote, + ); } // --------------------------------------------------------------------------- @@ -491,7 +615,7 @@ function normalizeCitation(raw: unknown): ParsedCitation | null { // --------------------------------------------------------------------------- export function resolveDoc(rawId: string, docIndex: DocIndex) { - return docIndex[rawId]; + return docIndex[rawId]; } /** @@ -502,30 +626,30 @@ export function resolveDoc(rawId: string, docIndex: DocIndex) { * silently returns "not found" and the model gives up and re-generates. */ export function resolveDocLabel( - rawId: string, - docStore: DocStore, - docIndex?: DocIndex, + rawId: string, + docStore: DocStore, + docIndex?: DocIndex, ): string | null { - if (docStore.has(rawId)) return rawId; - for (const [label, info] of docStore.entries()) { - if (info.filename === rawId) return label; + if (docStore.has(rawId)) return rawId; + for (const [label, info] of docStore.entries()) { + if (info.filename === rawId) return label; + } + if (docIndex) { + for (const [label, info] of Object.entries(docIndex)) { + if (info.document_id === rawId) return label; } - if (docIndex) { - for (const [label, info] of Object.entries(docIndex)) { - if (info.document_id === rawId) return label; - } - } - return null; + } + return null; } function citationReminder(docLabel: string, filename: string): string { - return [ - `[Citation requirement for ${docLabel} ("${filename}")]:`, - `If your final answer makes any factual claim from this document, include inline [N] markers and append a final <CITATIONS> JSON block.`, - `Every citation entry for this document MUST use "doc_id": "${docLabel}".`, - `Use this exact citation object shape: {"ref": 1, "doc_id": "${docLabel}", "page": 1, "quote": "exact verbatim text from the document"}.`, - `Do not use "marker" or "text" keys in the citation block; use "ref" and "quote".`, - ].join("\n"); + return [ + `[Citation requirement for ${docLabel} ("${filename}")]:`, + `If your final answer makes any factual claim from this document, include inline [N] markers and append a final <CITATIONS> JSON block.`, + `Every citation entry for this document MUST use "doc_id": "${docLabel}".`, + `Use this citation object shape: {"ref": 1, "doc_id": "${docLabel}", "quotes": [{"page": 1, "quote": "exact verbatim text from the document"}]}. Include top-level "page" and "quote" too only if they match the first quote.`, + `Do not use "marker" or "text" keys in the citation block; use "ref" and "quotes".`, + ].join("\n"); } /** @@ -542,705 +666,683 @@ function citationReminder(docLabel: string, filename: string): string { * only if the doc is no longer in the index (deleted, scope changed). */ export async function enrichWithPriorEvents( - messages: ChatMessage[], - chatId: string | null | undefined, - db: ReturnType<typeof createServerSupabase>, - docIndex: DocIndex, + messages: ChatMessage[], + chatId: string | null | undefined, + db: ReturnType<typeof createServerSupabase>, + docIndex: DocIndex, ): Promise<ChatMessage[]> { - if (!chatId) return messages; - const { data: rows } = await db - .from("chat_messages") - .select("content, created_at") - .eq("chat_id", chatId) - .eq("role", "assistant") - .order("created_at", { ascending: false }) - .limit(1); + if (!chatId) return messages; + const { data: rows } = await db + .from("chat_messages") + .select("content, created_at") + .eq("chat_id", chatId) + .eq("role", "assistant") + .order("created_at", { ascending: false }) + .limit(1); - const lastRow = rows?.[0] as { content?: unknown } | undefined; - const content = lastRow?.content; - if (!Array.isArray(content)) return messages; + const lastRow = rows?.[0] as { content?: unknown } | undefined; + const content = lastRow?.content; + if (!Array.isArray(content)) return messages; - const slugByDocumentId = new Map<string, string>(); - for (const [slug, info] of Object.entries(docIndex)) { - if (info.document_id) slugByDocumentId.set(info.document_id, slug); + const slugByDocumentId = new Map<string, string>(); + for (const [slug, info] of Object.entries(docIndex)) { + if (info.document_id) slugByDocumentId.set(info.document_id, slug); + } + const refFor = (documentId: unknown, filename: unknown) => { + const slug = + typeof documentId === "string" + ? slugByDocumentId.get(documentId) + : undefined; + return slug ? `${slug} ("${filename}")` : `"${filename}"`; + }; + + const lines: string[] = []; + for (const ev of content as Record<string, unknown>[]) { + if (ev?.type === "doc_created") { + lines.push(`- generate_docx → ${refFor(ev.document_id, ev.filename)}`); + } else if (ev?.type === "doc_edited") { + lines.push(`- edit_document → ${refFor(ev.document_id, ev.filename)}`); + } else if (ev?.type === "doc_read") { + lines.push(`- read_document → ${refFor(ev.document_id, ev.filename)}`); + } else if (ev?.type === "doc_replicated") { + // The model needs to know what each copy resolved to so it + // can call edit_document / read_document on them. Emit one + // line per copy, all attributed back to the same source. + const srcLabel = + typeof ev.filename === "string" ? `"${ev.filename}"` : ""; + const copies = Array.isArray(ev.copies) + ? (ev.copies as { + new_filename?: unknown; + document_id?: unknown; + }[]) + : []; + for (const c of copies) { + const ref = refFor(c.document_id, c.new_filename); + lines.push( + srcLabel + ? `- replicate_document → ${ref} (copy of ${srcLabel})` + : `- replicate_document → ${ref}`, + ); + } + } else if (ev?.type === "workflow_applied") { + lines.push(`- applied workflow: "${ev.title}"`); } - const refFor = (documentId: unknown, filename: unknown) => { - const slug = - typeof documentId === "string" - ? slugByDocumentId.get(documentId) - : undefined; - return slug ? `${slug} ("${filename}")` : `"${filename}"`; - }; + } + if (lines.length === 0) return messages; + const summary = `\n\n[Tool activity in your previous turn]\n${lines.join("\n")}`; - const lines: string[] = []; - for (const ev of content as Record<string, unknown>[]) { - if (ev?.type === "doc_created") { - lines.push( - `- generate_docx → ${refFor(ev.document_id, ev.filename)}`, - ); - } else if (ev?.type === "doc_edited") { - lines.push( - `- edit_document → ${refFor(ev.document_id, ev.filename)}`, - ); - } else if (ev?.type === "doc_read") { - lines.push( - `- read_document → ${refFor(ev.document_id, ev.filename)}`, - ); - } else if (ev?.type === "doc_replicated") { - // The model needs to know what each copy resolved to so it - // can call edit_document / read_document on them. Emit one - // line per copy, all attributed back to the same source. - const srcLabel = - typeof ev.filename === "string" ? `"${ev.filename}"` : ""; - const copies = Array.isArray(ev.copies) - ? (ev.copies as { - new_filename?: unknown; - document_id?: unknown; - }[]) - : []; - for (const c of copies) { - const ref = refFor(c.document_id, c.new_filename); - lines.push( - srcLabel - ? `- replicate_document → ${ref} (copy of ${srcLabel})` - : `- replicate_document → ${ref}`, - ); - } - } else if (ev?.type === "workflow_applied") { - lines.push(`- applied workflow: "${ev.title}"`); - } + // Find the index of the last assistant message and attach the + // summary there only. + let lastAssistantIdx = -1; + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === "assistant") { + lastAssistantIdx = i; + break; } - if (lines.length === 0) return messages; - const summary = `\n\n[Tool activity in your previous turn]\n${lines.join("\n")}`; - - // Find the index of the last assistant message and attach the - // summary there only. - let lastAssistantIdx = -1; - for (let i = messages.length - 1; i >= 0; i--) { - if (messages[i].role === "assistant") { - lastAssistantIdx = i; - break; - } - } - if (lastAssistantIdx < 0) return messages; - const enriched = messages.slice(); - const target = enriched[lastAssistantIdx]; - enriched[lastAssistantIdx] = { - ...target, - content: (target.content ?? "") + summary, - }; - return enriched; + } + if (lastAssistantIdx < 0) return messages; + const enriched = messages.slice(); + const target = enriched[lastAssistantIdx]; + enriched[lastAssistantIdx] = { + ...target, + content: (target.content ?? "") + summary, + }; + return enriched; } export function buildMessages( - messages: ChatMessage[], - docAvailability: { - doc_id: string; - filename: string; - folder_path?: string; - }[], - systemPromptExtra?: string, - docIndex?: DocIndex, + messages: ChatMessage[], + docAvailability: { + doc_id: string; + filename: string; + folder_path?: string; + }[], + systemPromptExtra?: string, + docIndex?: DocIndex, ) { - const formatted: unknown[] = []; - let systemContent = SYSTEM_PROMPT; + const formatted: unknown[] = []; + let systemContent = SYSTEM_PROMPT; - if (systemPromptExtra) { - systemContent += `\n\n${systemPromptExtra.trim()}`; - } + if (systemPromptExtra) { + systemContent += `\n\n${systemPromptExtra.trim()}`; + } - if (docAvailability.length) { - systemContent += "\n\n---\nAVAILABLE DOCUMENTS:\n"; - for (const doc of docAvailability) { - const label = doc.folder_path - ? `${doc.folder_path} / ${doc.filename}` - : doc.filename; - systemContent += `- ${doc.doc_id}: ${label}\n`; - } - systemContent += - "\nYou do NOT retain document content between conversation turns. You MUST call read_document (or fetch_documents) at the start of every response that involves a document's content, even if you have read it in a previous turn. Failure to do so will result in hallucinated or stale content.\n---\n"; + if (docAvailability.length) { + systemContent += "\n\n---\nAVAILABLE DOCUMENTS:\n"; + for (const doc of docAvailability) { + const label = doc.folder_path + ? `${doc.folder_path} / ${doc.filename}` + : doc.filename; + systemContent += `- ${doc.doc_id}: ${label}\n`; } - formatted.push({ role: "system", content: systemContent }); + systemContent += + "\nYou do NOT retain document content between conversation turns. You MUST call read_document (or fetch_documents) at the start of every response that involves a document's content, even if you have read it in a previous turn. Failure to do so will result in hallucinated or stale content.\n---\n"; + } + formatted.push({ role: "system", content: systemContent }); - // Map document_id (UUID) → current-turn doc_id slug, so when we - // inline a user attachment we hand the model the same handle it - // would use to call read_document / fetch_documents. - const slugByDocumentId = new Map<string, string>(); - if (docIndex) { - for (const [slug, info] of Object.entries(docIndex)) { - if (info.document_id) slugByDocumentId.set(info.document_id, slug); - } + // Map document_id (UUID) → current-turn doc_id slug, so when we + // inline a user attachment we hand the model the same handle it + // would use to call read_document / fetch_documents. + const slugByDocumentId = new Map<string, string>(); + if (docIndex) { + for (const [slug, info] of Object.entries(docIndex)) { + if (info.document_id) slugByDocumentId.set(info.document_id, slug); } + } - for (const msg of messages) { - let content = msg.content ?? ""; - if (msg.role === "user" && msg.workflow) { - content = `[Workflow: ${msg.workflow.title} (id: ${msg.workflow.id})]\n\n${content}`; - } - if (msg.role === "user" && msg.files?.length) { - const lines = msg.files.map((f) => { - const slug = f.document_id - ? slugByDocumentId.get(f.document_id) - : undefined; - return slug ? `- ${slug}: ${f.filename}` : `- ${f.filename}`; - }); - content = `[The user attached the following document(s) to this message:\n${lines.join("\n")}]\n\n${content}`; - } - formatted.push({ role: msg.role, content }); + for (const msg of messages) { + let content = msg.content ?? ""; + if (msg.role === "user" && msg.workflow) { + content = `[Workflow: ${msg.workflow.title} (id: ${msg.workflow.id})]\n\n${content}`; } - return formatted; + if (msg.role === "user" && msg.files?.length) { + const lines = msg.files.map((f) => { + const slug = f.document_id + ? slugByDocumentId.get(f.document_id) + : undefined; + return slug ? `- ${slug}: ${f.filename}` : `- ${f.filename}`; + }); + content = `[The user attached the following document(s) to this message:\n${lines.join("\n")}]\n\n${content}`; + } + formatted.push({ role: msg.role, content }); + } + return formatted; } export async function extractPdfText(buf: ArrayBuffer): Promise<string> { - try { - const pdfjsLib = await import( - "pdfjs-dist/legacy/build/pdf.mjs" as string - ); - const pdf = await ( - pdfjsLib as unknown as { - getDocument: (opts: unknown) => { - promise: Promise<{ - numPages: number; - getPage: (n: number) => Promise<{ - getTextContent: () => Promise<{ - items: { str?: string }[]; - }>; - }>; - }>; - }; - } - ).getDocument({ - data: new Uint8Array(buf), - standardFontDataUrl: STANDARD_FONT_DATA_URL, - }).promise; - const parts: string[] = []; - for (let i = 1; i <= pdf.numPages; i++) { - const page = await pdf.getPage(i); - const textContent = await page.getTextContent(); - parts.push( - `[Page ${i}]\n${textContent.items.map((it) => it.str ?? "").join(" ")}`, - ); - } - return parts.join("\n\n"); - } catch { - return ""; + try { + const pdfjsLib = await import("pdfjs-dist/legacy/build/pdf.mjs" as string); + const pdf = await ( + pdfjsLib as unknown as { + getDocument: (opts: unknown) => { + promise: Promise<{ + numPages: number; + getPage: (n: number) => Promise<{ + getTextContent: () => Promise<{ + items: { str?: string }[]; + }>; + }>; + }>; + }; + } + ).getDocument({ + data: new Uint8Array(buf), + standardFontDataUrl: STANDARD_FONT_DATA_URL, + }).promise; + const parts: string[] = []; + for (let i = 1; i <= pdf.numPages; i++) { + const page = await pdf.getPage(i); + const textContent = await page.getTextContent(); + parts.push( + `[Page ${i}]\n${textContent.items.map((it) => it.str ?? "").join(" ")}`, + ); } + return parts.join("\n\n"); + } catch { + return ""; + } } export async function generateDocx( - title: string, - sections: unknown[], - userId: string, - db: ReturnType<typeof createServerSupabase>, - options?: { landscape?: boolean; projectId?: string | null }, + title: string, + sections: unknown[], + userId: string, + db: ReturnType<typeof createServerSupabase>, + options?: { landscape?: boolean; projectId?: string | null }, ) { - try { - const { - Document, - Paragraph, - HeadingLevel, - Packer, - Table, - TableRow, - TableCell, - WidthType, - BorderStyle, - TextRun, - AlignmentType, - LevelFormat, - LevelSuffix, - PageOrientation, - PageBreak, - } = await import("docx"); + try { + const { + Document, + Paragraph, + HeadingLevel, + Packer, + Table, + TableRow, + TableCell, + WidthType, + BorderStyle, + TextRun, + AlignmentType, + LevelFormat, + LevelSuffix, + PageOrientation, + PageBreak, + } = await import("docx"); - const FONT = "Times New Roman"; - const SIZE = 22; // 11pt in half-points + const FONT = "Times New Roman"; + const SIZE = 22; // 11pt in half-points - type DocChild = - | InstanceType<typeof Paragraph> - | InstanceType<typeof Table>; - const children: DocChild[] = []; - children.push( + type DocChild = InstanceType<typeof Paragraph> | InstanceType<typeof Table>; + const children: DocChild[] = []; + children.push( + new Paragraph({ + heading: HeadingLevel.TITLE, + spacing: { after: 200 }, + alignment: AlignmentType.CENTER, + children: [ + new TextRun({ + text: title.toUpperCase(), + color: "000000", + font: FONT, + size: SIZE, + bold: true, + }), + ], + }), + ); + + const cellBorder = { + top: { style: BorderStyle.SINGLE, size: 1, color: "CCCCCC" }, + bottom: { style: BorderStyle.SINGLE, size: 1, color: "CCCCCC" }, + left: { style: BorderStyle.SINGLE, size: 1, color: "CCCCCC" }, + right: { style: BorderStyle.SINGLE, size: 1, color: "CCCCCC" }, + }; + + const headingLevels = [ + HeadingLevel.HEADING_1, + HeadingLevel.HEADING_2, + HeadingLevel.HEADING_3, + HeadingLevel.HEADING_4, + ]; + const LEGAL_NUMBERING_REF = "legal-clause-numbering"; + const legalNumbering = (level: number) => ({ + reference: LEGAL_NUMBERING_REF, + level: Math.max(0, Math.min(level, 4)), + }); + const legalNumberingLevels = [ + { + level: 0, + format: LevelFormat.DECIMAL, + text: "%1.", + alignment: AlignmentType.START, + suffix: LevelSuffix.TAB, + isLegalNumberingStyle: true, + style: { + paragraph: { indent: { left: 720, hanging: 720 } }, + run: { + bold: true, + color: "000000", + font: FONT, + size: SIZE, + }, + }, + }, + { + level: 1, + format: LevelFormat.DECIMAL, + text: "%1.%2", + alignment: AlignmentType.START, + suffix: LevelSuffix.TAB, + isLegalNumberingStyle: true, + style: { + paragraph: { indent: { left: 720, hanging: 720 } }, + run: { color: "000000", font: FONT, size: SIZE }, + }, + }, + { + level: 2, + format: LevelFormat.LOWER_LETTER, + text: "(%3)", + alignment: AlignmentType.START, + suffix: LevelSuffix.TAB, + style: { + paragraph: { indent: { left: 1440, hanging: 720 } }, + run: { color: "000000", font: FONT, size: SIZE }, + }, + }, + { + level: 3, + format: LevelFormat.LOWER_ROMAN, + text: "(%4)", + alignment: AlignmentType.START, + suffix: LevelSuffix.TAB, + style: { + paragraph: { indent: { left: 1440, hanging: 720 } }, + run: { color: "000000", font: FONT, size: SIZE }, + }, + }, + { + level: 4, + format: LevelFormat.UPPER_LETTER, + text: "(%5)", + alignment: AlignmentType.START, + suffix: LevelSuffix.TAB, + style: { + paragraph: { indent: { left: 2520, hanging: 720 } }, + run: { color: "000000", font: FONT, size: SIZE }, + }, + }, + ]; + const normalizeTable = ( + table: unknown, + ): { headers: string[]; rows: string[][] } | null => { + if (!table || typeof table !== "object") return null; + const raw = table as { headers?: unknown; rows?: unknown }; + const headers = Array.isArray(raw.headers) + ? raw.headers + .map((header) => (typeof header === "string" ? header.trim() : "")) + .filter(Boolean) + : []; + if (headers.length === 0) return null; + + const rawRows = Array.isArray(raw.rows) ? raw.rows : []; + const rows = rawRows + .filter((row): row is unknown[] => Array.isArray(row)) + .map((row) => + headers.map((_, i) => (typeof row[i] === "string" ? row[i] : "")), + ); + + return { headers, rows }; + }; + const stripManualNumbering = ( + value: string, + ): { text: string; levelFromPrefix: number | null } => { + const match = value.trim().match(/^(\d+(?:\.\d+)*)(?:[.)])?\s+(.+)$/); + if (!match) return { text: value.trim(), levelFromPrefix: null }; + return { + text: match[2].trim(), + levelFromPrefix: match[1].split(".").length - 1, + }; + }; + const parseManualListMarker = ( + value: string, + ): { text: string; levelOffset: number | null } => { + const trimmed = value.trim(); + const match = trimmed.match(/^(\(([a-z]+)\)|([a-z]+)[.)])\s+(.+)$/i); + if (!match) return { text: trimmed, levelOffset: null }; + const marker = (match[2] ?? match[3] ?? "").toLowerCase(); + const isRoman = + marker === "i" || + (marker.length > 1 && + /^(?:m{0,4}(?:cm|cd|d?c{0,3})(?:xc|xl|l?x{0,3})(?:ix|iv|v?i{0,3}))$/i.test( + marker, + )); + return { text: match[4].trim(), levelOffset: isRoman ? 3 : 2 }; + }; + const normalizeHeadingText = (value: string) => + value + .trim() + .replace(/[^a-zA-Z0-9]+/g, " ") + .trim() + .toLowerCase(); + + const isTitleLikeFirstHeading = (heading: string, sectionIndex: number) => { + if (sectionIndex !== 0) return false; + const normalized = normalizeHeadingText(heading); + const titleNormalized = normalizeHeadingText(title); + if (!normalized || !titleNormalized) return false; + if (normalized === titleNormalized) return true; + return ( + titleNormalized.includes(normalized) && + /\b(agreement|contract|deed|terms|policy|notice|nda|disclosure)\b/.test( + normalized, + ) + ); + }; + + const isUnnumberedHeading = (heading: string, sectionIndex: number) => { + const normalized = normalizeHeadingText(heading); + if (!normalized) return true; + if (normalized === "signatures" || normalized === "signature") { + return true; + } + if (isTitleLikeFirstHeading(heading, sectionIndex)) { + return true; + } + if ( + sectionIndex === 0 && + /^(agreement|contract|mutual non disclosure agreement|non disclosure agreement|employment agreement|service level agreement)$/.test( + normalized, + ) + ) { + return true; + } + return false; + }; + const isSignatureLine = (value: string) => + /^(?:by|name|title|date):\s*/i.test(value.trim()); + const looksLikeSignatureBlock = (value: string) => { + const lines = value + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + if (lines.length === 0) return false; + const signatureLineCount = lines.filter(isSignatureLine).length; + return signatureLineCount >= 2; + }; + let currentClauseLevel: number | null = null; + + for (const [sectionIndex, section] of ( + sections as { + heading?: string; + content?: string; + level?: number; + pageBreak?: boolean; + table?: { headers: string[]; rows: string[][] }; + }[] + ).entries()) { + if (section.pageBreak) { + children.push(new Paragraph({ children: [new PageBreak()] })); + } + if (section.heading) { + const stripped = stripManualNumbering(section.heading); + const isUnnumbered = isUnnumberedHeading(stripped.text, sectionIndex); + const skipHeading = isTitleLikeFirstHeading( + stripped.text, + sectionIndex, + ); + const idx = Math.min( + stripped.levelFromPrefix ?? (section.level ?? 1) - 1, + 3, + ); + currentClauseLevel = isUnnumbered || skipHeading ? null : idx; + const headingText = + idx === 0 && !isUnnumbered + ? stripped.text.toUpperCase() + : stripped.text; + if (!skipHeading) { + children.push( new Paragraph({ - heading: HeadingLevel.TITLE, - spacing: { after: 200 }, - alignment: AlignmentType.CENTER, - children: [ - new TextRun({ - text: title.toUpperCase(), - color: "000000", - font: FONT, - size: SIZE, - bold: true, - }), - ], + heading: headingLevels[idx], + numbering: isUnnumbered ? undefined : legalNumbering(idx), + spacing: { after: 160 }, + children: [ + new TextRun({ + text: headingText, + color: "000000", + font: FONT, + size: SIZE, + bold: true, + }), + ], }), - ); - - const cellBorder = { - top: { style: BorderStyle.SINGLE, size: 1, color: "CCCCCC" }, - bottom: { style: BorderStyle.SINGLE, size: 1, color: "CCCCCC" }, - left: { style: BorderStyle.SINGLE, size: 1, color: "CCCCCC" }, - right: { style: BorderStyle.SINGLE, size: 1, color: "CCCCCC" }, - }; - - const headingLevels = [ - HeadingLevel.HEADING_1, - HeadingLevel.HEADING_2, - HeadingLevel.HEADING_3, - HeadingLevel.HEADING_4, - ]; - const LEGAL_NUMBERING_REF = "legal-clause-numbering"; - const legalNumbering = (level: number) => ({ - reference: LEGAL_NUMBERING_REF, - level: Math.max(0, Math.min(level, 4)), - }); - const legalNumberingLevels = [ - { - level: 0, - format: LevelFormat.DECIMAL, - text: "%1.", - alignment: AlignmentType.START, - suffix: LevelSuffix.TAB, - isLegalNumberingStyle: true, - style: { - paragraph: { indent: { left: 720, hanging: 720 } }, - run: { - bold: true, - color: "000000", - font: FONT, - size: SIZE, - }, - }, - }, - { - level: 1, - format: LevelFormat.DECIMAL, - text: "%1.%2", - alignment: AlignmentType.START, - suffix: LevelSuffix.TAB, - isLegalNumberingStyle: true, - style: { - paragraph: { indent: { left: 720, hanging: 720 } }, - run: { color: "000000", font: FONT, size: SIZE }, - }, - }, - { - level: 2, - format: LevelFormat.LOWER_LETTER, - text: "(%3)", - alignment: AlignmentType.START, - suffix: LevelSuffix.TAB, - style: { - paragraph: { indent: { left: 1440, hanging: 720 } }, - run: { color: "000000", font: FONT, size: SIZE }, - }, - }, - { - level: 3, - format: LevelFormat.LOWER_ROMAN, - text: "(%4)", - alignment: AlignmentType.START, - suffix: LevelSuffix.TAB, - style: { - paragraph: { indent: { left: 1440, hanging: 720 } }, - run: { color: "000000", font: FONT, size: SIZE }, - }, - }, - { - level: 4, - format: LevelFormat.UPPER_LETTER, - text: "(%5)", - alignment: AlignmentType.START, - suffix: LevelSuffix.TAB, - style: { - paragraph: { indent: { left: 2520, hanging: 720 } }, - run: { color: "000000", font: FONT, size: SIZE }, - }, - }, - ]; - const normalizeTable = ( - table: unknown, - ): { headers: string[]; rows: string[][] } | null => { - if (!table || typeof table !== "object") return null; - const raw = table as { headers?: unknown; rows?: unknown }; - const headers = Array.isArray(raw.headers) - ? raw.headers - .map((header) => - typeof header === "string" ? header.trim() : "", - ) - .filter(Boolean) - : []; - if (headers.length === 0) return null; - - const rawRows = Array.isArray(raw.rows) ? raw.rows : []; - const rows = rawRows - .filter((row): row is unknown[] => Array.isArray(row)) - .map((row) => - headers.map((_, i) => - typeof row[i] === "string" ? row[i] : "", - ), - ); - - return { headers, rows }; - }; - const stripManualNumbering = ( - value: string, - ): { text: string; levelFromPrefix: number | null } => { - const match = value - .trim() - .match(/^(\d+(?:\.\d+)*)(?:[.)])?\s+(.+)$/); - if (!match) return { text: value.trim(), levelFromPrefix: null }; - return { - text: match[2].trim(), - levelFromPrefix: match[1].split(".").length - 1, - }; - }; - const parseManualListMarker = ( - value: string, - ): { text: string; levelOffset: number | null } => { - const trimmed = value.trim(); - const match = trimmed.match(/^(\(([a-z]+)\)|([a-z]+)[.)])\s+(.+)$/i); - if (!match) return { text: trimmed, levelOffset: null }; - const marker = (match[2] ?? match[3] ?? "").toLowerCase(); - const isRoman = - marker === "i" || - (marker.length > 1 && - /^(?:m{0,4}(?:cm|cd|d?c{0,3})(?:xc|xl|l?x{0,3})(?:ix|iv|v?i{0,3}))$/i.test( - marker, - )); - return { text: match[4].trim(), levelOffset: isRoman ? 3 : 2 }; - }; - const normalizeHeadingText = (value: string) => - value - .trim() - .replace(/[^a-zA-Z0-9]+/g, " ") - .trim() - .toLowerCase(); - - const isTitleLikeFirstHeading = ( - heading: string, - sectionIndex: number, - ) => { - if (sectionIndex !== 0) return false; - const normalized = normalizeHeadingText(heading); - const titleNormalized = normalizeHeadingText(title); - if (!normalized || !titleNormalized) return false; - if (normalized === titleNormalized) return true; - return ( - titleNormalized.includes(normalized) && - /\b(agreement|contract|deed|terms|policy|notice|nda|disclosure)\b/.test( - normalized, - ) - ); - }; - - const isUnnumberedHeading = (heading: string, sectionIndex: number) => { - const normalized = normalizeHeadingText(heading); - if (!normalized) return true; - if (normalized === "signatures" || normalized === "signature") { - return true; - } - if (isTitleLikeFirstHeading(heading, sectionIndex)) { - return true; - } - if ( - sectionIndex === 0 && - /^(agreement|contract|mutual non disclosure agreement|non disclosure agreement|employment agreement|service level agreement)$/.test( - normalized, - ) - ) { - return true; - } - return false; - }; - const isSignatureLine = (value: string) => - /^(?:by|name|title|date):\s*/i.test(value.trim()); - const looksLikeSignatureBlock = (value: string) => { - const lines = value - .split("\n") - .map((line) => line.trim()) - .filter(Boolean); - if (lines.length === 0) return false; - const signatureLineCount = lines.filter(isSignatureLine).length; - return signatureLineCount >= 2; - }; - let currentClauseLevel: number | null = null; - - for (const [sectionIndex, section] of (sections as { - heading?: string; - content?: string; - level?: number; - pageBreak?: boolean; - table?: { headers: string[]; rows: string[][] }; - }[]).entries()) { - if (section.pageBreak) { - children.push(new Paragraph({ children: [new PageBreak()] })); - } - if (section.heading) { - const stripped = stripManualNumbering(section.heading); - const isUnnumbered = isUnnumberedHeading( - stripped.text, - sectionIndex, - ); - const skipHeading = isTitleLikeFirstHeading( - stripped.text, - sectionIndex, - ); - const idx = Math.min( - stripped.levelFromPrefix ?? (section.level ?? 1) - 1, - 3, - ); - currentClauseLevel = isUnnumbered || skipHeading ? null : idx; - const headingText = - idx === 0 && !isUnnumbered - ? stripped.text.toUpperCase() - : stripped.text; - if (!skipHeading) { - children.push( - new Paragraph({ - heading: headingLevels[idx], - numbering: isUnnumbered - ? undefined - : legalNumbering(idx), - spacing: { after: 160 }, - children: [ - new TextRun({ - text: headingText, - color: "000000", - font: FONT, - size: SIZE, - bold: true, - }), - ], + ); + } + } + const normalizedTable = normalizeTable(section.table); + if (normalizedTable) { + const { headers, rows } = normalizedTable; + const colCount = headers.length; + const tableRows: InstanceType<typeof TableRow>[] = []; + // Header row + tableRows.push( + new TableRow({ + tableHeader: true, + children: headers.map( + (h) => + new TableCell({ + borders: cellBorder, + shading: { fill: "F2F2F2" }, + children: [ + new Paragraph({ + children: [ + new TextRun({ + text: h, + bold: true, + font: FONT, + size: SIZE, }), - ); - } - } - const normalizedTable = normalizeTable(section.table); - if (normalizedTable) { - const { headers, rows } = normalizedTable; - const colCount = headers.length; - const tableRows: InstanceType<typeof TableRow>[] = []; - // Header row - tableRows.push( - new TableRow({ - tableHeader: true, - children: headers.map( - (h) => - new TableCell({ - borders: cellBorder, - shading: { fill: "F2F2F2" }, - children: [ - new Paragraph({ - children: [ - new TextRun({ - text: h, - bold: true, - font: FONT, - size: SIZE, - }), - ], - alignment: AlignmentType.LEFT, - }), - ], - }), - ), + ], + alignment: AlignmentType.LEFT, }), - ); - // Data rows — normalize each row to exactly colCount cells. - // LLMs occasionally emit malformed rows (extra fragments from - // stray delimiters, or short rows); padding/truncating here - // keeps the rendered table aligned to the headers. - for (const normalized of rows) { - tableRows.push( - new TableRow({ - children: normalized.map( - (cell) => - new TableCell({ - borders: cellBorder, - children: [ - new Paragraph({ - children: [ - new TextRun({ - text: cell, - font: FONT, - size: SIZE, - }), - ], - }), - ], - }), - ), - }), - ); - } - children.push( - new Table({ - width: { size: 100, type: WidthType.PERCENTAGE }, - rows: tableRows, - }), - ); - children.push(new Paragraph({ text: "" })); - } - if (section.content) { - let numberedBodyParagraphs = 0; - const contentIsSignatureBlock = - section.heading && - normalizeHeadingText(section.heading).includes("signature") - ? true - : looksLikeSignatureBlock(section.content); - for (const line of section.content.split("\n")) { - const trimmed = line.trim(); - if (!trimmed) continue; - const bulletMatch = trimmed.match(/^[-•*]\s+(.+)/); - const rawText = bulletMatch - ? bulletMatch[1].trim() - : trimmed; - const manualList = parseManualListMarker(rawText); - const numeric = stripManualNumbering(rawText); - const text = bulletMatch - ? rawText - : manualList.levelOffset !== null - ? manualList.text - : numeric.text; - const inferredLevel = - currentClauseLevel === null || contentIsSignatureBlock - ? undefined - : bulletMatch - ? currentClauseLevel + 2 - : manualList.levelOffset !== null - ? currentClauseLevel + manualList.levelOffset - : numeric.levelFromPrefix !== null - ? numeric.levelFromPrefix - : numberedBodyParagraphs === 0 - ? currentClauseLevel + 1 - : currentClauseLevel + 2; - if (currentClauseLevel !== null) numberedBodyParagraphs++; - children.push( - new Paragraph({ - numbering: - inferredLevel === undefined - ? undefined - : legalNumbering(inferredLevel), - spacing: { after: 120 }, - children: [ - new TextRun({ - text, - font: FONT, - size: SIZE, - }), - ], - }), - ); - } - } - } - - const pageSetup = options?.landscape - ? { page: { size: { orientation: PageOrientation.LANDSCAPE } } } - : {}; - - const doc = new Document({ - numbering: { - config: [ - { - reference: LEGAL_NUMBERING_REF, - levels: legalNumberingLevels, - }, - ], - }, - sections: [{ properties: pageSetup, children }], - }); - const buf = await Packer.toBuffer(doc); - const zip = await import("jszip"); - const packageZip = await zip.default.loadAsync(buf); - for (const requiredPath of [ - "[Content_Types].xml", - "word/document.xml", - "word/_rels/document.xml.rels", - ]) { - if (!packageZip.file(requiredPath)) { - return { - error: `Generated DOCX is missing required package part: ${requiredPath}`, - }; - } - } - const docId = crypto.randomUUID().replace(/-/g, ""); - const safeTitle = - title - .replace(/[^a-zA-Z0-9 -]/g, "") - .trim() - .slice(0, 64) || "document"; - const filename = `${safeTitle}.docx`; - const key = generatedDocKey(userId, docId, filename); - - await uploadFile( - key, - buf.buffer as ArrayBuffer, - "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ], + }), + ), + }), ); - const downloadUrl = buildDownloadUrl(key, filename); - - // Persist to DB so generated docs are first-class documents: - // openable in the DocPanel and editable via edit_document. In - // project chats we attach to the project so it appears in the - // sidebar; in the general chat we leave project_id null and it - // stays a standalone document. - const { data: docRow, error: docErr } = await db - .from("documents") - .insert({ - project_id: options?.projectId ?? null, - user_id: userId, - filename, - file_type: "docx", - size_bytes: buf.byteLength, - status: "ready", - }) - .select("id") - .single(); - if (docErr || !docRow) { - return { - error: `Failed to record generated document: ${docErr?.message ?? "unknown"}`, - }; + // Data rows — normalize each row to exactly colCount cells. + // LLMs occasionally emit malformed rows (extra fragments from + // stray delimiters, or short rows); padding/truncating here + // keeps the rendered table aligned to the headers. + for (const normalized of rows) { + tableRows.push( + new TableRow({ + children: normalized.map( + (cell) => + new TableCell({ + borders: cellBorder, + children: [ + new Paragraph({ + children: [ + new TextRun({ + text: cell, + font: FONT, + size: SIZE, + }), + ], + }), + ], + }), + ), + }), + ); } - const documentId = docRow.id as string; - - const { data: versionRow, error: verErr } = await db - .from("document_versions") - .insert({ - document_id: documentId, - storage_path: key, - source: "generated", - version_number: 1, - display_name: filename, - }) - .select("id") - .single(); - if (verErr || !versionRow) { - return { - error: `Failed to record generated document version: ${verErr?.message ?? "unknown"}`, - }; + children.push( + new Table({ + width: { size: 100, type: WidthType.PERCENTAGE }, + rows: tableRows, + }), + ); + children.push(new Paragraph({ text: "" })); + } + if (section.content) { + let numberedBodyParagraphs = 0; + const contentIsSignatureBlock = + section.heading && + normalizeHeadingText(section.heading).includes("signature") + ? true + : looksLikeSignatureBlock(section.content); + for (const line of section.content.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + const bulletMatch = trimmed.match(/^[-•*]\s+(.+)/); + const rawText = bulletMatch ? bulletMatch[1].trim() : trimmed; + const manualList = parseManualListMarker(rawText); + const numeric = stripManualNumbering(rawText); + const text = bulletMatch + ? rawText + : manualList.levelOffset !== null + ? manualList.text + : numeric.text; + const inferredLevel = + currentClauseLevel === null || contentIsSignatureBlock + ? undefined + : bulletMatch + ? currentClauseLevel + 2 + : manualList.levelOffset !== null + ? currentClauseLevel + manualList.levelOffset + : numeric.levelFromPrefix !== null + ? numeric.levelFromPrefix + : numberedBodyParagraphs === 0 + ? currentClauseLevel + 1 + : currentClauseLevel + 2; + if (currentClauseLevel !== null) numberedBodyParagraphs++; + children.push( + new Paragraph({ + numbering: + inferredLevel === undefined + ? undefined + : legalNumbering(inferredLevel), + spacing: { after: 120 }, + children: [ + new TextRun({ + text, + font: FONT, + size: SIZE, + }), + ], + }), + ); } - const versionId = versionRow.id as string; - - await db - .from("documents") - .update({ current_version_id: versionId }) - .eq("id", documentId); - - return { - filename, - download_url: downloadUrl, - document_id: documentId, - version_id: versionId, - version_number: 1, - storage_path: key, - message: `Document '${filename}' has been generated successfully.`, - }; - } catch (e) { - return { error: String(e) }; + } } + + const pageSetup = options?.landscape + ? { page: { size: { orientation: PageOrientation.LANDSCAPE } } } + : {}; + + const doc = new Document({ + numbering: { + config: [ + { + reference: LEGAL_NUMBERING_REF, + levels: legalNumberingLevels, + }, + ], + }, + sections: [{ properties: pageSetup, children }], + }); + const buf = await Packer.toBuffer(doc); + const zip = await import("jszip"); + const packageZip = await zip.default.loadAsync(buf); + for (const requiredPath of [ + "[Content_Types].xml", + "word/document.xml", + "word/_rels/document.xml.rels", + ]) { + if (!packageZip.file(requiredPath)) { + return { + error: `Generated DOCX is missing required package part: ${requiredPath}`, + }; + } + } + const docId = crypto.randomUUID().replace(/-/g, ""); + const safeTitle = + title + .replace(/[^a-zA-Z0-9 -]/g, "") + .trim() + .slice(0, 64) || "document"; + const filename = `${safeTitle}.docx`; + const key = generatedDocKey(userId, docId, filename); + + await uploadFile( + key, + buf.buffer as ArrayBuffer, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ); + const downloadUrl = buildDownloadUrl(key, filename); + + // Persist to DB so generated docs are first-class documents: + // openable in the DocPanel and editable via edit_document. In + // project chats we attach to the project so it appears in the + // sidebar; in the general chat we leave project_id null and it + // stays a standalone document. + const { data: docRow, error: docErr } = await db + .from("documents") + .insert({ + project_id: options?.projectId ?? null, + user_id: userId, + status: "ready", + }) + .select("id") + .single(); + if (docErr || !docRow) { + return { + error: `Failed to record generated document: ${docErr?.message ?? "unknown"}`, + }; + } + const documentId = docRow.id as string; + + const { data: versionRow, error: verErr } = await db + .from("document_versions") + .insert({ + document_id: documentId, + storage_path: key, + source: "generated", + version_number: 1, + filename: filename, + file_type: "docx", + size_bytes: buf.byteLength, + page_count: null, + }) + .select("id") + .single(); + if (verErr || !versionRow) { + return { + error: `Failed to record generated document version: ${verErr?.message ?? "unknown"}`, + }; + } + const versionId = versionRow.id as string; + + await db + .from("documents") + .update({ + current_version_id: versionId, + }) + .eq("id", documentId); + + return { + filename, + download_url: downloadUrl, + document_id: documentId, + version_id: versionId, + version_number: 1, + storage_path: key, + message: `Document '${filename}' has been generated successfully.`, + }; + } catch (e) { + return { error: String(e) }; + } } // --------------------------------------------------------------------------- @@ -1252,14 +1354,14 @@ export async function generateDocx( * tracked-changes version if one exists, else the original upload. */ export async function loadCurrentVersionBytes( - documentId: string, - db: ReturnType<typeof createServerSupabase>, + documentId: string, + db: ReturnType<typeof createServerSupabase>, ): Promise<{ bytes: Buffer; storage_path: string } | null> { - const active = await loadActiveVersion(documentId, db); - if (!active) return null; - const raw = await downloadFile(active.storage_path); - if (!raw) return null; - return { bytes: Buffer.from(raw), storage_path: active.storage_path }; + const active = await loadActiveVersion(documentId, db); + if (!active) return null; + const raw = await downloadFile(active.storage_path); + if (!raw) return null; + return { bytes: Buffer.from(raw), storage_path: active.storage_path }; } /** @@ -1268,210 +1370,225 @@ export async function loadCurrentVersionBytes( * complete. Idempotent. */ export async function runEditDocument(params: { - documentId: string; - userId: string; - edits: EditInput[]; - db: ReturnType<typeof createServerSupabase>; - /** - * If provided, append these edits to the existing turn-scoped version - * (overwrites the file at storagePath and reuses the document_versions - * row) instead of creating a new version. Used to collapse multiple - * edit_document tool calls within a single assistant turn into one - * version. - */ - reuseVersion?: { - versionId: string; - versionNumber: number; - storagePath: string; - }; + documentId: string; + userId: string; + edits: EditInput[]; + db: ReturnType<typeof createServerSupabase>; + /** + * If provided, append these edits to the existing turn-scoped version + * (overwrites the file at storagePath and reuses the document_versions + * row) instead of creating a new version. Used to collapse multiple + * edit_document tool calls within a single assistant turn into one + * version. + */ + reuseVersion?: { + versionId: string; + versionNumber: number; + storagePath: string; + }; }): Promise< - | { - ok: true; - version_id: string; - version_number: number; - storage_path: string; - download_url: string; - annotations: EditAnnotation[]; - errors: { index: number; reason: string }[]; - } - | { ok: false; error: string } + | { + ok: true; + version_id: string; + version_number: number; + storage_path: string; + download_url: string; + annotations: EditAnnotation[]; + errors: { index: number; reason: string }[]; + } + | { ok: false; error: string } > { - const { documentId, userId, edits, db, reuseVersion } = params; + const { documentId, userId, edits, db, reuseVersion } = params; - const { data: doc } = await db - .from("documents") - .select("id, filename") - .eq("id", documentId) - .single(); - if (!doc) return { ok: false, error: "Document not found." }; + const { data: doc } = await db + .from("documents") + .select("id") + .eq("id", documentId) + .single(); + if (!doc) return { ok: false, error: "Document not found." }; - const current = await loadCurrentVersionBytes(documentId, db); - if (!current) return { ok: false, error: "Could not load document bytes." }; + const activeVersion = await loadActiveVersion(documentId, db); + let versionFilename = + activeVersion?.filename?.trim() || "Untitled document"; - const { - bytes: editedBytes, - changes, - errors, - } = await applyTrackedEdits(current.bytes, edits, { author: "Mike" }); + const current = await loadCurrentVersionBytes(documentId, db); + if (!current) return { ok: false, error: "Could not load document bytes." }; - if (changes.length === 0) { - return { - ok: false, - error: - errors[0]?.reason ?? - "No edits could be applied. Refine context_before/context_after and retry.", - }; - } + const { + bytes: editedBytes, + changes, + errors, + } = await applyTrackedEdits(current.bytes, edits, { author: "Mike" }); - const ab = editedBytes.buffer.slice( - editedBytes.byteOffset, - editedBytes.byteOffset + editedBytes.byteLength, - ) as ArrayBuffer; + if (changes.length === 0) { + return { + ok: false, + error: + errors[0]?.reason ?? + "No edits could be applied. Refine context_before/context_after and retry.", + }; + } - let versionRowId: string; - let newPath: string; - let nextVersionNumber: number; + const ab = editedBytes.buffer.slice( + editedBytes.byteOffset, + editedBytes.byteOffset + editedBytes.byteLength, + ) as ArrayBuffer; - if (reuseVersion) { - // Overwrite the existing turn version's file in place. The version - // row, version_number, and current_version_id all already point here. - newPath = reuseVersion.storagePath; - versionRowId = reuseVersion.versionId; - nextVersionNumber = reuseVersion.versionNumber; - await uploadFile( - newPath, - ab, - "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - ); - } else { - const versionId = crypto.randomUUID().replace(/-/g, ""); - newPath = `documents/${userId}/${documentId}/edits/${versionId}.docx`; - await uploadFile( - newPath, - ab, - "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - ); - - // Per-document sequential number for the new assistant_edit - // version. The counter spans upload + user_upload + assistant_edit - // so the original upload is V1 and the first assistant edit is V2. - const { data: maxRow } = await db - .from("document_versions") - .select("version_number") - .eq("document_id", documentId) - .in("source", ["upload", "user_upload", "assistant_edit"]) - .order("version_number", { ascending: false, nullsFirst: false }) - .limit(1) - .maybeSingle(); - nextVersionNumber = - ((maxRow?.version_number as number | null) ?? 1) + 1; - - // Inherit the display name from the most recent prior version so - // user-applied renames carry forward through further edits. Falls - // back to the parent document's filename when no prior version has - // a display name (e.g. the first assistant edit of a pre-existing - // doc). We intentionally do NOT append "[Edited Vn]" — the version - // number is surfaced separately as a tag in the UI. - const { data: prevRow } = await db - .from("document_versions") - .select("display_name, created_at") - .eq("document_id", documentId) - .order("created_at", { ascending: false }) - .limit(1) - .maybeSingle(); - const inheritedDisplayName = - (prevRow?.display_name as string | null) ?? - (doc.filename as string | null) ?? - null; - - const { data: versionRow, error: verErr } = await db - .from("document_versions") - .insert({ - document_id: documentId, - storage_path: newPath, - source: "assistant_edit", - version_number: nextVersionNumber, - display_name: inheritedDisplayName, - }) - .select("id") - .single(); - if (verErr || !versionRow) { - return { ok: false, error: "Failed to record document version." }; - } - versionRowId = versionRow.id as string; - } - - // Insert one row per change - const editRows = changes.map((c) => ({ - document_id: documentId, - version_id: versionRowId, - change_id: c.id, - del_w_id: c.delId ?? null, - ins_w_id: c.insId ?? null, - deleted_text: c.deletedText, - inserted_text: c.insertedText, - context_before: c.contextBefore ?? "", - context_after: c.contextAfter ?? "", - status: "pending" as const, - })); - const { data: insertedEdits, error: editsErr } = await db - .from("document_edits") - .insert(editRows) - .select( - "id, change_id, del_w_id, ins_w_id, deleted_text, inserted_text, context_before, context_after", - ); - - if (editsErr || !insertedEdits) { - return { ok: false, error: "Failed to record edits." }; - } + let versionRowId: string; + let newPath: string; + let nextVersionNumber: number; + if (reuseVersion) { + // Overwrite the existing turn version's file in place. The version + // row, version_number, and current_version_id all already point here. + newPath = reuseVersion.storagePath; + versionRowId = reuseVersion.versionId; + nextVersionNumber = reuseVersion.versionNumber; + await uploadFile( + newPath, + ab, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ); await db - .from("documents") - .update({ current_version_id: versionRowId }) - .eq("id", documentId); - - const annotations: EditAnnotation[] = insertedEdits.map( - (r: { - id: string; - change_id: string; - deleted_text: string; - inserted_text: string; - context_before: string | null; - context_after: string | null; - }) => { - const src = changes.find((c) => c.id === r.change_id); - return { - kind: "edit", - edit_id: r.id, - document_id: documentId, - version_id: versionRowId, - version_number: nextVersionNumber, - change_id: r.change_id, - del_w_id: src?.delId, - ins_w_id: src?.insId, - deleted_text: r.deleted_text ?? "", - inserted_text: r.inserted_text ?? "", - context_before: r.context_before ?? "", - context_after: r.context_after ?? "", - reason: src?.reason, - status: "pending", - }; - }, + .from("document_versions") + .update({ + file_type: "docx", + size_bytes: editedBytes.byteLength, + page_count: null, + }) + .eq("id", versionRowId); + } else { + const versionId = crypto.randomUUID().replace(/-/g, ""); + newPath = `documents/${userId}/${documentId}/edits/${versionId}.docx`; + await uploadFile( + newPath, + ab, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", ); - // Persistent, non-expiring permalink. The backend streams fresh bytes - // on each request, so this URL stays valid as long as the file exists. - const permalink = buildDownloadUrl(newPath, doc.filename as string); + // Per-document sequential number for the new assistant_edit + // version. The counter spans upload + user_upload + assistant_edit + // so the original upload is V1 and the first assistant edit is V2. + const { data: maxRow } = await db + .from("document_versions") + .select("version_number") + .eq("document_id", documentId) + .in("source", ["upload", "user_upload", "assistant_edit"]) + .order("version_number", { ascending: false, nullsFirst: false }) + .limit(1) + .maybeSingle(); + nextVersionNumber = ((maxRow?.version_number as number | null) ?? 1) + 1; - return { - ok: true, + // Inherit the filename from the most recent prior version so + // user-applied renames carry forward through further edits. Malformed + // legacy rows without a filename get a neutral placeholder, not the + // parent document filename. We intentionally do NOT append "[Edited Vn]" + // — the version number is surfaced separately as a tag in the UI. + const { data: prevRow } = await db + .from("document_versions") + .select("filename, created_at") + .eq("document_id", documentId) + .order("created_at", { ascending: false }) + .limit(1) + .maybeSingle(); + const inheritedFilename = + (prevRow?.filename as string | null)?.trim() || "Untitled document"; + versionFilename = inheritedFilename; + + const { data: versionRow, error: verErr } = await db + .from("document_versions") + .insert({ + document_id: documentId, + storage_path: newPath, + source: "assistant_edit", + version_number: nextVersionNumber, + filename: inheritedFilename, + file_type: "docx", + size_bytes: editedBytes.byteLength, + page_count: null, + }) + .select("id") + .single(); + if (verErr || !versionRow) { + return { ok: false, error: "Failed to record document version." }; + } + versionRowId = versionRow.id as string; + } + + // Insert one row per change + const editRows = changes.map((c) => ({ + document_id: documentId, + version_id: versionRowId, + change_id: c.id, + del_w_id: c.delId ?? null, + ins_w_id: c.insId ?? null, + deleted_text: c.deletedText, + inserted_text: c.insertedText, + context_before: c.contextBefore ?? "", + context_after: c.contextAfter ?? "", + status: "pending" as const, + })); + const { data: insertedEdits, error: editsErr } = await db + .from("document_edits") + .insert(editRows) + .select( + "id, change_id, del_w_id, ins_w_id, deleted_text, inserted_text, context_before, context_after", + ); + + if (editsErr || !insertedEdits) { + return { ok: false, error: "Failed to record edits." }; + } + + await db + .from("documents") + .update({ + current_version_id: versionRowId, + }) + .eq("id", documentId); + + const annotations: EditAnnotation[] = insertedEdits.map( + (r: { + id: string; + change_id: string; + deleted_text: string; + inserted_text: string; + context_before: string | null; + context_after: string | null; + }) => { + const src = changes.find((c) => c.id === r.change_id); + return { + kind: "edit", + edit_id: r.id, + document_id: documentId, version_id: versionRowId, version_number: nextVersionNumber, - storage_path: newPath, - download_url: permalink, - annotations, - errors, - }; + change_id: r.change_id, + del_w_id: src?.delId, + ins_w_id: src?.insId, + deleted_text: r.deleted_text ?? "", + inserted_text: r.inserted_text ?? "", + context_before: r.context_before ?? "", + context_after: r.context_after ?? "", + reason: src?.reason, + status: "pending", + }; + }, + ); + + // Persistent, non-expiring permalink. The backend streams fresh bytes + // on each request, so this URL stays valid as long as the file exists. + const resolvedFilename = versionFilename.trim() || "Untitled document.docx"; + const permalink = buildDownloadUrl(newPath, resolvedFilename); + + return { + ok: true, + version_id: versionRowId, + version_number: nextVersionNumber, + storage_path: newPath, + download_url: permalink, + annotations, + errors, + }; } // --------------------------------------------------------------------------- @@ -1479,150 +1596,150 @@ export async function runEditDocument(params: { // --------------------------------------------------------------------------- async function readDocumentContent( - docLabel: string, - docStore: DocStore, - write: (s: string) => void, - docIndex?: DocIndex, - db?: ReturnType<typeof createServerSupabase>, - opts?: { emitEvents?: boolean }, + docLabel: string, + docStore: DocStore, + write: (s: string) => void, + docIndex?: DocIndex, + db?: ReturnType<typeof createServerSupabase>, + opts?: { emitEvents?: boolean }, ): Promise<string> { - const emitEvents = opts?.emitEvents ?? true; - console.log(`[read_document] called with docLabel="${docLabel}"`); - const docInfo = docStore.get(docLabel); - if (!docInfo) { - console.log( - `[read_document] MISS — docLabel "${docLabel}" not in docStore. Known labels:`, - Array.from(docStore.keys()), - ); - return "Document not found."; - } - console.log( - `[read_document] docInfo: filename="${docInfo.filename}", file_type="${docInfo.file_type}", storage_path="${docInfo.storage_path}"`, + const emitEvents = opts?.emitEvents ?? true; + devLog(`[read_document] called with docLabel="${docLabel}"`); + const docInfo = docStore.get(docLabel); + if (!docInfo) { + devLog( + `[read_document] MISS — docLabel "${docLabel}" not in docStore. Known labels:`, + Array.from(docStore.keys()), ); + return "Document not found."; + } + devLog( + `[read_document] docInfo: filename="${docInfo.filename}", file_type="${docInfo.file_type}", storage_path="${docInfo.storage_path}"`, + ); - const documentId = docIndex?.[docLabel]?.document_id; - const emitDocRead = () => { - if (!emitEvents) return; - write( - `data: ${JSON.stringify({ - type: "doc_read", - filename: docInfo.filename, - document_id: documentId, - })}\n\n`, + const documentId = docIndex?.[docLabel]?.document_id; + const emitDocRead = () => { + if (!emitEvents) return; + write( + `data: ${JSON.stringify({ + type: "doc_read", + filename: docInfo.filename, + document_id: documentId, + })}\n\n`, + ); + }; + if (emitEvents) + write( + `data: ${JSON.stringify({ + type: "doc_read_start", + filename: docInfo.filename, + document_id: documentId, + })}\n\n`, + ); + try { + // Prefer the current tracked-changes version (if any) so read_document + // reflects accepted/pending edits rather than the original upload. + let raw: ArrayBuffer | null = null; + let sourcePath = docInfo.storage_path; + if (documentId && db) { + const current = await loadCurrentVersionBytes(documentId, db); + if (current) { + raw = current.bytes.buffer.slice( + current.bytes.byteOffset, + current.bytes.byteOffset + current.bytes.byteLength, + ) as ArrayBuffer; + sourcePath = current.storage_path; + devLog( + `[read_document] using current version path="${sourcePath}" (bytes=${raw.byteLength})`, ); - }; - if (emitEvents) - write( - `data: ${JSON.stringify({ - type: "doc_read_start", - filename: docInfo.filename, - document_id: documentId, - })}\n\n`, + } else { + devLog( + `[read_document] loadCurrentVersionBytes returned null for documentId="${documentId}", falling back to original storage_path`, ); - try { - // Prefer the current tracked-changes version (if any) so read_document - // reflects accepted/pending edits rather than the original upload. - let raw: ArrayBuffer | null = null; - let sourcePath = docInfo.storage_path; - if (documentId && db) { - const current = await loadCurrentVersionBytes(documentId, db); - if (current) { - raw = current.bytes.buffer.slice( - current.bytes.byteOffset, - current.bytes.byteOffset + current.bytes.byteLength, - ) as ArrayBuffer; - sourcePath = current.storage_path; - console.log( - `[read_document] using current version path="${sourcePath}" (bytes=${raw.byteLength})`, - ); - } else { - console.log( - `[read_document] loadCurrentVersionBytes returned null for documentId="${documentId}", falling back to original storage_path`, - ); - } - } - if (!raw) { - raw = await downloadFile(docInfo.storage_path); - if (raw) { - console.log( - `[read_document] fallback download from storage_path="${docInfo.storage_path}" (bytes=${raw.byteLength})`, - ); - } - } - if (!raw) { - console.log( - `[read_document] FAILED to download any bytes for docLabel="${docLabel}" (tried path="${sourcePath}")`, - ); - emitDocRead(); - return "Document could not be read."; - } - // Log the first 8 bytes so we can identify real file format regardless - // of the declared file_type. Valid .docx starts with "PK\x03\x04" - // (zip). Legacy .doc starts with "\xD0\xCF\x11\xE0" (OLE/CFB). - // %PDF-1 is a PDF even if mislabeled. Truncated uploads show as all-zero. - { - const head = Buffer.from(raw).subarray(0, 8); - const hex = head.toString("hex"); - const ascii = head.toString("binary").replace(/[^\x20-\x7e]/g, "."); - console.log( - `[read_document] magic bytes hex=${hex} ascii="${ascii}" for filename="${docInfo.filename}"`, - ); - } - let text: string; - if (docInfo.file_type === "pdf") { - text = await extractPdfText(raw); - console.log( - `[read_document] pdf extracted length=${text.length} for filename="${docInfo.filename}"`, - ); - } else if (docInfo.file_type === "docx") { - // Use the same flattening as the edit_document matcher so the - // LLM sees exactly the characters it can anchor against. - text = await extractDocxBodyText(Buffer.from(raw)); - console.log( - `[read_document] docx extractDocxBodyText length=${text.length} for filename="${docInfo.filename}"`, - ); - if (!text) { - console.log( - `[read_document] docx accepted-view extractor returned empty, falling back to mammoth for filename="${docInfo.filename}"`, - ); - const mammoth = await import("mammoth"); - const result = await mammoth.extractRawText({ - buffer: Buffer.from(raw), - }); - text = result.value; - console.log( - `[read_document] docx mammoth fallback length=${text.length} for filename="${docInfo.filename}"`, - ); - } - } else { - console.log( - `[read_document] unknown file_type="${docInfo.file_type}" for filename="${docInfo.filename}", trying mammoth`, - ); - const mammoth = await import("mammoth"); - const result = await mammoth.extractRawText({ - buffer: Buffer.from(raw), - }); - text = result.value; - console.log( - `[read_document] mammoth length=${text.length} for filename="${docInfo.filename}"`, - ); - } - console.log( - `[read_document] DONE filename="${docInfo.filename}" finalTextLength=${text.length} firstChars=${JSON.stringify(text.slice(0, 120))}`, - ); - emitDocRead(); - return text; - } catch (err) { - console.log( - `[read_document] THREW for docLabel="${docLabel}" filename="${docInfo.filename}":`, - err, - ); - if (emitEvents) - write( - `data: ${JSON.stringify({ type: "doc_read", filename: docInfo.filename })}\n\n`, - ); - return "Document could not be read."; + } } + if (!raw) { + raw = await downloadFile(docInfo.storage_path); + if (raw) { + devLog( + `[read_document] fallback download from storage_path="${docInfo.storage_path}" (bytes=${raw.byteLength})`, + ); + } + } + if (!raw) { + devLog( + `[read_document] FAILED to download any bytes for docLabel="${docLabel}" (tried path="${sourcePath}")`, + ); + emitDocRead(); + return "Document could not be read."; + } + // Log the first 8 bytes so we can identify real file format regardless + // of the declared file_type. Valid .docx starts with "PK\x03\x04" + // (zip). Legacy .doc starts with "\xD0\xCF\x11\xE0" (OLE/CFB). + // %PDF-1 is a PDF even if mislabeled. Truncated uploads show as all-zero. + { + const head = Buffer.from(raw).subarray(0, 8); + const hex = head.toString("hex"); + const ascii = head.toString("binary").replace(/[^\x20-\x7e]/g, "."); + devLog( + `[read_document] magic bytes hex=${hex} ascii="${ascii}" for filename="${docInfo.filename}"`, + ); + } + let text: string; + if (docInfo.file_type === "pdf") { + text = await extractPdfText(raw); + devLog( + `[read_document] pdf extracted length=${text.length} for filename="${docInfo.filename}"`, + ); + } else if (docInfo.file_type === "docx") { + // Use the same flattening as the edit_document matcher so the + // LLM sees exactly the characters it can anchor against. + text = await extractDocxBodyText(Buffer.from(raw)); + devLog( + `[read_document] docx extractDocxBodyText length=${text.length} for filename="${docInfo.filename}"`, + ); + if (!text) { + devLog( + `[read_document] docx accepted-view extractor returned empty, falling back to mammoth for filename="${docInfo.filename}"`, + ); + const mammoth = await import("mammoth"); + const result = await mammoth.extractRawText({ + buffer: Buffer.from(raw), + }); + text = result.value; + devLog( + `[read_document] docx mammoth fallback length=${text.length} for filename="${docInfo.filename}"`, + ); + } + } else { + devLog( + `[read_document] unknown file_type="${docInfo.file_type}" for filename="${docInfo.filename}", trying mammoth`, + ); + const mammoth = await import("mammoth"); + const result = await mammoth.extractRawText({ + buffer: Buffer.from(raw), + }); + text = result.value; + devLog( + `[read_document] mammoth length=${text.length} for filename="${docInfo.filename}"`, + ); + } + devLog( + `[read_document] DONE filename="${docInfo.filename}" finalTextLength=${text.length} firstChars=${JSON.stringify(text.slice(0, 120))}`, + ); + emitDocRead(); + return text; + } catch (err) { + devLog( + `[read_document] THREW for docLabel="${docLabel}" filename="${docInfo.filename}":`, + err, + ); + if (emitEvents) + write( + `data: ${JSON.stringify({ type: "doc_read", filename: docInfo.filename })}\n\n`, + ); + return "Document could not be read."; + } } /** @@ -1633,28 +1750,77 @@ async function readDocumentContent( * exact original excerpt. */ function normalizeWithMap(text: string): { norm: string; origIdx: number[] } { - const norm: string[] = []; - const origIdx: number[] = []; - let prevSpace = false; - for (let i = 0; i < text.length; i++) { - const ch = text[i]; - if (/\s/.test(ch)) { - if (!prevSpace) { - norm.push(" "); - origIdx.push(i); - prevSpace = true; - } - } else { - norm.push(ch.toLowerCase()); - origIdx.push(i); - prevSpace = false; - } + const norm: string[] = []; + const origIdx: number[] = []; + let prevSpace = false; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (/\s/.test(ch)) { + if (!prevSpace) { + norm.push(" "); + origIdx.push(i); + prevSpace = true; + } + } else { + norm.push(ch.toLowerCase()); + origIdx.push(i); + prevSpace = false; } - return { norm: norm.join(""), origIdx }; + } + return { norm: norm.join(""), origIdx }; } function normalizeQuery(q: string): string { - return q.trim().replace(/\s+/g, " ").toLowerCase(); + return q.trim().replace(/\s+/g, " ").toLowerCase(); +} + +type TextMatch = { + index: number; + excerpt: string; + context: string; +}; + +function findTextMatches(params: { + text: string; + query: string; + maxResults: number; + contextChars: number; + startIndex?: number; +}): { hits: TextMatch[]; totalMatches: number } { + const { text, query, maxResults, contextChars, startIndex = 0 } = params; + const { norm, origIdx } = normalizeWithMap(text); + const needle = normalizeQuery(query); + const hits: TextMatch[] = []; + let totalMatches = 0; + if (!needle) return { hits, totalMatches }; + + let from = 0; + while (from <= norm.length - needle.length) { + const pos = norm.indexOf(needle, from); + if (pos < 0) break; + const endNormPos = pos + needle.length; + const origStart = origIdx[pos] ?? 0; + const origEnd = + endNormPos - 1 < origIdx.length + ? origIdx[endNormPos - 1] + 1 + : text.length; + if (hits.length < maxResults) { + const ctxStart = Math.max(0, origStart - contextChars); + const ctxEnd = Math.min(text.length, origEnd + contextChars); + hits.push({ + index: startIndex + hits.length, + excerpt: text.slice(origStart, origEnd), + context: + (ctxStart > 0 ? "…" : "") + + text.slice(ctxStart, ctxEnd).replace(/\s+/g, " ").trim() + + (ctxEnd < text.length ? "…" : ""), + }); + } + totalMatches++; + from = pos + Math.max(1, needle.length); + } + + return { hits, totalMatches }; } /** @@ -1662,968 +1828,1797 @@ function normalizeQuery(q: string): string { * hits, each containing the original-text excerpt plus surrounding context. */ async function findInDocumentContent(params: { - docLabel: string; - query: string; - maxResults?: number; - contextChars?: number; - docStore: DocStore; - write: (s: string) => void; - docIndex?: DocIndex; - db?: ReturnType<typeof createServerSupabase>; + docLabel: string; + query: string; + maxResults?: number; + contextChars?: number; + docStore: DocStore; + write: (s: string) => void; + docIndex?: DocIndex; + db?: ReturnType<typeof createServerSupabase>; }): Promise<string> { - const { - docLabel, - query, - maxResults = 20, - contextChars = 80, - docStore, - write, - docIndex, - db, - } = params; + const { + docLabel, + query, + maxResults = 20, + contextChars = 80, + docStore, + write, + docIndex, + db, + } = params; - if (!query || !query.trim()) { - return JSON.stringify({ ok: false, error: "Empty query." }); - } - - const docInfo = docStore.get(docLabel); - if (!docInfo) { - return JSON.stringify({ - ok: false, - error: `Document '${docLabel}' not found.`, - }); - } - - // Announce the search to the UI, then reuse readDocumentContent for its - // fallbacks — but suppress its own doc_read events so the user only sees - // the doc_find block (not a competing doc_read block for the same op). - write( - `data: ${JSON.stringify({ - type: "doc_find_start", - filename: docInfo.filename, - query, - })}\n\n`, - ); - - const text = await readDocumentContent( - docLabel, - docStore, - write, - docIndex, - db, - { emitEvents: false }, - ); - if (!text || text === "Document could not be read.") { - write( - `data: ${JSON.stringify({ - type: "doc_find", - filename: docInfo.filename, - query, - total_matches: 0, - })}\n\n`, - ); - return JSON.stringify({ - ok: false, - filename: docInfo.filename, - error: "Document could not be read.", - }); - } - - const { norm, origIdx } = normalizeWithMap(text); - const needle = normalizeQuery(query); - if (!needle) { - return JSON.stringify({ - ok: false, - error: "Empty query after normalization.", - }); - } - - type Hit = { - index: number; - excerpt: string; - context: string; - }; - const hits: Hit[] = []; - let from = 0; - while (from <= norm.length - needle.length && hits.length < maxResults) { - const pos = norm.indexOf(needle, from); - if (pos < 0) break; - const endNormPos = pos + needle.length; - const origStart = origIdx[pos] ?? 0; - const origEnd = - endNormPos - 1 < origIdx.length - ? origIdx[endNormPos - 1] + 1 - : text.length; - const ctxStart = Math.max(0, origStart - contextChars); - const ctxEnd = Math.min(text.length, origEnd + contextChars); - hits.push({ - index: hits.length, - excerpt: text.slice(origStart, origEnd), - context: - (ctxStart > 0 ? "…" : "") + - text.slice(ctxStart, ctxEnd).replace(/\s+/g, " ").trim() + - (ctxEnd < text.length ? "…" : ""), - }); - from = pos + Math.max(1, needle.length); - } - - // Count total occurrences beyond the cap so the model knows whether to narrow the query. - let totalMatches = hits.length; - if (hits.length >= maxResults) { - let probe = from; - while (probe <= norm.length - needle.length) { - const pos = norm.indexOf(needle, probe); - if (pos < 0) break; - totalMatches++; - probe = pos + Math.max(1, needle.length); - } - } - - write( - `data: ${JSON.stringify({ - type: "doc_find", - filename: docInfo.filename, - query, - total_matches: totalMatches, - })}\n\n`, - ); + if (!query || !query.trim()) { + return JSON.stringify({ ok: false, error: "Empty query." }); + } + const docInfo = docStore.get(docLabel); + if (!docInfo) { return JSON.stringify({ - ok: true, + ok: false, + error: `Document '${docLabel}' not found.`, + }); + } + + // Announce the search to the UI, then reuse readDocumentContent for its + // fallbacks — but suppress its own doc_read events so the user only sees + // the doc_find block (not a competing doc_read block for the same op). + write( + `data: ${JSON.stringify({ + type: "doc_find_start", + filename: docInfo.filename, + query, + })}\n\n`, + ); + + const text = await readDocumentContent( + docLabel, + docStore, + write, + docIndex, + db, + { emitEvents: false }, + ); + if (!text || text === "Document could not be read.") { + write( + `data: ${JSON.stringify({ + type: "doc_find", filename: docInfo.filename, query, - total_matches: totalMatches, - returned: hits.length, - truncated: totalMatches > hits.length, - hits, + total_matches: 0, + })}\n\n`, + ); + return JSON.stringify({ + ok: false, + filename: docInfo.filename, + error: "Document could not be read.", }); + } + + const needle = normalizeQuery(query); + if (!needle) { + return JSON.stringify({ + ok: false, + error: "Empty query after normalization.", + }); + } + + const { hits, totalMatches } = findTextMatches({ + text, + query, + maxResults, + contextChars, + }); + + write( + `data: ${JSON.stringify({ + type: "doc_find", + filename: docInfo.filename, + query, + total_matches: totalMatches, + })}\n\n`, + ); + + return JSON.stringify({ + ok: true, + filename: docInfo.filename, + query, + total_matches: totalMatches, + returned: hits.length, + truncated: totalMatches > hits.length, + hits, + }); } export type DocEditedResult = { - filename: string; - document_id: string; - version_id: string; - version_number: number | null; - download_url: string; - annotations: EditAnnotation[]; + filename: string; + document_id: string; + version_id: string; + version_number: number | null; + download_url: string; + annotations: EditAnnotation[]; }; export type TurnEditState = Map< - string, - { versionId: string; versionNumber: number; storagePath: string } + string, + { versionId: string; versionNumber: number; storagePath: string } >; export type DocCreatedResult = { - filename: string; - download_url: string; - document_id?: string; - version_id?: string; - version_number?: number | null; + filename: string; + download_url: string; + document_id?: string; + version_id?: string; + version_number?: number | null; }; export type DocReplicatedResult = { - /** Filename of the source document being copied. */ - filename: string; - /** How many copies were produced in this single tool call. */ - count: number; - /** One entry per new copy. */ - copies: { - new_filename: string; - document_id: string; - version_id: string; - }[]; + /** Filename of the source document being copied. */ + filename: string; + /** How many copies were produced in this single tool call. */ + count: number; + /** One entry per new copy. */ + copies: { + new_filename: string; + document_id: string; + version_id: string; + }[]; }; +type CourtlistenerCaseRecord = { + clusterId: number; + caseName: string | null; + citations: string[]; + url: string | null; + pdfUrl: string | null; + dateFiled: string | null; + judges: string | null; + opinions?: unknown[]; +}; + +type CourtlistenerCaseInput = { + clusterId?: number | null; + caseName?: string | null; + citation?: string | null; + citations?: string[]; + url?: string | null; + pdfUrl?: string | null; + dateFiled?: string | null; + judges?: string | null; + opinions?: unknown[]; +}; + +type CourtlistenerTurnState = { + casesByClusterId: Map<number, CourtlistenerCaseRecord>; +}; + +function nonEmpty(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function upsertCourtlistenerCases( + state: CourtlistenerTurnState, + inputs: CourtlistenerCaseInput[], +): CourtlistenerCaseRecord[] { + const records: CourtlistenerCaseRecord[] = []; + for (const input of inputs) { + if (typeof input.clusterId !== "number" || !Number.isFinite(input.clusterId)) { + continue; + } + const clusterId = Math.floor(input.clusterId); + const current = + state.casesByClusterId.get(clusterId) ?? + { + clusterId, + caseName: null, + citations: [], + url: null, + pdfUrl: null, + dateFiled: null, + judges: null, + }; + const nextCitations = [ + ...current.citations, + ...(input.citation ? [input.citation] : []), + ...(input.citations ?? []), + ] + .map(nonEmpty) + .filter((value): value is string => !!value); + const record: CourtlistenerCaseRecord = { + ...current, + caseName: current.caseName ?? nonEmpty(input.caseName), + citations: Array.from(new Set(nextCitations)), + url: current.url ?? nonEmpty(input.url), + pdfUrl: current.pdfUrl ?? nonEmpty(input.pdfUrl), + dateFiled: current.dateFiled ?? nonEmpty(input.dateFiled), + judges: current.judges ?? nonEmpty(input.judges), + opinions: current.opinions ?? input.opinions, + }; + state.casesByClusterId.set(clusterId, record); + records.push(record); + } + return records; +} + +function caseCitationEventFromRecord( + record: CourtlistenerCaseRecord, +): CaseCitationEvent | null { + if (!record.url) return null; + return { + type: "case_citation", + cluster_id: record.clusterId, + case_name: record.caseName, + citation: record.citations[0] ?? null, + url: record.url, + pdfUrl: record.pdfUrl, + dateFiled: record.dateFiled, + judges: record.judges, + }; +} + +function recordFromUnknown(value: unknown): Record<string, unknown> | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record<string, unknown>) + : null; +} + +function stringField( + record: Record<string, unknown> | null, + key: string, +): string | null { + const value = record?.[key]; + return typeof value === "string" ? value : null; +} + +function numberField( + record: Record<string, unknown> | null, + key: string, +): number | null { + const value = record?.[key]; + return typeof value === "number" && Number.isFinite(value) + ? Math.floor(value) + : null; +} + +function stringArrayField( + record: Record<string, unknown> | null, + key: string, +): string[] { + const value = record?.[key]; + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === "string") + : []; +} + +function courtlistenerCaseInputFromFetchedCase( + fallbackClusterId: number, + fetchedCase: unknown, +): CourtlistenerCaseInput { + const record = recordFromUnknown(fetchedCase); + const clusterId = + numberField(record, "clusterId") ?? numberField(record, "id") ?? fallbackClusterId; + return { + clusterId, + caseName: stringField(record, "caseName"), + citations: stringArrayField(record, "citations"), + url: stringField(record, "url"), + pdfUrl: stringField(record, "pdfUrl"), + dateFiled: stringField(record, "dateFiled"), + judges: stringField(record, "judges"), + opinions: Array.isArray(record?.opinions) ? record.opinions : undefined, + }; +} + +function courtlistenerOpinionCount(fetchedCase: unknown): number { + const record = recordFromUnknown(fetchedCase); + return Array.isArray(record?.opinions) ? record.opinions.length : 0; +} + +function courtlistenerOpinionMetadata(raw: unknown) { + const opinion = recordFromUnknown(raw); + if (!opinion) return null; + const text = + stringField(opinion, "text") ?? + (stringField(opinion, "html") + ? stripCaseOpinionHtml(stringField(opinion, "html")!) + : null); + return { + opinion_id: + numberField(opinion, "opinionId") ?? numberField(opinion, "id"), + type: stringField(opinion, "type"), + author: stringField(opinion, "author"), + per_curiam: stringField(opinion, "per_curiam"), + joined_by_str: stringField(opinion, "joined_by_str"), + url: stringField(opinion, "url"), + char_count: text?.length ?? 0, + }; +} + +function courtlistenerFetchedCaseMetadata( + record: CourtlistenerCaseRecord, + opinionCount: number, +) { + return { + cluster_id: record.clusterId, + case_name: record.caseName, + citation: record.citations[0] ?? null, + citations: record.citations, + dateFiled: record.dateFiled, + url: record.url, + pdfUrl: record.pdfUrl, + judges: record.judges, + opinion_count: opinionCount, + opinions: (record.opinions ?? []) + .map(courtlistenerOpinionMetadata) + .filter((opinion): opinion is NonNullable<typeof opinion> => !!opinion), + }; +} + +function stripCaseOpinionHtml(value: string): string { + return value + .replace(/<style[\s\S]*?<\/style>/gi, " ") + .replace(/<script[\s\S]*?<\/script>/gi, " ") + .replace(/<[^>]+>/g, " ") + .replace(/ /g, " ") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/\s+/g, " ") + .trim(); +} + +type CachedCaseOpinionText = { + opinion_id: number | null; + type: string | null; + author: string | null; + url: string | null; + text: string; +}; + +function cachedCaseOpinionTexts( + record: CourtlistenerCaseRecord, +): CachedCaseOpinionText[] { + return (record.opinions ?? []) + .map((raw) => { + const opinion = recordFromUnknown(raw); + if (!opinion) return null; + const text = + stringField(opinion, "text") ?? + (stringField(opinion, "html") + ? stripCaseOpinionHtml(stringField(opinion, "html")!) + : null); + if (!text) return null; + return { + opinion_id: + numberField(opinion, "opinionId") ?? numberField(opinion, "id"), + type: stringField(opinion, "type"), + author: stringField(opinion, "author"), + url: stringField(opinion, "url"), + text, + }; + }) + .filter((opinion): opinion is CachedCaseOpinionText => !!opinion); +} + +function requestedCourtlistenerOpinionIds(args: Record<string, unknown>) { + const rawIds = Array.isArray(args.opinionIds) + ? args.opinionIds + : Array.isArray(args.opinion_ids) + ? args.opinion_ids + : typeof args.opinionId === "number" + ? [args.opinionId] + : typeof args.opinion_id === "number" + ? [args.opinion_id] + : []; + return Array.from( + new Set( + rawIds + .filter((value): value is number => typeof value === "number") + .filter((value) => Number.isFinite(value) && value > 0) + .map((value) => Math.floor(value)), + ), + ); +} + +type FindInCaseArgs = { + clusterId: number | null; + query: string; + maxResults: number; + contextChars: number; +}; + +function parseFindInCaseArgs(args: Record<string, unknown>): FindInCaseArgs { + return { + clusterId: + typeof args.clusterId === "number" && Number.isFinite(args.clusterId) + ? Math.floor(args.clusterId) + : typeof args.cluster_id === "number" && Number.isFinite(args.cluster_id) + ? Math.floor(args.cluster_id) + : null, + query: typeof args.query === "string" ? args.query : "", + maxResults: + typeof args.max_results === "number" + ? Math.max(0, Math.floor(args.max_results)) + : 20, + contextChars: + typeof args.context_chars === "number" + ? Math.max(0, Math.floor(args.context_chars)) + : 160, + }; +} + +function findInCaseSearchSummary( + event: Extract<CourtlistenerToolEvent, { type: "courtlistener_find_in_case" }>, +) { + return { + cluster_id: event.cluster_id, + query: event.query, + total_matches: event.total_matches, + case_name: event.case_name, + citation: event.citation, + error: event.error, + }; +} + +function cachedCaseNotFetchedResult(clusterId: number | null) { + return { + ok: false, + cluster_id: clusterId, + error: + "Case has not been fetched in this turn. Call courtlistener_get_cases first.", + }; +} + export async function runToolCalls( - toolCalls: ToolCall[], - docStore: DocStore, - userId: string, - db: ReturnType<typeof createServerSupabase>, - write: (s: string) => void, - workflowStore?: WorkflowStore, - tabularStore?: TabularCellStore, - docIndex?: DocIndex, - turnEditState?: TurnEditState, - projectId?: string | null, + toolCalls: ToolCall[], + docStore: DocStore, + userId: string, + db: ReturnType<typeof createServerSupabase>, + write: (s: string) => void, + workflowStore?: WorkflowStore, + tabularStore?: TabularCellStore, + docIndex?: DocIndex, + turnEditState?: TurnEditState, + projectId?: string | null, + courtlistenerState?: CourtlistenerTurnState, + apiKeys?: import("./llm").UserApiKeys, ): Promise<{ - toolResults: unknown[]; - docsRead: { filename: string; document_id?: string }[]; - docsFound: { filename: string; query: string; total_matches: number }[]; - docsCreated: DocCreatedResult[]; - docsReplicated: DocReplicatedResult[]; - workflowsApplied: { workflow_id: string; title: string }[]; - docsEdited: DocEditedResult[]; + toolResults: unknown[]; + docsRead: { filename: string; document_id?: string }[]; + docsFound: { filename: string; query: string; total_matches: number }[]; + docsCreated: DocCreatedResult[]; + docsReplicated: DocReplicatedResult[]; + workflowsApplied: { workflow_id: string; title: string }[]; + docsEdited: DocEditedResult[]; + courtlistenerEvents: CourtlistenerToolEvent[]; + caseCitationEvents: CaseCitationEvent[]; }> { - const toolResults: unknown[] = []; - const docsRead: { filename: string; document_id?: string }[] = []; - const docsFound: { - filename: string; - query: string; - total_matches: number; - }[] = []; - const docsCreated: DocCreatedResult[] = []; - const docsReplicated: DocReplicatedResult[] = []; - const workflowsApplied: { workflow_id: string; title: string }[] = []; - const docsEdited: DocEditedResult[] = []; + const toolResults: unknown[] = []; + const docsRead: { filename: string; document_id?: string }[] = []; + const docsFound: { + filename: string; + query: string; + total_matches: number; + }[] = []; + const docsCreated: DocCreatedResult[] = []; + const docsReplicated: DocReplicatedResult[] = []; + const workflowsApplied: { workflow_id: string; title: string }[] = []; + const docsEdited: DocEditedResult[] = []; + const courtlistenerEvents: CourtlistenerToolEvent[] = []; + const caseCitationEvents: CaseCitationEvent[] = []; + const courtState: CourtlistenerTurnState = + courtlistenerState ?? + { + casesByClusterId: new Map(), + }; + const groupedFindInCaseSearches = toolCalls + .filter((tc) => tc.function.name === COURTLISTENER_TOOL_NAMES.findInCase) + .map((tc) => { + let rawArgs: Record<string, unknown> = {}; + try { + rawArgs = JSON.parse(tc.function.arguments || "{}"); + } catch { + /* ignore */ + } + const parsed = parseFindInCaseArgs(rawArgs); + return { + cluster_id: parsed.clusterId, + query: parsed.query, + total_matches: 0, + }; + }); + const shouldGroupFindInCase = groupedFindInCaseSearches.length > 1; + let groupedFindInCaseStarted = false; + const groupedFindInCaseEvents: Extract< + CourtlistenerToolEvent, + { type: "courtlistener_find_in_case" } + >[] = []; - for (const tc of toolCalls) { - let args: Record<string, unknown> = {}; - try { - args = JSON.parse(tc.function.arguments || "{}"); - } catch { - /* ignore */ - } - - if (tc.function.name === "read_document") { - const rawDocId = args.doc_id as string; - const docId = - resolveDocLabel(rawDocId, docStore, docIndex) ?? rawDocId; - const content = await readDocumentContent( - docId, - docStore, - write, - docIndex, - db, - ); - const filename = docStore.get(docId)?.filename; - const documentId = docIndex?.[docId]?.document_id; - if (filename) docsRead.push({ filename, document_id: documentId }); - toolResults.push({ - role: "tool", - tool_call_id: tc.id, - content: filename - ? `${citationReminder(docId, filename)}\n\n${content}` - : content, - }); - } else if (tc.function.name === "find_in_document") { - const rawDocId = args.doc_id as string; - const docId = - resolveDocLabel(rawDocId, docStore, docIndex) ?? rawDocId; - const query = (args.query as string) ?? ""; - const maxResults = - typeof args.max_results === "number" - ? args.max_results - : undefined; - const contextChars = - typeof args.context_chars === "number" - ? args.context_chars - : undefined; - const content = await findInDocumentContent({ - docLabel: docId, - query, - maxResults, - contextChars, - docStore, - write, - docIndex, - db, - }); - const filename = docStore.get(docId)?.filename; - if (filename) { - let totalMatches = 0; - try { - const parsed = JSON.parse(content) as { - total_matches?: number; - }; - totalMatches = parsed.total_matches ?? 0; - } catch { - /* ignore — still record the find attempt */ - } - docsFound.push({ - filename, - query, - total_matches: totalMatches, - }); - } - toolResults.push({ role: "tool", tool_call_id: tc.id, content }); - } else if (tc.function.name === "list_documents") { - const list = Array.from(docStore.entries()).map( - ([doc_id, info]) => ({ - doc_id, - filename: info.filename, - file_type: info.file_type, - }), - ); - toolResults.push({ - role: "tool", - tool_call_id: tc.id, - content: JSON.stringify(list), - }); - } else if (tc.function.name === "fetch_documents") { - const rawDocIds = (args.doc_ids as string[]) ?? []; - const docIds = rawDocIds.map( - (id) => resolveDocLabel(id, docStore, docIndex) ?? id, - ); - const parts: string[] = []; - for (const docId of docIds) { - const content = await readDocumentContent( - docId, - docStore, - write, - docIndex, - db, - ); - const filename = docStore.get(docId)?.filename ?? docId; - parts.push( - `--- ${filename} (${docId}) ---\n${citationReminder(docId, filename)}\n\n${content}`, - ); - if (docStore.get(docId)) { - const documentId = docIndex?.[docId]?.document_id; - docsRead.push({ filename, document_id: documentId }); - } - } - toolResults.push({ - role: "tool", - tool_call_id: tc.id, - content: parts.join("\n\n"), - }); - } else if (tc.function.name === "list_workflows") { - const list = workflowStore - ? Array.from(workflowStore.entries()).map(([id, w]) => ({ - id, - title: w.title, - })) - : []; - toolResults.push({ - role: "tool", - tool_call_id: tc.id, - content: JSON.stringify(list), - }); - } else if (tc.function.name === "read_workflow") { - const wfId = args.workflow_id as string; - const wf = workflowStore?.get(wfId); - if (wf) { - write( - `data: ${JSON.stringify({ type: "workflow_applied", workflow_id: wfId, title: wf.title })}\n\n`, - ); - workflowsApplied.push({ workflow_id: wfId, title: wf.title }); - } - toolResults.push({ - role: "tool", - tool_call_id: tc.id, - content: wf ? wf.prompt_md : `Workflow '${wfId}' not found.`, - }); - } else if (tc.function.name === "read_table_cells" && tabularStore) { - const colIndices = args.col_indices as number[] | undefined; - const rowIndices = args.row_indices as number[] | undefined; - - const filteredCols = colIndices?.length - ? tabularStore.columns.filter((_, i) => colIndices.includes(i)) - : tabularStore.columns; - const filteredDocs = rowIndices?.length - ? tabularStore.documents.filter((_, i) => - rowIndices.includes(i), - ) - : tabularStore.documents; - - const label = `${filteredCols.length} ${filteredCols.length === 1 ? "column" : "columns"} × ${filteredDocs.length} ${filteredDocs.length === 1 ? "row" : "rows"}`; - write( - `data: ${JSON.stringify({ type: "doc_read_start", filename: label })}\n\n`, - ); - - const lines: string[] = []; - for (const col of filteredCols) { - const colPos = tabularStore.columns.findIndex( - (c) => c.index === col.index, - ); - for (const doc of filteredDocs) { - const rowPos = tabularStore.documents.findIndex( - (d) => d.id === doc.id, - ); - const cell = tabularStore.cells.get( - `${col.index}:${doc.id}`, - ); - lines.push( - `[COL:${colPos} "${col.name}" | ROW:${rowPos} "${doc.filename}"]`, - ); - if (cell?.summary) { - lines.push(`Summary: ${cell.summary}`); - if (cell.flag) lines.push(`Flag: ${cell.flag}`); - if (cell.reasoning) - lines.push(`Reasoning: ${cell.reasoning}`); - } else { - lines.push(`(not yet generated)`); - } - lines.push(""); - } - } - - write( - `data: ${JSON.stringify({ type: "doc_read", filename: label })}\n\n`, - ); - docsRead.push({ filename: label }); - toolResults.push({ - role: "tool", - tool_call_id: tc.id, - content: lines.join("\n") || "No cells found.", - }); - } else if (tc.function.name === "edit_document" && docIndex) { - const rawDocId = args.doc_id as string; - const editsRaw = args.edits as unknown[] | undefined; - const docId = - resolveDocLabel(rawDocId, docStore, docIndex) ?? rawDocId; - const docInfo = docStore.get(docId); - const indexed = docIndex?.[docId]; - - const emitEditError = ( - filename: string, - documentId: string, - error: string, - ) => { - // Surface the failure as a failed "Edited" block in the UI - // (start → done-with-error) so it matches the shape the - // success/late-failure paths already use. - write( - `data: ${JSON.stringify({ - type: "doc_edited_start", - filename, - })}\n\n`, - ); - write( - `data: ${JSON.stringify({ - type: "doc_edited", - filename, - document_id: documentId, - version_id: "", - download_url: "", - annotations: [], - error, - })}\n\n`, - ); - }; - - if (!docInfo || !indexed) { - const err = `Document '${docId}' not found in this chat's attachments.`; - emitEditError(docId, indexed?.document_id ?? "", err); - toolResults.push({ - role: "tool", - tool_call_id: tc.id, - content: JSON.stringify({ error: err }), - }); - } else if (!Array.isArray(editsRaw) || editsRaw.length === 0) { - const err = "edits array is required and must not be empty."; - emitEditError(docInfo.filename, indexed.document_id, err); - toolResults.push({ - role: "tool", - tool_call_id: tc.id, - content: JSON.stringify({ error: err }), - }); - } else if (docInfo.file_type !== "docx") { - const err = "edit_document only supports .docx files."; - emitEditError(docInfo.filename, indexed.document_id, err); - toolResults.push({ - role: "tool", - tool_call_id: tc.id, - content: JSON.stringify({ error: err }), - }); - } else { - write( - `data: ${JSON.stringify({ - type: "doc_edited_start", - filename: docInfo.filename, - })}\n\n`, - ); - const edits: EditInput[] = ( - editsRaw as Record<string, unknown>[] - ).map((e) => ({ - find: String(e.find ?? ""), - replace: String(e.replace ?? ""), - context_before: String(e.context_before ?? ""), - context_after: String(e.context_after ?? ""), - reason: e.reason ? String(e.reason) : undefined, - })); - const reuseVersion = turnEditState?.get(indexed.document_id); - const result = await runEditDocument({ - documentId: indexed.document_id, - userId, - edits, - db, - reuseVersion, - }); - - if (result.ok) { - turnEditState?.set(indexed.document_id, { - versionId: result.version_id, - versionNumber: result.version_number, - storagePath: result.storage_path, - }); - // Keep the chat-local doc label pointed at the latest - // edited version so any follow-up read_document call in - // the same assistant turn reads and cites the same bytes. - if (docIndex[docId]) { - docIndex[docId] = { - ...docIndex[docId], - version_id: result.version_id, - version_number: result.version_number, - }; - } - const currentDocStore = docStore.get(docId); - if (currentDocStore) { - docStore.set(docId, { - ...currentDocStore, - storage_path: result.storage_path, - }); - } - const payload: DocEditedResult = { - filename: docInfo.filename, - document_id: indexed.document_id, - version_id: result.version_id, - version_number: result.version_number, - download_url: result.download_url, - annotations: result.annotations, - }; - docsEdited.push(payload); - write( - `data: ${JSON.stringify({ - type: "doc_edited", - ...payload, - })}\n\n`, - ); - toolResults.push({ - role: "tool", - tool_call_id: tc.id, - content: JSON.stringify({ - ok: true, - doc_id: docId, - document_id: indexed.document_id, - version_id: result.version_id, - version_number: result.version_number, - applied: result.annotations.length, - errors: result.errors, - }), - }); - } else { - write( - `data: ${JSON.stringify({ - type: "doc_edited", - filename: docInfo.filename, - document_id: indexed.document_id, - version_id: "", - download_url: "", - annotations: [], - error: result.error, - })}\n\n`, - ); - toolResults.push({ - role: "tool", - tool_call_id: tc.id, - content: JSON.stringify({ - ok: false, - error: result.error, - }), - }); - } - } - } else if (tc.function.name === "replicate_document" && docIndex) { - const rawDocId = args.doc_id as string; - const requestedFilename = - typeof args.new_filename === "string" && - args.new_filename.trim() - ? args.new_filename.trim() - : null; - const requestedCount = - typeof args.count === "number" && Number.isFinite(args.count) - ? Math.max(1, Math.min(20, Math.floor(args.count))) - : 1; - const sourceLabel = - resolveDocLabel(rawDocId, docStore, docIndex) ?? rawDocId; - const sourceInfo = docStore.get(sourceLabel); - const sourceIndexed = docIndex[sourceLabel]; - const sourceFilename = sourceInfo?.filename ?? rawDocId; - - write( - `data: ${JSON.stringify({ - type: "doc_replicate_start", - filename: sourceFilename, - count: requestedCount, - })}\n\n`, - ); - - const fail = (error: string) => { - write( - `data: ${JSON.stringify({ - type: "doc_replicated", - filename: sourceFilename, - count: requestedCount, - copies: [], - error, - })}\n\n`, - ); - toolResults.push({ - role: "tool", - tool_call_id: tc.id, - content: JSON.stringify({ ok: false, error }), - }); - }; - - if (!sourceInfo || !sourceIndexed) { - fail(`Document '${rawDocId}' not found in this project.`); - } else if (!projectId) { - fail("replicate_document is only available in project chats."); - } else { - try { - // Pull the active version once — every copy gets the - // same starting bytes (with any accepted tracked - // changes rolled in), no point re-fetching per copy. - const active = await loadActiveVersion( - sourceIndexed.document_id, - db, - ); - const sourcePath = - active?.storage_path ?? sourceInfo.storage_path; - const sourcePdfPath = active?.pdf_storage_path ?? null; - const raw = await downloadFile(sourcePath); - const pdfBytes = sourcePdfPath - ? await downloadFile(sourcePdfPath) - : null; - if (!raw) { - fail( - "Could not read the source document's bytes from storage.", - ); - } else { - // Build N filenames. With count=1 keep the - // pre-existing "(copy)" suffix; with count>1 use - // numbered "(1)", "(2)" suffixes. - const srcExt = - sourceInfo.filename.match(/\.[^./\\]+$/)?.[0] ?? ""; - const baseStem = (() => { - if (requestedFilename) { - return requestedFilename.replace( - /\.[^./\\]+$/, - "", - ); - } - return sourceInfo.filename.replace( - /\.[^./\\]+$/, - "", - ); - })(); - const filenames: string[] = []; - for (let n = 1; n <= requestedCount; n++) { - const suffix = - requestedCount === 1 - ? requestedFilename - ? "" - : " (copy)" - : ` (${n})`; - filenames.push(`${baseStem}${suffix}${srcExt}`); - } - - // Bulk insert N documents in one round-trip. - const docRows = filenames.map((fn) => ({ - project_id: projectId, - user_id: userId, - filename: fn, - file_type: sourceInfo.file_type, - size_bytes: raw.byteLength, - status: "ready", - })); - const { data: insertedDocs, error: docErr } = await db - .from("documents") - .insert(docRows) - .select("id, filename"); - if ( - docErr || - !insertedDocs || - insertedDocs.length === 0 - ) { - fail( - `Failed to record replicated documents: ${docErr?.message ?? "unknown"}`, - ); - } else { - // Preserve the request order so each row pairs - // with the right filename. Supabase returns - // inserted rows in the same order as the - // payload. - const newDocs = insertedDocs as { - id: string; - filename: string; - }[]; - const contentType = - sourceInfo.file_type === "pdf" - ? "application/pdf" - : "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; - - // Parallel uploads: the doc bytes (and PDF - // rendition if any) for every new copy. - const uploadJobs: Promise<unknown>[] = []; - const newKeys: string[] = []; - const newPdfKeys: (string | null)[] = []; - for (const d of newDocs) { - const key = storageKey( - userId, - d.id, - d.filename, - ); - newKeys.push(key); - uploadJobs.push( - uploadFile(key, raw, contentType), - ); - if (pdfBytes) { - const pdfKey = convertedPdfKey( - userId, - d.id, - ); - newPdfKeys.push(pdfKey); - uploadJobs.push( - uploadFile( - pdfKey, - pdfBytes, - "application/pdf", - ), - ); - } else { - newPdfKeys.push(null); - } - } - await Promise.all(uploadJobs); - - // Bulk insert N versions in one round-trip. - const versionRows = newDocs.map((d, idx) => ({ - document_id: d.id, - storage_path: newKeys[idx], - pdf_storage_path: newPdfKeys[idx], - source: "upload", - version_number: 1, - display_name: d.filename, - })); - const { data: insertedVersions, error: verErr } = - await db - .from("document_versions") - .insert(versionRows) - .select("id, document_id"); - if ( - verErr || - !insertedVersions || - insertedVersions.length !== newDocs.length - ) { - fail( - `Failed to record replicated document versions: ${verErr?.message ?? "unknown"}`, - ); - } else { - const versionByDocId = new Map< - string, - string - >(); - for (const v of insertedVersions as { - id: string; - document_id: string; - }[]) { - versionByDocId.set(v.document_id, v.id); - } - - // current_version_id has to be a per-row - // value, so a single UPDATE statement - // can't cover all N. Fan out in parallel - // instead of sequential awaits. - await Promise.all( - newDocs.map((d) => - db - .from("documents") - .update({ - current_version_id: - versionByDocId.get(d.id), - }) - .eq("id", d.id), - ), - ); - - // Register every copy under a fresh doc-N - // slug so the model can edit/read any of - // them in the same turn. - const existingLabels = new Set( - Object.keys(docIndex), - ); - let nextLabelIdx = 0; - const copies: { - new_filename: string; - document_id: string; - version_id: string; - }[] = []; - const toolPayloadCopies: { - doc_id: string; - document_id: string; - version_id: string; - filename: string; - download_url: string; - }[] = []; - for (let idx = 0; idx < newDocs.length; idx++) { - const d = newDocs[idx]; - const newKey = newKeys[idx]; - const versionId = versionByDocId.get(d.id); - if (!versionId) continue; - while ( - existingLabels.has( - `doc-${nextLabelIdx}`, - ) - ) - nextLabelIdx++; - const slug = `doc-${nextLabelIdx}`; - existingLabels.add(slug); - docIndex[slug] = { - document_id: d.id, - filename: d.filename, - }; - docStore.set(slug, { - storage_path: newKey, - file_type: sourceInfo.file_type, - filename: d.filename, - }); - copies.push({ - new_filename: d.filename, - document_id: d.id, - version_id: versionId, - }); - toolPayloadCopies.push({ - doc_id: slug, - document_id: d.id, - version_id: versionId, - filename: d.filename, - download_url: buildDownloadUrl( - newKey, - d.filename, - ), - }); - } - - write( - `data: ${JSON.stringify({ - type: "doc_replicated", - filename: sourceFilename, - count: copies.length, - copies, - })}\n\n`, - ); - docsReplicated.push({ - filename: sourceFilename, - count: copies.length, - copies, - }); - toolResults.push({ - role: "tool", - tool_call_id: tc.id, - content: JSON.stringify({ - ok: true, - count: copies.length, - copies: toolPayloadCopies, - }), - }); - } - } - } - } catch (e) { - fail(`replicate_document failed: ${String(e)}`); - } - } - } else if (tc.function.name === "generate_docx") { - const title = args.title as string; - const landscape = !!args.landscape; - console.log( - `[generate_docx] title="${title}" landscape=${landscape} args.landscape=${args.landscape}`, - ); - const previewFilename = `${ - title - .replace(/[^a-zA-Z0-9 _-]/g, "") - .trim() - .slice(0, 64) || "document" - }.docx`; - write( - `data: ${JSON.stringify({ type: "doc_created_start", filename: previewFilename })}\n\n`, - ); - const result = await generateDocx( - title, - args.sections as unknown[], - userId, - db, - { landscape, projectId: projectId ?? null }, - ); - let newDocLabel: string | null = null; - if ("filename" in result && "download_url" in result) { - const dlFilename = result.filename as string; - const dlUrl = result.download_url as string; - const documentId = (result as { document_id?: string }) - .document_id; - const versionId = (result as { version_id?: string }) - .version_id; - const versionNumber = - (result as { version_number?: number }).version_number ?? - null; - const storagePath = (result as { storage_path?: string }) - .storage_path; - - // Register the generated doc in the chat context so - // edit_document (and read_document / find_in_document) - // can act on it within the same assistant turn. New label - // is the next free `doc-N` index. Subsequent turns pick - // it up via the normal attachment/project doc query. - if (documentId && storagePath && docIndex) { - const existingLabels = new Set(Object.keys(docIndex)); - let i = 0; - while (existingLabels.has(`doc-${i}`)) i++; - newDocLabel = `doc-${i}`; - docIndex[newDocLabel] = { - document_id: documentId, - filename: dlFilename, - }; - docStore.set(newDocLabel, { - storage_path: storagePath, - file_type: "docx", - filename: dlFilename, - }); - } - - write( - `data: ${JSON.stringify({ - type: "doc_created", - filename: dlFilename, - download_url: dlUrl, - document_id: documentId, - version_id: versionId, - version_number: versionNumber, - })}\n\n`, - ); - docsCreated.push({ - filename: dlFilename, - download_url: dlUrl, - document_id: documentId, - version_id: versionId, - version_number: versionNumber, - }); - } else { - write( - `data: ${JSON.stringify({ type: "doc_created", filename: previewFilename, download_url: "" })}\n\n`, - ); - } - // Surface the chat-local doc label in the tool result so the - // model can pass it as `doc_id` to edit_document / read_document - // / find_in_document in the same turn. Without this the model - // only sees the DB UUID, which isn't valid as a doc_id anchor. - const { download_url, storage_path, ...safeToolResult } = - result as Record<string, unknown>; - const toolResultPayload = newDocLabel - ? { - ...safeToolResult, - doc_id: newDocLabel, - next_required_action: `Before writing your final response, call read_document with doc_id "${newDocLabel}". Describe and cite the generated document using doc_id "${newDocLabel}", not the source/template document.`, - } - : safeToolResult; - toolResults.push({ - role: "tool", - tool_call_id: tc.id, - content: JSON.stringify(toolResultPayload), - }); - } + for (const tc of toolCalls) { + let args: Record<string, unknown> = {}; + try { + args = JSON.parse(tc.function.arguments || "{}"); + } catch { + /* ignore */ } - return { - toolResults, - docsRead, - docsFound, - docsCreated, - docsReplicated, - workflowsApplied, - docsEdited, + if (tc.function.name === "read_document") { + const rawDocId = args.doc_id as string; + const docId = resolveDocLabel(rawDocId, docStore, docIndex) ?? rawDocId; + const content = await readDocumentContent( + docId, + docStore, + write, + docIndex, + db, + ); + const filename = docStore.get(docId)?.filename; + const documentId = docIndex?.[docId]?.document_id; + if (filename) docsRead.push({ filename, document_id: documentId }); + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: filename + ? `${citationReminder(docId, filename)}\n\n${content}` + : content, + }); + } else if (tc.function.name === "find_in_document") { + const rawDocId = args.doc_id as string; + const docId = resolveDocLabel(rawDocId, docStore, docIndex) ?? rawDocId; + const query = (args.query as string) ?? ""; + const maxResults = + typeof args.max_results === "number" ? args.max_results : undefined; + const contextChars = + typeof args.context_chars === "number" ? args.context_chars : undefined; + const content = await findInDocumentContent({ + docLabel: docId, + query, + maxResults, + contextChars, + docStore, + write, + docIndex, + db, + }); + const filename = docStore.get(docId)?.filename; + if (filename) { + let totalMatches = 0; + try { + const parsed = JSON.parse(content) as { + total_matches?: number; + }; + totalMatches = parsed.total_matches ?? 0; + } catch { + /* ignore — still record the find attempt */ + } + docsFound.push({ + filename, + query, + total_matches: totalMatches, + }); + } + toolResults.push({ role: "tool", tool_call_id: tc.id, content }); + } else if (tc.function.name === "list_documents") { + const list = Array.from(docStore.entries()).map(([doc_id, info]) => ({ + doc_id, + filename: info.filename, + file_type: info.file_type, + })); + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: JSON.stringify(list), + }); + } else if (tc.function.name === "fetch_documents") { + const rawDocIds = (args.doc_ids as string[]) ?? []; + const docIds = rawDocIds.map( + (id) => resolveDocLabel(id, docStore, docIndex) ?? id, + ); + const parts: string[] = []; + for (const docId of docIds) { + const content = await readDocumentContent( + docId, + docStore, + write, + docIndex, + db, + ); + const filename = docStore.get(docId)?.filename ?? docId; + parts.push( + `--- ${filename} (${docId}) ---\n${citationReminder(docId, filename)}\n\n${content}`, + ); + if (docStore.get(docId)) { + const documentId = docIndex?.[docId]?.document_id; + docsRead.push({ filename, document_id: documentId }); + } + } + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: parts.join("\n\n"), + }); + } else if (tc.function.name === "list_workflows") { + const list = workflowStore + ? Array.from(workflowStore.entries()).map(([id, w]) => ({ + id, + title: w.title, + })) + : []; + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: JSON.stringify(list), + }); + } else if (tc.function.name === "read_workflow") { + const wfId = args.workflow_id as string; + const wf = workflowStore?.get(wfId); + if (wf) { + write( + `data: ${JSON.stringify({ type: "workflow_applied", workflow_id: wfId, title: wf.title })}\n\n`, + ); + workflowsApplied.push({ workflow_id: wfId, title: wf.title }); + } + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: wf ? wf.prompt_md : `Workflow '${wfId}' not found.`, + }); + } else if (tc.function.name === "read_table_cells" && tabularStore) { + const colIndices = args.col_indices as number[] | undefined; + const rowIndices = args.row_indices as number[] | undefined; + + const filteredCols = colIndices?.length + ? tabularStore.columns.filter((_, i) => colIndices.includes(i)) + : tabularStore.columns; + const filteredDocs = rowIndices?.length + ? tabularStore.documents.filter((_, i) => rowIndices.includes(i)) + : tabularStore.documents; + + const label = `${filteredCols.length} ${filteredCols.length === 1 ? "column" : "columns"} × ${filteredDocs.length} ${filteredDocs.length === 1 ? "row" : "rows"}`; + write( + `data: ${JSON.stringify({ type: "doc_read_start", filename: label })}\n\n`, + ); + + const lines: string[] = []; + for (const col of filteredCols) { + const colPos = tabularStore.columns.findIndex( + (c) => c.index === col.index, + ); + for (const doc of filteredDocs) { + const rowPos = tabularStore.documents.findIndex( + (d) => d.id === doc.id, + ); + const cell = tabularStore.cells.get(`${col.index}:${doc.id}`); + lines.push( + `[COL:${colPos} "${col.name}" | ROW:${rowPos} "${doc.filename}"]`, + ); + if (cell?.summary) { + lines.push(`Summary: ${cell.summary}`); + if (cell.flag) lines.push(`Flag: ${cell.flag}`); + if (cell.reasoning) lines.push(`Reasoning: ${cell.reasoning}`); + } else { + lines.push(`(not yet generated)`); + } + lines.push(""); + } + } + + write( + `data: ${JSON.stringify({ type: "doc_read", filename: label })}\n\n`, + ); + docsRead.push({ filename: label }); + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: lines.join("\n") || "No cells found.", + }); + } else if (tc.function.name === COURTLISTENER_TOOL_NAMES.searchCaseLaw) { + const query = typeof args.query === "string" ? args.query : ""; + write( + `data: ${JSON.stringify({ type: "courtlistener_search_case_law_start", query })}\n\n`, + ); + try { + const result = await searchCourtlistenerCaseLaw({ + query: query || undefined, + court: typeof args.court === "string" ? args.court : undefined, + filedAfter: + typeof args.filedAfter === "string" ? args.filedAfter : undefined, + filedBefore: + typeof args.filedBefore === "string" ? args.filedBefore : undefined, + limit: typeof args.limit === "number" ? args.limit : undefined, + apiToken: apiKeys?.courtlistener, + }); + const resultCount = + result && + typeof result === "object" && + Array.isArray((result as { results?: unknown }).results) + ? (result as { results: unknown[] }).results.length + : 0; + const error = + result && + typeof result === "object" && + typeof (result as { error?: unknown }).error === "string" + ? (result as { error: string }).error + : undefined; + const event: CourtlistenerToolEvent = { + type: "courtlistener_search_case_law", + query, + result_count: resultCount, + ...(error ? { error } : {}), + }; + write(`data: ${JSON.stringify(event)}\n\n`); + courtlistenerEvents.push(event); + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: JSON.stringify(result), + }); + } catch (err) { + const event: CourtlistenerToolEvent = { + type: "courtlistener_search_case_law", + query, + result_count: 0, + error: + err instanceof Error ? err.message : "CourtListener search failed.", + }; + write(`data: ${JSON.stringify(event)}\n\n`); + courtlistenerEvents.push(event); + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: JSON.stringify({ + error: + err instanceof Error + ? err.message + : "CourtListener search failed.", + }), + }); + } + } else if (tc.function.name === COURTLISTENER_TOOL_NAMES.getCases) { + const rawClusterIds = Array.isArray(args.clusterIds) + ? args.clusterIds + : Array.isArray(args.cluster_ids) + ? args.cluster_ids + : typeof args.clusterId === "number" + ? [args.clusterId] + : []; + const clusterIds = Array.from( + new Set( + rawClusterIds + .filter((value): value is number => typeof value === "number") + .filter((value) => Number.isFinite(value) && value > 0) + .map((value) => Math.floor(value)), + ), + ); + write( + `data: ${JSON.stringify({ type: "courtlistener_get_cases_start", cluster_ids: clusterIds })}\n\n`, + ); + try { + const result = await getCourtlistenerCases({ + clusterIds, + db, + apiToken: apiKeys?.courtlistener, + }); + const fetchedCases = + result && + typeof result === "object" && + Array.isArray((result as { cases?: unknown }).cases) + ? (result as { cases: unknown[] }).cases + : []; + fetchedCases.forEach((fetchedCase, index) => { + const clusterId = + courtlistenerCaseInputFromFetchedCase( + clusterIds[index] ?? 0, + fetchedCase, + ).clusterId ?? 0; + if (clusterId) { + write( + `data: ${JSON.stringify({ type: "case_opinions", cluster_id: clusterId, case: fetchedCase })}\n\n`, + ); + } + }); + const caseRecords = upsertCourtlistenerCases( + courtState, + fetchedCases.map((fetchedCase, index) => + courtlistenerCaseInputFromFetchedCase( + clusterIds[index] ?? 0, + fetchedCase, + ), + ), + ); + const opinionCount = fetchedCases.reduce<number>( + (sum, fetchedCase) => sum + courtlistenerOpinionCount(fetchedCase), + 0, + ); + const caseOpinionCountByClusterId = new Map<number, number>(); + fetchedCases.forEach((fetchedCase, index) => { + const clusterId = + courtlistenerCaseInputFromFetchedCase( + clusterIds[index] ?? 0, + fetchedCase, + ).clusterId ?? 0; + if (clusterId) { + caseOpinionCountByClusterId.set( + clusterId, + courtlistenerOpinionCount(fetchedCase), + ); + } + }); + const errors = fetchedCases + .map((fetchedCase) => + stringField(recordFromUnknown(fetchedCase), "error"), + ) + .filter((error): error is string => !!error); + const resultError = + result && + typeof result === "object" && + typeof (result as { error?: unknown }).error === "string" + ? (result as { error: string }).error + : undefined; + const hasMultipleOpinionCase = caseRecords.some( + (record) => + (caseOpinionCountByClusterId.get(record.clusterId) ?? 0) > 1, + ); + const event: CourtlistenerToolEvent = { + type: "courtlistener_get_cases", + cluster_ids: clusterIds, + case_count: fetchedCases.length, + opinion_count: opinionCount, + cases: caseRecords.map((record) => ({ + cluster_id: record.clusterId, + case_name: record.caseName, + citation: record.citations[0] ?? null, + dateFiled: record.dateFiled, + url: record.url, + })), + ...(resultError || errors.length + ? { error: resultError ?? errors.join("; ") } + : {}), + }; + write(`data: ${JSON.stringify(event)}\n\n`); + courtlistenerEvents.push(event); + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: JSON.stringify({ + ok: !resultError && errors.length === 0, + cluster_ids: clusterIds, + case_count: fetchedCases.length, + opinion_count: opinionCount, + cases: caseRecords.map((record) => + courtlistenerFetchedCaseMetadata( + record, + caseOpinionCountByClusterId.get(record.clusterId) ?? 0, + ), + ), + ...(resultError || errors.length + ? { error: resultError ?? errors.join("; ") } + : {}), + next_required_action: hasMultipleOpinionCase + ? "Opinion text is cached server-side only. Use courtlistener_find_in_case with short 1-3 word keyword probes for relevant passages. At least one fetched case has multiple opinions; if snippets are insufficient, choose the needed opinion_id(s) from the text-free opinion metadata and call courtlistener_read_case with only those IDs. Do not read all opinions unless the question requires it." + : "Opinion text is cached server-side only. Use courtlistener_find_in_case with short 1-3 word keyword probes for relevant passages, or courtlistener_read_case if snippets are insufficient.", + }), + }); + } catch (err) { + const event: CourtlistenerToolEvent = { + type: "courtlistener_get_cases", + cluster_ids: clusterIds, + case_count: 0, + opinion_count: 0, + error: + err instanceof Error + ? err.message + : "CourtListener case fetch failed.", + }; + write(`data: ${JSON.stringify(event)}\n\n`); + courtlistenerEvents.push(event); + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: JSON.stringify({ + error: + err instanceof Error + ? err.message + : "CourtListener case fetch failed.", + }), + }); + } + } else if (tc.function.name === COURTLISTENER_TOOL_NAMES.findInCase) { + const { clusterId, query, maxResults, contextChars } = + parseFindInCaseArgs(args); + if (shouldGroupFindInCase) { + if (!groupedFindInCaseStarted) { + write( + `data: ${JSON.stringify({ + type: "courtlistener_find_in_case_start", + cluster_id: null, + query: "", + searches: groupedFindInCaseSearches, + })}\n\n`, + ); + groupedFindInCaseStarted = true; + } + } else { + write( + `data: ${JSON.stringify({ type: "courtlistener_find_in_case_start", cluster_id: clusterId, query })}\n\n`, + ); + } + + const record = + typeof clusterId === "number" ? courtState.casesByClusterId.get(clusterId) : undefined; + if (!record) { + const payload = cachedCaseNotFetchedResult(clusterId); + const event: CourtlistenerToolEvent = { + type: "courtlistener_find_in_case", + cluster_id: clusterId, + query, + total_matches: 0, + error: payload.error, + }; + if (shouldGroupFindInCase) { + groupedFindInCaseEvents.push(event); + } else { + write(`data: ${JSON.stringify(event)}\n\n`); + courtlistenerEvents.push(event); + } + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: JSON.stringify(payload), + }); + continue; + } + + const opinions = cachedCaseOpinionTexts(record); + const hits: Array< + TextMatch & { + opinion_id: number | null; + type: string | null; + author: string | null; + url: string | null; + } + > = []; + let totalMatches = 0; + for (const opinion of opinions) { + const remaining = Math.max(0, maxResults - hits.length); + const result = findTextMatches({ + text: opinion.text, + query, + maxResults: remaining, + contextChars, + startIndex: hits.length, + }); + totalMatches += result.totalMatches; + hits.push( + ...result.hits.map((hit) => ({ + ...hit, + opinion_id: opinion.opinion_id, + type: opinion.type, + author: opinion.author, + url: opinion.url, + })), + ); + } + + const event: CourtlistenerToolEvent = { + type: "courtlistener_find_in_case", + cluster_id: record.clusterId, + query, + total_matches: totalMatches, + case_name: record.caseName, + citation: record.citations[0] ?? null, + }; + if (shouldGroupFindInCase) { + groupedFindInCaseEvents.push(event); + } else { + write(`data: ${JSON.stringify(event)}\n\n`); + courtlistenerEvents.push(event); + } + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: JSON.stringify({ + ok: true, + cluster_id: record.clusterId, + case_name: record.caseName, + citation: record.citations[0] ?? null, + query, + total_matches: totalMatches, + returned: hits.length, + truncated: totalMatches > hits.length, + hits, + }), + }); + } else if (tc.function.name === COURTLISTENER_TOOL_NAMES.readCase) { + const clusterId = + typeof args.clusterId === "number" && Number.isFinite(args.clusterId) + ? Math.floor(args.clusterId) + : typeof args.cluster_id === "number" && + Number.isFinite(args.cluster_id) + ? Math.floor(args.cluster_id) + : null; + write( + `data: ${JSON.stringify({ type: "courtlistener_read_case_start", cluster_id: clusterId })}\n\n`, + ); + + const record = + typeof clusterId === "number" ? courtState.casesByClusterId.get(clusterId) : undefined; + if (!record) { + const payload = cachedCaseNotFetchedResult(clusterId); + const event: CourtlistenerToolEvent = { + type: "courtlistener_read_case", + cluster_id: clusterId, + opinion_count: 0, + error: payload.error, + }; + write(`data: ${JSON.stringify(event)}\n\n`); + courtlistenerEvents.push(event); + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: JSON.stringify(payload), + }); + continue; + } + + const opinions = cachedCaseOpinionTexts(record); + const requestedOpinionIds = requestedCourtlistenerOpinionIds(args); + const selectedOpinions = + requestedOpinionIds.length > 0 + ? opinions.filter( + (opinion) => + typeof opinion.opinion_id === "number" && + requestedOpinionIds.includes(opinion.opinion_id), + ) + : opinions.length === 1 + ? opinions + : []; + if (!selectedOpinions.length) { + const multipleOpinions = opinions.length > 1; + const payload = { + ok: false, + cluster_id: record.clusterId, + case_name: record.caseName, + citations: record.citations, + url: record.url, + dateFiled: record.dateFiled, + judges: record.judges, + opinion_count: opinions.length, + opinions: (record.opinions ?? []) + .map(courtlistenerOpinionMetadata) + .filter( + (opinion): opinion is NonNullable<typeof opinion> => + !!opinion, + ), + error: multipleOpinions + ? "Multiple opinions are available. Call courtlistener_read_case again with the opinionId or opinionIds needed." + : "No matching opinion_id was found for this fetched case.", + }; + const event: CourtlistenerToolEvent = { + type: "courtlistener_read_case", + cluster_id: record.clusterId, + case_name: record.caseName, + citation: record.citations[0] ?? null, + opinion_count: 0, + error: payload.error, + }; + write(`data: ${JSON.stringify(event)}\n\n`); + courtlistenerEvents.push(event); + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: JSON.stringify(payload), + }); + continue; + } + + const event: CourtlistenerToolEvent = { + type: "courtlistener_read_case", + cluster_id: record.clusterId, + case_name: record.caseName, + citation: record.citations[0] ?? null, + opinion_count: selectedOpinions.length, + }; + write(`data: ${JSON.stringify(event)}\n\n`); + courtlistenerEvents.push(event); + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: JSON.stringify({ + ok: true, + cluster_id: record.clusterId, + case_name: record.caseName, + citations: record.citations, + url: record.url, + dateFiled: record.dateFiled, + judges: record.judges, + opinion_count: opinions.length, + returned_opinion_count: selectedOpinions.length, + opinions: selectedOpinions, + }), + }); + } else if (tc.function.name === COURTLISTENER_TOOL_NAMES.verifyCitations) { + const citations = Array.isArray(args.citations) + ? args.citations.filter( + (value): value is string => typeof value === "string", + ) + : undefined; + const citationCount = + citations?.length ?? + (typeof args.text === "string" && args.text.trim() ? 1 : 0); + write( + `data: ${JSON.stringify({ type: "courtlistener_verify_citations_start", citation_count: citationCount })}\n\n`, + ); + try { + const result = (await verifyCourtlistenerCitations({ + text: typeof args.text === "string" ? args.text : undefined, + citations, + db, + apiToken: apiKeys?.courtlistener, + })) as { + citationLinks?: { + clusterId?: number | null; + citation?: string | null; + caseName?: string | null; + dateFiled?: string | null; + pdfUrl?: string | null; + judges?: string | null; + url?: string | null; + markdown?: string; + }[]; + results?: unknown[]; + error?: string; + source?: string; + [key: string]: unknown; + }; + if (Array.isArray(result.citationLinks)) { + const caseRecords = upsertCourtlistenerCases( + courtState, + result.citationLinks.map((link) => ({ + clusterId: link.clusterId, + caseName: link.caseName, + citation: link.citation, + url: link.url, + pdfUrl: link.pdfUrl, + dateFiled: link.dateFiled, + judges: link.judges, + })), + ); + const recordsByClusterId = new Map( + caseRecords.map((record) => [record.clusterId, record]), + ); + result.citationLinks = result.citationLinks.map((link) => { + if (!link.url) return link; + const href = + typeof link.clusterId === "number" + ? `us-case-${link.clusterId}` + : link.url; + const label = [link.caseName, link.citation] + .filter(Boolean) + .join(", "); + const record = + typeof link.clusterId === "number" + ? recordsByClusterId.get(link.clusterId) + : undefined; + if (record) { + const event = caseCitationEventFromRecord(record); + if (event) { + caseCitationEvents.push(event); + write(`data: ${JSON.stringify(event)}\n\n`); + } + } + return { + ...link, + markdown: `[${label || link.url}](${href})`, + }; + }); + } + const rows = + result && + typeof result === "object" && + Array.isArray((result as { results?: unknown }).results) + ? (result as { results: unknown[] }).results + : []; + const matchCount = rows.reduce<number>((count, row) => { + if (!row || typeof row !== "object") return count; + const clusters = (row as { clusters?: unknown }).clusters; + return count + (Array.isArray(clusters) ? clusters.length : 0); + }, 0); + const error = + result && + typeof result === "object" && + typeof (result as { error?: unknown }).error === "string" + ? (result as { error: string }).error + : undefined; + const event: CourtlistenerToolEvent = { + type: "courtlistener_verify_citations", + citation_count: citationCount, + match_count: matchCount, + ...(error ? { error } : {}), + }; + write(`data: ${JSON.stringify(event)}\n\n`); + courtlistenerEvents.push(event); + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: JSON.stringify(result), + }); + } catch (err) { + const event: CourtlistenerToolEvent = { + type: "courtlistener_verify_citations", + citation_count: citationCount, + match_count: 0, + error: + err instanceof Error + ? err.message + : "CourtListener citation lookup failed.", + }; + write(`data: ${JSON.stringify(event)}\n\n`); + courtlistenerEvents.push(event); + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: JSON.stringify({ + error: + err instanceof Error + ? err.message + : "CourtListener citation lookup failed.", + }), + }); + } + } else if (tc.function.name === "edit_document" && docIndex) { + const rawDocId = args.doc_id as string; + const editsRaw = args.edits as unknown[] | undefined; + const docId = resolveDocLabel(rawDocId, docStore, docIndex) ?? rawDocId; + const docInfo = docStore.get(docId); + const indexed = docIndex?.[docId]; + + const emitEditError = ( + filename: string, + documentId: string, + error: string, + ) => { + // Surface the failure as a failed "Edited" block in the UI + // (start → done-with-error) so it matches the shape the + // success/late-failure paths already use. + write( + `data: ${JSON.stringify({ + type: "doc_edited_start", + filename, + })}\n\n`, + ); + write( + `data: ${JSON.stringify({ + type: "doc_edited", + filename, + document_id: documentId, + version_id: "", + download_url: "", + annotations: [], + error, + })}\n\n`, + ); + }; + + if (!docInfo || !indexed) { + const err = `Document '${docId}' not found in this chat's attachments.`; + emitEditError(docId, indexed?.document_id ?? "", err); + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: JSON.stringify({ error: err }), + }); + } else if (!Array.isArray(editsRaw) || editsRaw.length === 0) { + const err = "edits array is required and must not be empty."; + emitEditError(docInfo.filename, indexed.document_id, err); + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: JSON.stringify({ error: err }), + }); + } else if (docInfo.file_type !== "docx") { + const err = "edit_document only supports .docx files."; + emitEditError(docInfo.filename, indexed.document_id, err); + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: JSON.stringify({ error: err }), + }); + } else { + write( + `data: ${JSON.stringify({ + type: "doc_edited_start", + filename: docInfo.filename, + })}\n\n`, + ); + const edits: EditInput[] = (editsRaw as Record<string, unknown>[]).map( + (e) => ({ + find: String(e.find ?? ""), + replace: String(e.replace ?? ""), + context_before: String(e.context_before ?? ""), + context_after: String(e.context_after ?? ""), + reason: e.reason ? String(e.reason) : undefined, + }), + ); + const reuseVersion = turnEditState?.get(indexed.document_id); + const result = await runEditDocument({ + documentId: indexed.document_id, + userId, + edits, + db, + reuseVersion, + }); + + if (result.ok) { + turnEditState?.set(indexed.document_id, { + versionId: result.version_id, + versionNumber: result.version_number, + storagePath: result.storage_path, + }); + // Keep the chat-local doc label pointed at the latest + // edited version so any follow-up read_document call in + // the same assistant turn reads and cites the same bytes. + if (docIndex[docId]) { + docIndex[docId] = { + ...docIndex[docId], + version_id: result.version_id, + version_number: result.version_number, + }; + } + const currentDocStore = docStore.get(docId); + if (currentDocStore) { + docStore.set(docId, { + ...currentDocStore, + storage_path: result.storage_path, + }); + } + const payload: DocEditedResult = { + filename: docInfo.filename, + document_id: indexed.document_id, + version_id: result.version_id, + version_number: result.version_number, + download_url: result.download_url, + annotations: result.annotations, + }; + docsEdited.push(payload); + write( + `data: ${JSON.stringify({ + type: "doc_edited", + ...payload, + })}\n\n`, + ); + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: JSON.stringify({ + ok: true, + doc_id: docId, + document_id: indexed.document_id, + version_id: result.version_id, + version_number: result.version_number, + applied: result.annotations.length, + errors: result.errors, + next_required_action: [ + `The edited document remains available as doc_id "${docId}".`, + `Before making factual claims about the edited document's final contents, call read_document with doc_id "${docId}" and base the response on that returned text.`, + `Do not include download links or URLs in your prose response; the edited document card is shown automatically by the UI.`, + `If you describe specific content from the edited document, cite it with [N] markers and a final <CITATIONS> block using doc_id "${docId}".`, + ].join(" "), + }), + }); + } else { + write( + `data: ${JSON.stringify({ + type: "doc_edited", + filename: docInfo.filename, + document_id: indexed.document_id, + version_id: "", + download_url: "", + annotations: [], + error: result.error, + })}\n\n`, + ); + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: JSON.stringify({ + ok: false, + error: result.error, + }), + }); + } + } + } else if (tc.function.name === "replicate_document" && docIndex) { + const rawDocId = args.doc_id as string; + const requestedFilename = + typeof args.new_filename === "string" && args.new_filename.trim() + ? args.new_filename.trim() + : null; + const requestedCount = + typeof args.count === "number" && Number.isFinite(args.count) + ? Math.max(1, Math.min(20, Math.floor(args.count))) + : 1; + const sourceLabel = + resolveDocLabel(rawDocId, docStore, docIndex) ?? rawDocId; + const sourceInfo = docStore.get(sourceLabel); + const sourceIndexed = docIndex[sourceLabel]; + const sourceFilename = sourceInfo?.filename ?? rawDocId; + + write( + `data: ${JSON.stringify({ + type: "doc_replicate_start", + filename: sourceFilename, + count: requestedCount, + })}\n\n`, + ); + + const fail = (error: string) => { + write( + `data: ${JSON.stringify({ + type: "doc_replicated", + filename: sourceFilename, + count: requestedCount, + copies: [], + error, + })}\n\n`, + ); + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: JSON.stringify({ ok: false, error }), + }); + }; + + if (!sourceInfo || !sourceIndexed) { + fail(`Document '${rawDocId}' not found in this project.`); + } else if (!projectId) { + fail("replicate_document is only available in project chats."); + } else { + try { + // Pull the active version once — every copy gets the + // same starting bytes (with any accepted tracked + // changes rolled in), no point re-fetching per copy. + const active = await loadActiveVersion(sourceIndexed.document_id, db); + const sourcePath = active?.storage_path ?? sourceInfo.storage_path; + const sourcePdfPath = active?.pdf_storage_path ?? null; + const raw = await downloadFile(sourcePath); + const pdfBytes = sourcePdfPath + ? await downloadFile(sourcePdfPath) + : null; + if (!raw) { + fail("Could not read the source document's bytes from storage."); + } else { + // Build N filenames. With count=1 keep the + // pre-existing "(copy)" suffix; with count>1 use + // numbered "(1)", "(2)" suffixes. + const srcExt = sourceInfo.filename.match(/\.[^./\\]+$/)?.[0] ?? ""; + const baseStem = (() => { + if (requestedFilename) { + return requestedFilename.replace(/\.[^./\\]+$/, ""); + } + return sourceInfo.filename.replace(/\.[^./\\]+$/, ""); + })(); + const filenames: string[] = []; + for (let n = 1; n <= requestedCount; n++) { + const suffix = + requestedCount === 1 + ? requestedFilename + ? "" + : " (copy)" + : ` (${n})`; + filenames.push(`${baseStem}${suffix}${srcExt}`); + } + + // Bulk insert N documents in one round-trip. + const docRows = filenames.map((fn) => ({ + project_id: projectId, + user_id: userId, + status: "ready", + })); + const { data: insertedDocs, error: docErr } = await db + .from("documents") + .insert(docRows) + .select("id"); + if (docErr || !insertedDocs || insertedDocs.length === 0) { + fail( + `Failed to record replicated documents: ${docErr?.message ?? "unknown"}`, + ); + } else { + // Preserve the request order so each row pairs + // with the right filename. Supabase returns + // inserted rows in the same order as the + // payload. + const newDocs = (insertedDocs as { id: string }[]).map( + (doc, idx) => ({ + ...doc, + filename: filenames[idx] ?? "Untitled document.docx", + }), + ); + const contentType = + sourceInfo.file_type === "pdf" + ? "application/pdf" + : "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + + // Parallel uploads: the doc bytes (and PDF + // rendition if any) for every new copy. + const uploadJobs: Promise<unknown>[] = []; + const newKeys: string[] = []; + const newPdfKeys: (string | null)[] = []; + for (const d of newDocs) { + const key = storageKey(userId, d.id, d.filename); + newKeys.push(key); + uploadJobs.push(uploadFile(key, raw, contentType)); + if (pdfBytes) { + const pdfKey = convertedPdfKey(userId, d.id); + newPdfKeys.push(pdfKey); + uploadJobs.push( + uploadFile(pdfKey, pdfBytes, "application/pdf"), + ); + } else { + newPdfKeys.push(null); + } + } + await Promise.all(uploadJobs); + + // Bulk insert N versions in one round-trip. + const versionRows = newDocs.map((d, idx) => ({ + document_id: d.id, + storage_path: newKeys[idx], + pdf_storage_path: newPdfKeys[idx], + source: "upload", + version_number: 1, + filename: d.filename, + file_type: active?.file_type ?? sourceInfo.file_type, + size_bytes: active?.size_bytes ?? raw.byteLength, + page_count: active?.page_count ?? null, + })); + const { data: insertedVersions, error: verErr } = await db + .from("document_versions") + .insert(versionRows) + .select("id, document_id"); + if ( + verErr || + !insertedVersions || + insertedVersions.length !== newDocs.length + ) { + fail( + `Failed to record replicated document versions: ${verErr?.message ?? "unknown"}`, + ); + } else { + const versionByDocId = new Map<string, string>(); + for (const v of insertedVersions as { + id: string; + document_id: string; + }[]) { + versionByDocId.set(v.document_id, v.id); + } + + // current_version_id has to be a per-row + // value, so a single UPDATE statement + // can't cover all N. Fan out in parallel + // instead of sequential awaits. + await Promise.all( + newDocs.map((d) => + db + .from("documents") + .update({ + current_version_id: versionByDocId.get(d.id), + }) + .eq("id", d.id), + ), + ); + + // Register every copy under a fresh doc-N + // slug so the model can edit/read any of + // them in the same turn. + const existingLabels = new Set(Object.keys(docIndex)); + let nextLabelIdx = 0; + const copies: { + new_filename: string; + document_id: string; + version_id: string; + }[] = []; + const toolPayloadCopies: { + doc_id: string; + document_id: string; + version_id: string; + filename: string; + download_url: string; + }[] = []; + for (let idx = 0; idx < newDocs.length; idx++) { + const d = newDocs[idx]; + const newKey = newKeys[idx]; + const versionId = versionByDocId.get(d.id); + if (!versionId) continue; + while (existingLabels.has(`doc-${nextLabelIdx}`)) + nextLabelIdx++; + const slug = `doc-${nextLabelIdx}`; + existingLabels.add(slug); + docIndex[slug] = { + document_id: d.id, + filename: d.filename, + }; + docStore.set(slug, { + storage_path: newKey, + file_type: sourceInfo.file_type, + filename: d.filename, + }); + copies.push({ + new_filename: d.filename, + document_id: d.id, + version_id: versionId, + }); + toolPayloadCopies.push({ + doc_id: slug, + document_id: d.id, + version_id: versionId, + filename: d.filename, + download_url: buildDownloadUrl(newKey, d.filename), + }); + } + + write( + `data: ${JSON.stringify({ + type: "doc_replicated", + filename: sourceFilename, + count: copies.length, + copies, + })}\n\n`, + ); + docsReplicated.push({ + filename: sourceFilename, + count: copies.length, + copies, + }); + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: JSON.stringify({ + ok: true, + count: copies.length, + copies: toolPayloadCopies, + }), + }); + } + } + } + } catch (e) { + fail(`replicate_document failed: ${String(e)}`); + } + } + } else if (tc.function.name === "generate_docx") { + const title = args.title as string; + const landscape = !!args.landscape; + devLog( + `[generate_docx] title="${title}" landscape=${landscape} args.landscape=${args.landscape}`, + ); + const previewFilename = `${ + title + .replace(/[^a-zA-Z0-9 _-]/g, "") + .trim() + .slice(0, 64) || "document" + }.docx`; + write( + `data: ${JSON.stringify({ type: "doc_created_start", filename: previewFilename })}\n\n`, + ); + const result = await generateDocx( + title, + args.sections as unknown[], + userId, + db, + { landscape, projectId: projectId ?? null }, + ); + let newDocLabel: string | null = null; + if ("filename" in result && "download_url" in result) { + const dlFilename = result.filename as string; + const dlUrl = result.download_url as string; + const documentId = (result as { document_id?: string }).document_id; + const versionId = (result as { version_id?: string }).version_id; + const versionNumber = + (result as { version_number?: number }).version_number ?? null; + const storagePath = (result as { storage_path?: string }).storage_path; + + // Register the generated doc in the chat context so + // edit_document (and read_document / find_in_document) + // can act on it within the same assistant turn. New label + // is the next free `doc-N` index. Subsequent turns pick + // it up via the normal attachment/project doc query. + if (documentId && storagePath && docIndex) { + const existingLabels = new Set(Object.keys(docIndex)); + let i = 0; + while (existingLabels.has(`doc-${i}`)) i++; + newDocLabel = `doc-${i}`; + docIndex[newDocLabel] = { + document_id: documentId, + filename: dlFilename, + }; + docStore.set(newDocLabel, { + storage_path: storagePath, + file_type: "docx", + filename: dlFilename, + }); + } + + write( + `data: ${JSON.stringify({ + type: "doc_created", + filename: dlFilename, + download_url: dlUrl, + document_id: documentId, + version_id: versionId, + version_number: versionNumber, + })}\n\n`, + ); + docsCreated.push({ + filename: dlFilename, + download_url: dlUrl, + document_id: documentId, + version_id: versionId, + version_number: versionNumber, + }); + } else { + write( + `data: ${JSON.stringify({ type: "doc_created", filename: previewFilename, download_url: "" })}\n\n`, + ); + } + // Surface the chat-local doc label in the tool result so the + // model can pass it as `doc_id` to edit_document / read_document + // / find_in_document in the same turn. Without this the model + // only sees the DB UUID, which isn't valid as a doc_id anchor. + const { download_url, storage_path, ...safeToolResult } = + result as Record<string, unknown>; + const toolResultPayload = newDocLabel + ? { + ...safeToolResult, + doc_id: newDocLabel, + next_required_action: [ + `Before writing your final response, call read_document with doc_id "${newDocLabel}".`, + `Base your description on the generated document's actual returned text, not on memory of what you intended to generate.`, + `Do not include download links, URLs, or markdown links to the document in your prose response; the document card is shown automatically by the UI.`, + `Give a concise description of the generated document and, if you make factual claims about its contents, cite it with [N] markers and a final <CITATIONS> block using doc_id "${newDocLabel}", not any source/template document.`, + ].join(" "), + } + : safeToolResult; + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: JSON.stringify(toolResultPayload), + }); + } + } + + if (shouldGroupFindInCase && groupedFindInCaseEvents.length > 0) { + const errors = groupedFindInCaseEvents + .map((event) => event.error) + .filter((error): error is string => !!error); + const groupEvent: CourtlistenerToolEvent = { + type: "courtlistener_find_in_case", + cluster_id: null, + query: "", + total_matches: groupedFindInCaseEvents.reduce( + (sum, event) => sum + event.total_matches, + 0, + ), + searches: groupedFindInCaseEvents.map(findInCaseSearchSummary), + ...(errors.length ? { error: errors.join("; ") } : {}), }; + write(`data: ${JSON.stringify(groupEvent)}\n\n`); + courtlistenerEvents.push(groupEvent); + } + + return { + toolResults, + docsRead, + docsFound, + docsCreated, + docsReplicated, + workflowsApplied, + docsEdited, + courtlistenerEvents, + caseCitationEvents, + }; } // --------------------------------------------------------------------------- @@ -2632,19 +3627,110 @@ export async function runToolCalls( 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[] { - 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 []; + 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 []; + } +} + +function parsePartialCitationObjects(text: string): ParsedCitation[] { + const beforeClose = text.split(CITATIONS_CLOSE_TAG)[0] ?? text; + const arrayStart = beforeClose.indexOf("["); + if (arrayStart < 0) return []; + + const parsed: ParsedCitation[] = []; + let inString = false; + let escaped = false; + let depth = 0; + let objectStart = -1; + + for (let i = arrayStart + 1; i < beforeClose.length; i += 1) { + const char = beforeClose[i]; + + if (escaped) { + escaped = false; + continue; } + if (char === "\\") { + escaped = inString; + continue; + } + if (char === '"') { + inString = !inString; + continue; + } + if (inString) continue; + + if (char === "{") { + if (depth === 0) objectStart = i; + depth += 1; + } else if (char === "}") { + if (depth === 0) continue; + depth -= 1; + if (depth === 0 && objectStart >= 0) { + try { + const raw = JSON.parse(beforeClose.slice(objectStart, i + 1)); + const citation = normalizeCitation(raw); + if (citation) parsed.push(citation); + } catch { + /* ignore incomplete/malformed partial object */ + } + objectStart = -1; + } + } else if (char === "]" && depth === 0) { + break; + } + } + + return parsed; +} + +function createCitationAnnotation( + citation: ParsedCitation, + docIndex: DocIndex, + casesByClusterId?: CourtlistenerTurnState["casesByClusterId"], +) { + if (citation.kind === "case") { + const caseRecord = casesByClusterId?.get(citation.cluster_id); + return { + type: "citation_data", + kind: "case", + ref: citation.ref, + cluster_id: citation.cluster_id, + case_name: caseRecord?.caseName ?? null, + citation: caseRecord?.citations[0] ?? null, + url: caseRecord?.url ?? null, + pdfUrl: caseRecord?.pdfUrl ?? null, + dateFiled: caseRecord?.dateFiled ?? null, + judges: caseRecord?.judges ?? null, + quotes: citation.quotes, + }; + } + + const docInfo = resolveDoc(citation.doc_id, docIndex); + return { + type: "citation_data", + kind: "document", + ref: citation.ref, + doc_id: citation.doc_id, + document_id: docInfo?.document_id, + version_id: docInfo?.version_id ?? null, + version_number: docInfo?.version_number ?? null, + filename: docInfo?.filename ?? citation.doc_id, + page: citation.page, + quote: citation.quote, + quotes: citation.quotes, + }; } // --------------------------------------------------------------------------- @@ -2652,357 +3738,456 @@ function parseCitations(text: string): ParsedCitation[] { // --------------------------------------------------------------------------- export type EditAnnotation = { - kind: "edit"; - edit_id: string; - document_id: string; - version_id: string; - version_number?: number | null; - change_id: string; - del_w_id?: string; - ins_w_id?: string; - deleted_text: string; - inserted_text: string; - context_before: string; - context_after: string; - reason?: string; - status: "pending" | "accepted" | "rejected"; + kind: "edit"; + edit_id: string; + document_id: string; + version_id: string; + version_number?: number | null; + change_id: string; + del_w_id?: string; + ins_w_id?: string; + deleted_text: string; + inserted_text: string; + context_before: string; + context_after: string; + reason?: string; + status: "pending" | "accepted" | "rejected"; }; type AssistantEvent = - | { type: "reasoning"; text: string } - | { type: "doc_read"; filename: string; document_id?: string } - | { - type: "doc_find"; - filename: string; - query: string; - total_matches: number; - } - | { - type: "doc_created"; - filename: string; - download_url: string; - document_id?: string; - version_id?: string; - version_number?: number | null; - } - | { type: "doc_download"; filename: string; download_url: string } - | { - type: "doc_replicated"; - /** Source document being copied. */ - filename: string; - count: number; - copies: { - new_filename: string; - document_id: string; - version_id: string; - }[]; - } - | { type: "workflow_applied"; workflow_id: string; title: string } - | { - type: "doc_edited"; - filename: string; - document_id: string; - version_id: string; - /** Per-document monotonic Vn; null if backend couldn't determine it. */ - version_number: number | null; - download_url: string; - annotations: EditAnnotation[]; - } - | { type: "content"; text: string }; + | { type: "reasoning"; text: string } + | { type: "doc_read"; filename: string; document_id?: string } + | { + type: "doc_find"; + filename: string; + query: string; + total_matches: number; + } + | { + type: "doc_created"; + filename: string; + download_url: string; + document_id?: string; + version_id?: string; + version_number?: number | null; + } + | { type: "doc_download"; filename: string; download_url: string } + | { + type: "doc_replicated"; + /** Source document being copied. */ + filename: string; + count: number; + copies: { + new_filename: string; + document_id: string; + version_id: string; + }[]; + } + | { type: "workflow_applied"; workflow_id: string; title: string } + | { + type: "doc_edited"; + filename: string; + document_id: string; + version_id: string; + /** Per-document monotonic Vn; null if backend couldn't determine it. */ + version_number: number | null; + download_url: string; + annotations: EditAnnotation[]; + } + | CaseCitationEvent + | CourtlistenerToolEvent + | { type: "case_opinions"; cluster_id: number; case: unknown } + | { type: "content"; text: string } + | { type: "error"; message: string }; + +export class AssistantStreamError extends Error { + fullText: string; + events: AssistantEvent[]; + + constructor(message: string, fullText: string, events: AssistantEvent[]) { + super(message); + this.name = "AssistantStreamError"; + this.fullText = fullText; + this.events = events; + } +} + +export function isAbortError(error: unknown): boolean { + if (!error || typeof error !== "object") return false; + const record = error as { name?: unknown; message?: unknown }; + return ( + record.name === "AbortError" || record.message === "Stream aborted." + ); +} + +function throwIfAborted(signal?: AbortSignal) { + if (!signal?.aborted) return; + const err = new Error("Stream aborted."); + err.name = "AbortError"; + throw err; +} export async function runLLMStream(params: { - apiMessages: unknown[]; - docStore: DocStore; - docIndex: DocIndex; - userId: string; - db: ReturnType<typeof createServerSupabase>; - write: (s: string) => void; - extraTools?: unknown[]; - workflowStore?: WorkflowStore; - tabularStore?: TabularCellStore; - buildCitations?: (fullText: string) => unknown[]; - model?: string; - apiKeys?: import("./llm").UserApiKeys; - /** - * If set, generate_docx will attach created docs to this project so - * they appear in the project sidebar. Leave null for general chats — - * generated docs still get persisted, but as standalone documents. - */ - projectId?: string | null; -}): Promise<{ fullText: string; events: AssistantEvent[] }> { - const { - apiMessages, - docStore, + apiMessages: unknown[]; + docStore: DocStore; + docIndex: DocIndex; + userId: string; + db: ReturnType<typeof createServerSupabase>; + write: (s: string) => void; + extraTools?: unknown[]; + includeResearchTools?: boolean; + workflowStore?: WorkflowStore; + tabularStore?: TabularCellStore; + buildCitations?: (fullText: string) => unknown[]; + model?: string; + apiKeys?: import("./llm").UserApiKeys; + signal?: AbortSignal; + /** + * If set, generate_docx will attach created docs to this project so + * they appear in the project sidebar. Leave null for general chats — + * generated docs still get persisted, but as standalone documents. + */ + projectId?: string | null; +}): Promise<{ + fullText: string; + events: AssistantEvent[]; + annotations: unknown[]; +}> { + const { + apiMessages, + docStore, + docIndex, + userId, + db, + write, + extraTools, + includeResearchTools = true, + workflowStore, + tabularStore, + buildCitations, + model, + apiKeys, + signal, + projectId, + } = params; + const researchTools = includeResearchTools ? COURTLISTENER_TOOLS : []; + const baseTools = [...TOOLS, ...researchTools, ...WORKFLOW_TOOLS]; + const activeTools = extraTools?.length + ? [...baseTools, ...extraTools] + : baseTools; + + // Extract system prompt; pass remaining turns to the adapter as + // plain user/assistant messages. + const rawMsgs = apiMessages as { role: string; content: string | null }[]; + const systemPrompt = + rawMsgs[0]?.role === "system" ? (rawMsgs[0].content ?? "") : ""; + const chatMessages: LlmMessage[] = rawMsgs + .filter((m) => m.role !== "system") + .map((m) => ({ + role: m.role === "assistant" ? "assistant" : "user", + content: m.content ?? "", + })); + + const events: AssistantEvent[] = []; + // One assistant turn produces at most one document_versions row per + // edited doc. `runToolCalls` fires once per tool-call batch; the model + // may emit multiple batches in a single turn, so this map persists + // across batches to let subsequent edit_document calls overwrite the + // turn's existing version instead of creating a new one. + const turnEditState: TurnEditState = new Map(); + const courtlistenerTurnState: CourtlistenerTurnState = { + casesByClusterId: new Map(), + }; + let fullText = ""; + let iterText = ""; + let iterVisibleText = ""; + let iterReasoning = ""; + let visibleTailBuffer = ""; + let citationsOpenSeen = false; + let streamingCitationsBuffer = ""; + let streamedCitationCount = 0; + + const emitCitationStreamSnapshot = ( + status: "started" | "partial", + citations: unknown[], + ) => { + if (buildCitations) return; + write(`data: ${JSON.stringify({ type: "citations", status, citations })}\n\n`); + }; + + const streamHiddenCitationContent = (delta: string) => { + if (buildCitations || !delta) return; + streamingCitationsBuffer += delta; + const partial = parsePartialCitationObjects(streamingCitationsBuffer); + if (partial.length <= streamedCitationCount) return; + streamedCitationCount = partial.length; + const citations = partial.map((c) => + createCitationAnnotation( + c, docIndex, - userId, - db, - write, - extraTools, - workflowStore, - tabularStore, - buildCitations, - model, - apiKeys, - projectId, - } = params; - const activeTools = extraTools?.length - ? [...TOOLS, ...WORKFLOW_TOOLS, ...extraTools] - : [...TOOLS, ...WORKFLOW_TOOLS]; + courtlistenerTurnState.casesByClusterId, + ), + ); + emitCitationStreamSnapshot("partial", citations); + }; - // Extract system prompt; pass remaining turns to the adapter as - // plain user/assistant messages. - const rawMsgs = apiMessages as { role: string; content: string | null }[]; - const systemPrompt = - rawMsgs[0]?.role === "system" ? (rawMsgs[0].content ?? "") : ""; - const chatMessages: LlmMessage[] = rawMsgs - .filter((m) => m.role !== "system") - .map((m) => ({ - role: m.role === "assistant" ? "assistant" : "user", - content: m.content ?? "", - })); + const streamVisibleContent = (delta: string) => { + if (!delta) return; + if (citationsOpenSeen) { + streamHiddenCitationContent(delta); + return; + } - const events: AssistantEvent[] = []; - // One assistant turn produces at most one document_versions row per - // edited doc. `runToolCalls` fires once per tool-call batch; the model - // may emit multiple batches in a single turn, so this map persists - // across batches to let subsequent edit_document calls overwrite the - // turn's existing version instead of creating a new one. - const turnEditState: TurnEditState = new Map(); - let fullText = ""; - let iterText = ""; - let iterVisibleText = ""; - let iterReasoning = ""; - let visibleTailBuffer = ""; - let citationsOpenSeen = false; - - const streamVisibleContent = (delta: string) => { - if (!delta) return; - if (citationsOpenSeen) return; - - const combined = visibleTailBuffer + delta; - const markerIdx = combined.indexOf(CITATIONS_OPEN_TAG); - if (markerIdx >= 0) { - const visible = combined.slice(0, markerIdx); - if (visible) { - iterVisibleText += visible; - write( - `data: ${JSON.stringify({ type: "content_delta", text: visible })}\n\n`, - ); - } - visibleTailBuffer = ""; - citationsOpenSeen = true; - return; - } - - const keep = Math.min(CITATIONS_OPEN_TAG.length - 1, combined.length); - const visible = combined.slice(0, combined.length - keep); - visibleTailBuffer = combined.slice(combined.length - keep); - if (visible) { - iterVisibleText += visible; - write( - `data: ${JSON.stringify({ type: "content_delta", text: visible })}\n\n`, - ); - } - }; - - const flushVisibleTail = () => { - if (citationsOpenSeen || !visibleTailBuffer) { - visibleTailBuffer = ""; - return; - } - iterVisibleText += visibleTailBuffer; + const combined = visibleTailBuffer + delta; + const markerIdx = combined.indexOf(CITATIONS_OPEN_TAG); + if (markerIdx >= 0) { + const visible = combined.slice(0, markerIdx); + if (visible) { + iterVisibleText += visible; write( - `data: ${JSON.stringify({ type: "content_delta", text: visibleTailBuffer })}\n\n`, + `data: ${JSON.stringify({ type: "content_delta", text: visible })}\n\n`, ); - visibleTailBuffer = ""; - }; + } + visibleTailBuffer = ""; + citationsOpenSeen = true; + streamingCitationsBuffer = ""; + streamedCitationCount = 0; + emitCitationStreamSnapshot("started", []); + streamHiddenCitationContent( + combined.slice(markerIdx + CITATIONS_OPEN_TAG.length), + ); + return; + } - const flushText = () => { - if (!iterText) return; - fullText += iterText; - flushVisibleTail(); - if (iterVisibleText) { - events.push({ type: "content", text: iterVisibleText }); - } - iterText = ""; - iterVisibleText = ""; - visibleTailBuffer = ""; - citationsOpenSeen = false; - }; + const keep = Math.min(CITATIONS_OPEN_TAG.length - 1, combined.length); + const visible = combined.slice(0, combined.length - keep); + visibleTailBuffer = combined.slice(combined.length - keep); + if (visible) { + iterVisibleText += visible; + write( + `data: ${JSON.stringify({ type: "content_delta", text: visible })}\n\n`, + ); + } + }; - const selectedModel = resolveModel(model, DEFAULT_MAIN_MODEL); + const flushVisibleTail = () => { + if (citationsOpenSeen || !visibleTailBuffer) { + visibleTailBuffer = ""; + return; + } + iterVisibleText += visibleTailBuffer; + write( + `data: ${JSON.stringify({ type: "content_delta", text: visibleTailBuffer })}\n\n`, + ); + visibleTailBuffer = ""; + }; + const flushText = () => { + if (!iterText) return; + fullText += iterText; + flushVisibleTail(); + if (iterVisibleText) { + events.push({ type: "content", text: iterVisibleText }); + } + iterText = ""; + iterVisibleText = ""; + visibleTailBuffer = ""; + citationsOpenSeen = false; + streamingCitationsBuffer = ""; + streamedCitationCount = 0; + }; + + const selectedModel = resolveModel(model, DEFAULT_MAIN_MODEL); + + try { + throwIfAborted(signal); await streamChatWithTools({ - model: selectedModel, - systemPrompt, - messages: chatMessages, - tools: activeTools as OpenAIToolSchema[], - maxIterations: 10, + model: selectedModel, + systemPrompt, + messages: chatMessages, + tools: activeTools as OpenAIToolSchema[], + maxIterations: 10, + apiKeys, + enableThinking: true, + abortSignal: signal, + callbacks: { + onContentDelta: (delta) => { + iterText += delta; + streamVisibleContent(delta); + }, + onReasoningDelta: (delta) => { + iterReasoning += delta; + write( + `data: ${JSON.stringify({ type: "reasoning_delta", text: delta })}\n\n`, + ); + }, + onReasoningBlockEnd: () => { + if (!iterReasoning) return; + events.push({ type: "reasoning", text: iterReasoning }); + write(`data: ${JSON.stringify({ type: "reasoning_block_end" })}\n\n`); + iterReasoning = ""; + }, + // Fires after Claude's turn ends with stop_reason=tool_use, before + // the tool actually runs. Flushes any buffered assistant text so + // it's emitted in chronological order, then signals the client so + // it can open a fresh PreResponseWrapper (shows "Working…") while + // the tool executes — avoids the dead gap between message_stop + // and the first tool-specific event. + onToolCallStart: (call) => { + flushText(); + write( + `data: ${JSON.stringify({ + type: "tool_call_start", + name: call.name, + })}\n\n`, + ); + }, + }, + runTools: async (calls) => { + throwIfAborted(signal); + // Emit any text the model produced before this tool turn so the + // UI sees it before the tool results stream in. + flushText(); + + const toolCalls: ToolCall[] = calls.map((c) => ({ + id: c.id, + function: { + name: c.name, + arguments: JSON.stringify(c.input), + }, + })); + const { + toolResults, + docsRead, + docsFound, + docsCreated, + docsReplicated, + workflowsApplied, + docsEdited, + courtlistenerEvents, + caseCitationEvents, + } = await runToolCalls( + toolCalls, + docStore, + userId, + db, + write, + workflowStore, + tabularStore, + docIndex, + turnEditState, + projectId, + courtlistenerTurnState, apiKeys, - enableThinking: true, - callbacks: { - onContentDelta: (delta) => { - iterText += delta; - streamVisibleContent(delta); - }, - onReasoningDelta: (delta) => { - iterReasoning += delta; - write( - `data: ${JSON.stringify({ type: "reasoning_delta", text: delta })}\n\n`, - ); - }, - onReasoningBlockEnd: () => { - if (!iterReasoning) return; - events.push({ type: "reasoning", text: iterReasoning }); - write( - `data: ${JSON.stringify({ type: "reasoning_block_end" })}\n\n`, - ); - iterReasoning = ""; - }, - // Fires after Claude's turn ends with stop_reason=tool_use, before - // the tool actually runs. Flushes any buffered assistant text so - // it's emitted in chronological order, then signals the client so - // it can open a fresh PreResponseWrapper (shows "Working…") while - // the tool executes — avoids the dead gap between message_stop - // and the first tool-specific event. - onToolCallStart: (call) => { - flushText(); - write( - `data: ${JSON.stringify({ - type: "tool_call_start", - name: call.name, - })}\n\n`, - ); - }, - }, - runTools: async (calls) => { - // Emit any text the model produced before this tool turn so the - // UI sees it before the tool results stream in. - flushText(); - - const toolCalls: ToolCall[] = calls.map((c) => ({ - id: c.id, - function: { - name: c.name, - arguments: JSON.stringify(c.input), - }, - })); - const { - toolResults, - docsRead, - docsFound, - docsCreated, - docsReplicated, - workflowsApplied, - docsEdited, - } = await runToolCalls( - toolCalls, - docStore, - userId, - db, - write, - workflowStore, - tabularStore, - docIndex, - turnEditState, - projectId, - ); - for (const r of docsRead) { - events.push({ - type: "doc_read", - filename: r.filename, - document_id: r.document_id, - }); - } - for (const f of docsFound) { - events.push({ - type: "doc_find", - filename: f.filename, - query: f.query, - total_matches: f.total_matches, - }); - } - for (const dl of docsCreated) { - events.push({ - type: "doc_created", - filename: dl.filename, - download_url: dl.download_url, - document_id: dl.document_id, - version_id: dl.version_id, - version_number: dl.version_number ?? null, - }); - } - for (const r of docsReplicated) { - events.push({ - type: "doc_replicated", - filename: r.filename, - count: r.count, - copies: r.copies, - }); - } - for (const wf of workflowsApplied) { - events.push({ - type: "workflow_applied", - workflow_id: wf.workflow_id, - title: wf.title, - }); - } - for (const e of docsEdited) { - events.push({ - type: "doc_edited", - filename: e.filename, - document_id: e.document_id, - version_id: e.version_id, - version_number: e.version_number, - download_url: e.download_url, - annotations: e.annotations, - }); - } - - // Index alignment would break if any tool branch skips its - // push (unhandled tool name, disabled store, guard failure). - // Each tool_result already carries its tool_call_id, so key off - // that directly — and fall back to an error result for any - // tool_use that didn't produce one, so Claude's next request - // has a tool_result for every tool_use it sent. - const resultByCallId = new Map<string, string>(); - for (const r of toolResults) { - const row = r as { tool_call_id: string; content?: unknown }; - resultByCallId.set(row.tool_call_id, String(row.content ?? "")); - } - return toolCalls.map((c) => ({ - tool_use_id: c.id, - content: - resultByCallId.get(c.id) ?? - JSON.stringify({ - error: `Tool '${c.function.name}' is not available.`, - }), - })); - }, - }); - - flushText(); - - // Parse and emit citations from <CITATIONS> block - const citations = buildCitations - ? buildCitations(fullText) - : parseCitations(fullText).map((c) => { - const docInfo = resolveDoc(c.doc_id, docIndex); - return { - ref: c.ref, - doc_id: c.doc_id, - document_id: docInfo?.document_id, - version_id: docInfo?.version_id ?? null, - version_number: docInfo?.version_number ?? null, - filename: docInfo?.filename ?? c.doc_id, - page: c.page, - quote: c.quote, - }; + ); + throwIfAborted(signal); + for (const r of docsRead) { + events.push({ + type: "doc_read", + filename: r.filename, + document_id: r.document_id, }); - write(`data: ${JSON.stringify({ type: "citations", citations })}\n\n`); - write("data: [DONE]\n\n"); + } + for (const f of docsFound) { + events.push({ + type: "doc_find", + filename: f.filename, + query: f.query, + total_matches: f.total_matches, + }); + } + for (const dl of docsCreated) { + events.push({ + type: "doc_created", + filename: dl.filename, + download_url: dl.download_url, + document_id: dl.document_id, + version_id: dl.version_id, + version_number: dl.version_number ?? null, + }); + } + for (const r of docsReplicated) { + events.push({ + type: "doc_replicated", + filename: r.filename, + count: r.count, + copies: r.copies, + }); + } + for (const wf of workflowsApplied) { + events.push({ + type: "workflow_applied", + workflow_id: wf.workflow_id, + title: wf.title, + }); + } + for (const e of docsEdited) { + events.push({ + type: "doc_edited", + filename: e.filename, + document_id: e.document_id, + version_id: e.version_id, + version_number: e.version_number, + download_url: e.download_url, + annotations: e.annotations, + }); + } + for (const event of courtlistenerEvents) { + events.push(event); + } + for (const event of caseCitationEvents) { + events.push(event); + } - return { fullText, events }; + // Index alignment would break if any tool branch skips its + // push (unhandled tool name, disabled store, guard failure). + // Each tool_result already carries its tool_call_id, so key off + // that directly — and fall back to an error result for any + // tool_use that didn't produce one, so Claude's next request + // has a tool_result for every tool_use it sent. + const resultByCallId = new Map<string, string>(); + for (const r of toolResults) { + const row = r as { tool_call_id: string; content?: unknown }; + resultByCallId.set(row.tool_call_id, String(row.content ?? "")); + } + return toolCalls.map((c) => ({ + tool_use_id: c.id, + content: + resultByCallId.get(c.id) ?? + JSON.stringify({ + error: `Tool '${c.function.name}' is not available.`, + }), + })); + }, + }); + } catch (err) { + if (isAbortError(err)) throw err; + flushText(); + const message = + err instanceof Error && err.message ? err.message : "Stream error"; + events.push({ type: "error", message }); + throw new AssistantStreamError(message, fullText, events); + } + + flushText(); + + // Parse and emit citations from <CITATIONS> block + const parsedCitations = parseCitations(fullText); + const citations = buildCitations + ? buildCitations(fullText) + : parsedCitations.map((c) => + createCitationAnnotation( + c, + docIndex, + courtlistenerTurnState.casesByClusterId, + ), + ); + write( + `data: ${JSON.stringify({ type: "citations", status: "final", citations })}\n\n`, + ); + write("data: [DONE]\n\n"); + + return { fullText, events, annotations: citations }; } // --------------------------------------------------------------------------- @@ -3010,36 +4195,17 @@ export async function runLLMStream(params: { // --------------------------------------------------------------------------- export function extractAnnotations( - fullText: string, - docIndex: DocIndex, - events?: ({ type: string } & Record<string, unknown>[]) | unknown[], + fullText: string, + docIndex: DocIndex, + _events?: ({ type: string } & Record<string, unknown>[]) | unknown[], ): unknown[] { - const out: unknown[] = parseCitations(fullText).map((c) => { - const docInfo = resolveDoc(c.doc_id, docIndex); - return { - type: "citation_data", - ref: c.ref, - doc_id: c.doc_id, - document_id: docInfo?.document_id, - version_id: docInfo?.version_id ?? null, - version_number: docInfo?.version_number ?? null, - filename: docInfo?.filename ?? c.doc_id, - page: c.page, - quote: c.quote, - }; - }); - if (Array.isArray(events)) { - for (const ev of events as { - type?: string; - annotations?: EditAnnotation[]; - }[]) { - if (ev?.type === "doc_edited" && Array.isArray(ev.annotations)) { - for (const a of ev.annotations) - out.push({ ...a, type: "edit_data" }); - } - } - } - return out; + return parseCitations(fullText).map((c) => + createCitationAnnotation(c, docIndex), + ); +} + +export function stripTransientAssistantEvents(events: AssistantEvent[]) { + return events.filter((event) => event.type !== "case_opinions"); } // --------------------------------------------------------------------------- @@ -3047,238 +4213,238 @@ export function extractAnnotations( // --------------------------------------------------------------------------- export async function buildDocContext( - messages: ChatMessage[], - userId: string, - db: ReturnType<typeof createServerSupabase>, - chatId?: string | null, + messages: ChatMessage[], + userId: string, + db: ReturnType<typeof createServerSupabase>, + chatId?: string | null, ): Promise<{ docIndex: DocIndex; docStore: DocStore }> { - const docIndex: DocIndex = {}; - const docStore: DocStore = new Map(); + const docIndex: DocIndex = {}; + const docStore: DocStore = new Map(); - const documentIds = new Set<string>(); - for (const m of messages) { - for (const f of m.files ?? []) { - if (f.document_id) documentIds.add(f.document_id); - } + const documentIds = new Set<string>(); + for (const m of messages) { + for (const f of m.files ?? []) { + if (f.document_id) documentIds.add(f.document_id); } + } - // Also pull in document_ids from prior assistant events in this chat — - // generated docs (generate_docx) and tracked-change edits (edit_document) - // aren't attached to user messages as files, so they only live in the - // assistant's `doc_created` / `doc_edited` events. Without this sweep - // the model loses access to generated docs after the turn that created - // them, and can't call edit_document / read_document on them. - if (chatId) { - const { data: rows } = await db - .from("chat_messages") - .select("content") - .eq("chat_id", chatId) - .eq("role", "assistant"); - for (const row of rows ?? []) { - const content = (row as { content?: unknown }).content; - if (!Array.isArray(content)) continue; - for (const ev of content as Record<string, unknown>[]) { - if ( - (ev?.type === "doc_created" || ev?.type === "doc_edited") && - typeof ev.document_id === "string" - ) { - documentIds.add(ev.document_id); - } - } + // Also pull in document_ids from prior assistant events in this chat — + // generated docs (generate_docx) and tracked-change edits (edit_document) + // aren't attached to user messages as files, so they only live in the + // assistant's `doc_created` / `doc_edited` events. Without this sweep + // the model loses access to generated docs after the turn that created + // them, and can't call edit_document / read_document on them. + if (chatId) { + const { data: rows } = await db + .from("chat_messages") + .select("content") + .eq("chat_id", chatId) + .eq("role", "assistant"); + for (const row of rows ?? []) { + const content = (row as { content?: unknown }).content; + if (!Array.isArray(content)) continue; + for (const ev of content as Record<string, unknown>[]) { + if ( + (ev?.type === "doc_created" || ev?.type === "doc_edited") && + typeof ev.document_id === "string" + ) { + documentIds.add(ev.document_id); } + } } + } - const ids = [...documentIds]; - if (ids.length > 0) { - const { data: docs } = await db - .from("documents") - .select("id, filename, file_type, current_version_id, status") - .in("id", ids) - .eq("user_id", userId) - .eq("status", "ready"); + const ids = [...documentIds]; + if (ids.length > 0) { + const { data: docs } = await db + .from("documents") + .select("id, current_version_id, status") + .in("id", ids) + .eq("user_id", userId) + .eq("status", "ready"); - const docList = (docs ?? []) as unknown as { - id: string; - filename: string; - file_type: string; - current_version_id?: string | null; - active_version_number?: number | null; - storage_path?: string | null; - }[]; - await attachActiveVersionPaths(db, docList); - for (let i = 0; i < docList.length; i++) { - const doc = docList[i]; - if (!doc.storage_path) continue; - const docLabel = `doc-${i}`; - docIndex[docLabel] = { - document_id: doc.id, - filename: doc.filename, - version_id: doc.current_version_id ?? null, - version_number: doc.active_version_number ?? null, - }; - docStore.set(docLabel, { - storage_path: doc.storage_path, - file_type: doc.file_type, - filename: doc.filename, - }); - } + const docList = (docs ?? []) as unknown as { + id: string; + filename?: string | null; + file_type?: string | null; + current_version_id?: string | null; + active_version_number?: number | null; + storage_path?: string | null; + }[]; + await attachActiveVersionPaths(db, docList); + for (let i = 0; i < docList.length; i++) { + const doc = docList[i]; + if (!doc.storage_path) continue; + const docLabel = `doc-${i}`; + const filename = doc.filename?.trim() || "Untitled document"; + docIndex[docLabel] = { + document_id: doc.id, + filename, + version_id: doc.current_version_id ?? null, + version_number: doc.active_version_number ?? null, + }; + docStore.set(docLabel, { + storage_path: doc.storage_path, + file_type: doc.file_type ?? "", + filename, + }); } + } - console.log( - "[buildDocContext] available docs:", - Object.entries(docIndex).map(([label, info]) => ({ - label, - filename: info.filename, - document_id: info.document_id, - })), - ); - return { docIndex, docStore }; + devLog( + "[buildDocContext] available docs:", + Object.entries(docIndex).map(([label, info]) => ({ + label, + filename: info.filename, + document_id: info.document_id, + })), + ); + return { docIndex, docStore }; } export async function buildProjectDocContext( - projectId: string, - _userId: string, - db: ReturnType<typeof createServerSupabase>, + projectId: string, + _userId: string, + db: ReturnType<typeof createServerSupabase>, ): Promise<{ - docIndex: DocIndex; - docStore: DocStore; - folderPaths: Map<string, string>; + docIndex: DocIndex; + docStore: DocStore; + folderPaths: Map<string, string>; }> { - const docIndex: DocIndex = {}; - const docStore: DocStore = new Map(); + const docIndex: DocIndex = {}; + const docStore: DocStore = new Map(); - const [{ data: docs }, { data: folders }] = await Promise.all([ - db - .from("documents") - .select( - "id, filename, file_type, current_version_id, status, folder_id", - ) - .eq("project_id", projectId) - .eq("status", "ready") - .order("created_at", { ascending: true }), - db - .from("project_subfolders") - .select("id, name, parent_folder_id") - .eq("project_id", projectId), - ]); - const docList = (docs ?? []) as unknown as { - id: string; - filename: string; - file_type: string; - current_version_id?: string | null; - active_version_number?: number | null; - folder_id?: string | null; - storage_path?: string | null; - }[]; - await attachActiveVersionPaths(db, docList); + const [{ data: docs }, { data: folders }] = await Promise.all([ + db + .from("documents") + .select("id, current_version_id, status, folder_id") + .eq("project_id", projectId) + .eq("status", "ready") + .order("created_at", { ascending: true }), + db + .from("project_subfolders") + .select("id, name, parent_folder_id") + .eq("project_id", projectId), + ]); + const docList = (docs ?? []) as unknown as { + id: string; + filename?: string | null; + file_type?: string | null; + current_version_id?: string | null; + active_version_number?: number | null; + folder_id?: string | null; + storage_path?: string | null; + }[]; + await attachActiveVersionPaths(db, docList); - // Build folder id → full path map - const folderMap = new Map< - string, - { name: string; parent_folder_id: string | null } - >(); - for (const f of folders ?? []) - folderMap.set(f.id, { - name: f.name, - parent_folder_id: f.parent_folder_id, - }); + // Build folder id → full path map + const folderMap = new Map< + string, + { name: string; parent_folder_id: string | null } + >(); + for (const f of folders ?? []) + folderMap.set(f.id, { + name: f.name, + parent_folder_id: f.parent_folder_id, + }); - function resolvePath(folderId: string | null): string { - if (!folderId) return ""; - const parts: string[] = []; - let cur: string | null = folderId; - while (cur) { - const f = folderMap.get(cur); - if (!f) break; - parts.unshift(f.name); - cur = f.parent_folder_id; - } - return parts.join(" / "); + function resolvePath(folderId: string | null): string { + if (!folderId) return ""; + const parts: string[] = []; + let cur: string | null = folderId; + while (cur) { + const f = folderMap.get(cur); + if (!f) break; + parts.unshift(f.name); + cur = f.parent_folder_id; } + return parts.join(" / "); + } - const folderPaths = new Map<string, string>(); // doc label → folder path + const folderPaths = new Map<string, string>(); // doc label → folder path - for (let i = 0; i < docList.length; i++) { - const doc = docList[i]; - if (!doc.storage_path) continue; - const docLabel = `doc-${i}`; - docIndex[docLabel] = { - document_id: doc.id, - filename: doc.filename, - version_id: doc.current_version_id ?? null, - version_number: doc.active_version_number ?? null, - }; - docStore.set(docLabel, { - storage_path: doc.storage_path, - file_type: doc.file_type, - filename: doc.filename, - }); - const path = resolvePath(doc.folder_id ?? null); - if (path) folderPaths.set(docLabel, path); - } + for (let i = 0; i < docList.length; i++) { + const doc = docList[i]; + if (!doc.storage_path) continue; + const docLabel = `doc-${i}`; + const filename = doc.filename?.trim() || "Untitled document"; + docIndex[docLabel] = { + document_id: doc.id, + filename, + version_id: doc.current_version_id ?? null, + version_number: doc.active_version_number ?? null, + }; + docStore.set(docLabel, { + storage_path: doc.storage_path, + file_type: doc.file_type ?? "", + filename, + }); + const path = resolvePath(doc.folder_id ?? null); + if (path) folderPaths.set(docLabel, path); + } - console.log( - "[buildProjectDocContext] available docs:", - Object.entries(docIndex).map(([label, info]) => ({ - label, - filename: info.filename, - document_id: info.document_id, - folder: folderPaths.get(label) ?? null, - })), - ); - return { docIndex, docStore, folderPaths }; + devLog( + "[buildProjectDocContext] available docs:", + Object.entries(docIndex).map(([label, info]) => ({ + label, + filename: info.filename, + document_id: info.document_id, + folder: folderPaths.get(label) ?? null, + })), + ); + return { docIndex, docStore, folderPaths }; } export async function buildWorkflowStore( - userId: string, - userEmail: string | null | undefined, - db: ReturnType<typeof createServerSupabase>, + userId: string, + userEmail: string | null | undefined, + db: ReturnType<typeof createServerSupabase>, ): Promise<WorkflowStore> { - const { BUILTIN_WORKFLOWS } = await import("./builtinWorkflows"); - const store: WorkflowStore = new Map(); - const normalizedUserEmail = (userEmail ?? "").trim().toLowerCase(); + const { BUILTIN_WORKFLOWS } = await import("./builtinWorkflows"); + const store: WorkflowStore = new Map(); + const normalizedUserEmail = (userEmail ?? "").trim().toLowerCase(); - // Seed built-ins first - for (const wf of BUILTIN_WORKFLOWS) { - store.set(wf.id, { title: wf.title, prompt_md: wf.prompt_md }); + // Seed built-ins first + for (const wf of BUILTIN_WORKFLOWS) { + store.set(wf.id, { title: wf.title, prompt_md: wf.prompt_md }); + } + + // Then overlay user-owned assistant workflows. + const { data: workflows } = await db + .from("workflows") + .select("id, title, prompt_md") + .eq("user_id", userId) + .eq("type", "assistant"); + for (const wf of workflows ?? []) { + if (wf.prompt_md) { + store.set(wf.id, { title: wf.title, prompt_md: wf.prompt_md }); } + } - // Then overlay user-owned assistant workflows. - const { data: workflows } = await db + // Shared assistant workflows must also be readable by workflow tools. + if (normalizedUserEmail) { + const { data: shares } = await db + .from("workflow_shares") + .select("workflow_id") + .eq("shared_with_email", normalizedUserEmail); + const sharedIds = [ + ...new Set((shares ?? []).map((share) => share.workflow_id)), + ]; + if (sharedIds.length > 0) { + const { data: sharedWorkflows } = await db .from("workflows") .select("id, title, prompt_md") - .eq("user_id", userId) + .in("id", sharedIds) .eq("type", "assistant"); - for (const wf of workflows ?? []) { + for (const wf of sharedWorkflows ?? []) { if (wf.prompt_md) { - store.set(wf.id, { title: wf.title, prompt_md: wf.prompt_md }); + store.set(wf.id, { + title: wf.title, + prompt_md: wf.prompt_md, + }); } + } } - - // Shared assistant workflows must also be readable by workflow tools. - if (normalizedUserEmail) { - const { data: shares } = await db - .from("workflow_shares") - .select("workflow_id") - .eq("shared_with_email", normalizedUserEmail); - const sharedIds = [ - ...new Set((shares ?? []).map((share) => share.workflow_id)), - ]; - if (sharedIds.length > 0) { - const { data: sharedWorkflows } = await db - .from("workflows") - .select("id, title, prompt_md") - .in("id", sharedIds) - .eq("type", "assistant"); - for (const wf of sharedWorkflows ?? []) { - if (wf.prompt_md) { - store.set(wf.id, { - title: wf.title, - prompt_md: wf.prompt_md, - }); - } - } - } - } - return store; + } + return store; } diff --git a/backend/src/lib/courtlistener.ts b/backend/src/lib/courtlistener.ts new file mode 100644 index 0000000..82a07bd --- /dev/null +++ b/backend/src/lib/courtlistener.ts @@ -0,0 +1,1008 @@ +import fs from "fs/promises"; +import path from "path"; +import { downloadFile, listFiles } from "./storage"; +import { createServerSupabase } from "./supabase"; + +const COURTLISTENER_BASE = "https://www.courtlistener.com/api/rest/v4"; +const COURTLISTENER_WEB_BASE = "https://www.courtlistener.com"; +const COURTLISTENER_STORAGE_BASE = "https://storage.courtlistener.com"; +const COURTLISTENER_R2_OPINIONS_PREFIX = "courtlistener/opinions/by-cluster"; + +type JsonRecord = Record<string, unknown>; +type ServerSupabase = ReturnType<typeof createServerSupabase>; +const isDev = process.env.NODE_ENV !== "production"; +const devLog = (...args: Parameters<typeof console.log>) => { + if (isDev) console.log(...args); +}; + +function courtlistenerBulkDataEnabled() { + return process.env.COURTLISTENER_BULK_DATA_ENABLED === "true"; +} + +async function logRawOpinionPayload(opinionId: number, opinion: JsonRecord) { + if (process.env.NODE_ENV === "production") return; + const logsDir = path.resolve( + process.cwd(), + "logs", + "courtlistener-opinions", + ); + await fs.mkdir(logsDir, { recursive: true }); + await fs.writeFile( + path.join(logsDir, `courtlistener-opinion-${opinionId}.json`), + JSON.stringify(opinion, null, 2), + ); +} + +function courtlistenerHeaders(apiToken?: string | null): HeadersInit { + const token = + apiToken?.trim() || process.env.COURTLISTENER_API_TOKEN?.trim(); + if (!token) { + throw new Error( + "COURTLISTENER_API_TOKEN must be set to use CourtListener tools.", + ); + } + return { + Accept: "application/json", + Authorization: `Token ${token}`, + }; +} + +function parseCourtlistenerError(status: number, detail: string): string { + const trimmed = detail.trim(); + if (!trimmed) return `CourtListener error (${status})`; + let message = trimmed; + try { + const parsed = JSON.parse(trimmed) as unknown; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + const record = parsed as Record<string, unknown>; + message = + typeof record.detail === "string" && record.detail.trim() + ? record.detail.trim() + : typeof record.message === "string" && record.message.trim() + ? record.message.trim() + : trimmed; + } + } catch { + // Non-JSON response bodies are displayed as-is. + } + + if (status === 429) { + const wait = message.match(/available in\s+(\d+)\s+seconds?/i)?.[1]; + return wait + ? `CourtListener rate limit exceeded. Try again in ${wait} seconds.` + : `CourtListener rate limit exceeded. ${message}`; + } + return `CourtListener error (${status}): ${message}`; +} + +async function courtlistenerFetch<T>( + pathOrUrl: string, + init?: RequestInit, + apiToken?: string | null, +): Promise<T> { + const url = pathOrUrl.startsWith("http") + ? pathOrUrl + : `${COURTLISTENER_BASE}${pathOrUrl}`; + devLog("[courtlistener/api] request", { + method: init?.method ?? "GET", + path: pathOrUrl, + url, + }); + const response = await fetch(url, { + ...init, + headers: { + ...courtlistenerHeaders(apiToken), + ...(init?.headers ?? {}), + }, + }); + devLog("[courtlistener/api] response", { + method: init?.method ?? "GET", + path: pathOrUrl, + status: response.status, + }); + if (!response.ok) { + const detail = await response.text().catch(() => ""); + throw new Error(parseCourtlistenerError(response.status, detail)); + } + return response.json() as Promise<T>; +} + +function asString(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value : null; +} + +function asNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function absoluteWebUrl(path: unknown): string | null { + const value = asString(path); + if (!value) return null; + return value.startsWith("http") + ? value + : `${COURTLISTENER_WEB_BASE}${value}`; +} + +function absoluteStorageUrl(path: unknown): string | null { + const value = asString(path); + if (!value) return null; + if (value.startsWith("http")) return value; + return `${COURTLISTENER_STORAGE_BASE}/${value.replace(/^\/+/, "")}`; +} + +function citationLabel(citation: unknown): string | null { + if (typeof citation === "string") return citation; + if (!citation || typeof citation !== "object") return null; + const c = citation as JsonRecord; + const volume = asString(c.volume) ?? String(c.volume ?? "").trim(); + const reporter = asString(c.reporter); + const page = asString(c.page) ?? String(c.page ?? "").trim(); + return [volume, reporter, page].filter(Boolean).join(" ") || null; +} + +function compactCluster(raw: unknown) { + if (!raw || typeof raw !== "object") { + return { + id: null, + caseName: null, + dateFiled: null, + judges: null, + court: null, + citations: [], + url: null, + subOpinions: [], + }; + } + const cluster = raw as JsonRecord; + return { + id: asNumber(cluster.id), + caseName: + asString(cluster.case_name) ?? + asString(cluster.caseName) ?? + asString(cluster.name), + dateFiled: asString(cluster.date_filed) ?? asString(cluster.dateFiled), + judges: asString(cluster.judges), + court: + asString((cluster.docket as JsonRecord | undefined)?.court_id) ?? + asString(cluster.court) ?? + null, + citations: Array.isArray(cluster.citations) + ? cluster.citations.map(citationLabel).filter(Boolean) + : [], + url: absoluteWebUrl(cluster.absolute_url), + pdfUrl: + absoluteStorageUrl(cluster.filepath_pdf_harvard) ?? + absoluteStorageUrl(cluster.filepath_pdf_scan), + subOpinions: Array.isArray(cluster.sub_opinions) + ? cluster.sub_opinions + : [], + }; +} + +function compactOpinion(opinion: JsonRecord, maxChars: number) { + const rawHtml = + asString(opinion.html_with_citations) ?? + asString(opinion.html) ?? + asString(opinion.xml_harvard) ?? + null; + const rawText = asString(opinion.plain_text) ?? rawHtml ?? null; + const text = stripOpinionMarkup(rawText); + const html = sanitizeOpinionHtml(rawHtml); + return { + opinionId: asNumber(opinion.id), + type: asString(opinion.type), + author: + asString(opinion.author_str) ?? + asString((opinion.author as JsonRecord | undefined)?.name), + per_curiam: asString(opinion.per_curiam), + joined_by_str: asString(opinion.joined_by_str), + url: absoluteWebUrl(opinion.absolute_url), + text: truncate(text, maxChars), + html: truncate(html, maxChars), + }; +} + +async function fetchCaseOpinionsFromCourtlistenerOpinionsEndpoint(args: { + clusterId: number; + maxChars: number; + includeFullText?: boolean; + apiToken?: string | null; +}) { + const opinions: ReturnType<typeof compactOpinion>[] = []; + const rawOpinions: JsonRecord[] = []; + let nextUrl: string | null = `/opinions/?cluster=${args.clusterId}`; + + while (nextUrl) { + devLog("[courtlistener/opinions-endpoint] fetching page", { + clusterId: args.clusterId, + path: nextUrl, + }); + const data = await courtlistenerFetch<JsonRecord>( + nextUrl, + undefined, + args.apiToken, + ); + const results = Array.isArray(data.results) ? data.results : []; + const opinionMaxChars = args.includeFullText + ? Math.max( + 500, + Math.floor(args.maxChars / Math.max(1, results.length)), + ) + : 3000; + const pageOpinions = results.filter( + (opinion): opinion is JsonRecord => + !!opinion && + typeof opinion === "object" && + !Array.isArray(opinion), + ); + rawOpinions.push(...pageOpinions); + opinions.push( + ...pageOpinions.map((opinion) => + compactOpinion(opinion, opinionMaxChars), + ), + ); + nextUrl = asString(data.next); + } + + return { + id: args.clusterId, + url: + absoluteWebUrl(rawOpinions[0]?.absolute_url) ?? + `${COURTLISTENER_WEB_BASE}/opinion/${args.clusterId}/`, + opinions, + source: "api", + }; +} + +function truncate(value: string | null, maxChars: number): string | null { + if (!value) return null; + if (value.length <= maxChars) return value; + return `${value.slice(0, Math.max(0, maxChars - 1))}…`; +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function decodeHtmlEntities(value: string): string { + return value + .replace(/ /g, " ") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&#(\d+);/g, (_match, code) => + String.fromCharCode(Number.parseInt(code, 10)), + ) + .replace(/&#x([0-9a-f]+);/gi, (_match, code) => + String.fromCharCode(Number.parseInt(code, 16)), + ); +} + +function stripOpinionMarkup(value: string | null): string | null { + if (!value) return null; + return decodeHtmlEntities( + value + .replace(/<page-number[^>]*>(.*?)<\/page-number>/gis, "$1") + .replace(/<\/p>/gi, "\n\n") + .replace(/<br\s*\/?>/gi, "\n") + .replace(/<\/(div|section|opinion|blockquote|li|h[1-6])>/gi, "\n") + .replace(/<[^>]+>/g, "") + .replace(/[ \t]+\n/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .trim(), + ); +} + +function safeCourtlistenerHref(rawHref: string | null): string | null { + if (!rawHref) return null; + const href = decodeHtmlEntities(rawHref.trim()); + if (!href) return null; + if (href.startsWith("#")) return href; + if (href.startsWith("/")) return `${COURTLISTENER_WEB_BASE}${href}`; + if (href.startsWith(COURTLISTENER_WEB_BASE)) return href; + if (/^https?:\/\//i.test(href)) return null; + return null; +} + +const SAFE_OPINION_HTML_TAGS = new Set([ + "a", + "blockquote", + "br", + "code", + "div", + "em", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "i", + "li", + "ol", + "p", + "pre", + "small", + "span", + "strong", + "sub", + "sup", + "table", + "tbody", + "td", + "th", + "thead", + "tr", + "u", + "ul", +]); + +const SAFE_OPINION_ATTRS = new Set([ + "aria-label", + "class", + "colspan", + "href", + "id", + "rowspan", + "title", +]); + +const VOID_OPINION_TAGS = new Set(["br"]); + +function sanitizeOpinionClassList(value: string): string | null { + const classes = decodeHtmlEntities(value) + .split(/\s+/) + .filter((className) => /^[a-z0-9_-]{1,80}$/i.test(className)); + return classes.length ? classes.join(" ") : null; +} + +function sanitizeOpinionHtmlAttrs(tagName: string, attrs: string): string { + const output: string[] = []; + const attrPattern = + /([^\s"'<>/=`]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g; + let match: RegExpExecArray | null; + + while ((match = attrPattern.exec(attrs))) { + const rawName = match[1] ?? ""; + const name = rawName.toLowerCase(); + const rawValue = match[2] ?? match[3] ?? match[4] ?? ""; + if (!SAFE_OPINION_ATTRS.has(name) || name.startsWith("on")) continue; + + if (name === "href") { + if (tagName !== "a") continue; + const href = safeCourtlistenerHref(rawValue); + if (!href) continue; + output.push(`href="${escapeHtml(href)}"`); + continue; + } + + if (name === "class") { + const classList = sanitizeOpinionClassList(rawValue); + if (classList) output.push(`class="${escapeHtml(classList)}"`); + continue; + } + + if (name === "id") { + const id = decodeHtmlEntities(rawValue).trim(); + if (/^[a-z0-9_-]{1,120}$/i.test(id)) { + output.push(`id="${escapeHtml(id)}"`); + } + continue; + } + + if (name === "colspan" || name === "rowspan") { + const value = Number.parseInt(rawValue, 10); + if (Number.isFinite(value) && value > 0 && value <= 100) { + output.push(`${name}="${value}"`); + } + continue; + } + + const value = decodeHtmlEntities(rawValue).trim(); + if (value) output.push(`${name}="${escapeHtml(value.slice(0, 300))}"`); + } + + if (tagName === "a") { + output.push('target="_blank"', 'rel="noopener noreferrer"'); + } + + return output.length ? ` ${output.join(" ")}` : ""; +} + +function sanitizeOpinionHtml(value: string | null): string | null { + if (!value) return null; + const normalized = value + .replace(/<!--[\s\S]*?-->/g, "") + .replace(/<(script|style|iframe|object|embed|form|svg|math)\b[\s\S]*?<\/\1>/gi, "") + .replace(/<(script|style|iframe|object|embed|form|svg|math)\b[^>]*\/?>/gi, "") + .replace( + /<page-number\b[^>]*>([\s\S]*?)<\/page-number>/gi, + (_m, inner) => + `<span class="case-page-number">${escapeHtml(stripOpinionMarkup(inner) ?? "")}</span>`, + ); + + const sanitized = normalized.replace( + /<\/?([a-z0-9-]+)\b([^>]*)>/gi, + (match, tag, attrs) => { + const name = String(tag).toLowerCase(); + const closing = match.startsWith("</"); + if (!SAFE_OPINION_HTML_TAGS.has(name)) return ""; + if (closing) { + return VOID_OPINION_TAGS.has(name) ? "" : `</${name}>`; + } + if (VOID_OPINION_TAGS.has(name)) return `<${name}>`; + return `<${name}${sanitizeOpinionHtmlAttrs(name, String(attrs))}>`; + }, + ); + + return sanitized.replace(/\n{3,}/g, "\n\n").trim(); +} + +function parseCitationParts(value: string) { + const match = value + .trim() + .match(/\b(\d{1,4})\s+([A-Za-z][A-Za-z0-9.\s]*?)\s+(\d{1,7})\b/); + if (!match) return null; + return { + volume: match[1], + reporter: match[2].replace(/\s+/g, " ").trim(), + page: match[3], + }; +} + +function citationPartsLabel(parts: ReturnType<typeof parseCitationParts>) { + if (!parts) return null; + return [parts.volume, parts.reporter, parts.page] + .filter(Boolean) + .join(" "); +} + +function clusterUrl(cluster: JsonRecord): string | null { + const id = asNumber(cluster.id); + if (!id) return null; + const slug = asString(cluster.slug); + return slug + ? `${COURTLISTENER_WEB_BASE}/opinion/${id}/${slug}/` + : `${COURTLISTENER_WEB_BASE}/opinion/${id}/`; +} + +function compactBulkCluster(cluster: JsonRecord, citations: string[] = []) { + return { + id: asNumber(cluster.id), + caseName: + asString(cluster.case_name) ?? + asString(cluster.case_name_full) ?? + asString(cluster.case_name_short), + dateFiled: asString(cluster.date_filed), + judges: asString(cluster.judges), + court: null, + citations, + url: clusterUrl(cluster), + pdfUrl: absoluteStorageUrl(cluster.filepath_pdf_harvard), + subOpinions: [], + }; +} + +async function getBulkCitationLookup(args: { + db?: ServerSupabase; + citations: string[]; +}) { + if (!args.db || !courtlistenerBulkDataEnabled()) return null; + const parsed = args.citations.map((citation) => ({ + citation, + parts: parseCitationParts(citation), + })); + if (!parsed.length || parsed.some((row) => !row.parts)) return null; + + const results: { + citation: string | null; + status: string; + message: string | null; + clusters: ReturnType<typeof compactBulkCluster>[]; + }[] = []; + + for (const row of parsed) { + const parts = row.parts; + if (!parts) return null; + const verifiedCitation = citationPartsLabel(parts); + if (!verifiedCitation) return null; + const { data: citationRows, error } = await args.db + .from("courtlistener_citation_index") + .select("cluster_id, volume, reporter, page") + .eq("volume", parts.volume) + .eq("reporter", parts.reporter) + .eq("page", parts.page) + .limit(20); + if (error) return null; + const clusterIds = [ + ...new Set( + (citationRows ?? []) + .map((citationRow) => + typeof citationRow.cluster_id === "number" + ? citationRow.cluster_id + : Number(citationRow.cluster_id), + ) + .filter((id) => Number.isFinite(id)), + ), + ]; + if (!clusterIds.length) return null; + + const { data: clusters, error: clusterError } = await args.db + .from("courtlistener_opinion_cluster_index") + .select( + "id, case_name, case_name_short, case_name_full, slug, date_filed, judges, filepath_pdf_harvard", + ) + .in("id", clusterIds); + if (clusterError) return null; + const clustersById = new Map( + (clusters ?? []) + .map((cluster) => { + const compact = compactBulkCluster( + cluster as JsonRecord, + [verifiedCitation], + ); + return typeof compact.id === "number" + ? ([compact.id, compact] as const) + : null; + }) + .filter( + ( + entry, + ): entry is readonly [ + number, + ReturnType<typeof compactBulkCluster>, + ] => !!entry, + ), + ); + const matchedClusters = clusterIds + .map((clusterId) => clustersById.get(clusterId)) + .filter( + (cluster): cluster is ReturnType<typeof compactBulkCluster> => + !!cluster && !!cluster.caseName, + ); + if (matchedClusters.length !== clusterIds.length) return null; + + results.push({ + citation: verifiedCitation, + status: "ok", + message: null, + clusters: matchedClusters, + }); + } + + const citationLinks = results.flatMap((result) => + result.clusters.flatMap((cluster) => { + if (!cluster.url) return []; + const label = [cluster.caseName, result.citation] + .filter(Boolean) + .join(", "); + return [ + { + clusterId: cluster.id, + citation: result.citation, + caseName: cluster.caseName, + court: cluster.court, + dateFiled: cluster.dateFiled, + judges: cluster.judges, + pdfUrl: cluster.pdfUrl, + url: cluster.url, + markdown: `[${label || cluster.url}](${cluster.url})`, + }, + ]; + }), + ); + + const payload = { + citationsSubmitted: args.citations.length || undefined, + citationLinks, + results, + source: "bulk", + }; + return payload; +} + +async function getBulkCourtlistenerCaseOpinions(args: { + db?: ServerSupabase; + clusterId: number; + maxChars: number; +}) { + if (!courtlistenerBulkDataEnabled()) { + devLog("[courtlistener/r2-opinions] bulk data disabled", { + clusterId: args.clusterId, + }); + return null; + } + + const prefix = `${COURTLISTENER_R2_OPINIONS_PREFIX}/${args.clusterId}/`; + devLog("[courtlistener/r2-opinions] listing", { + clusterId: args.clusterId, + prefix, + }); + const opinionKeys = (await listFiles(prefix)) + .filter((key) => key.endsWith(".json")) + .sort(); + devLog("[courtlistener/r2-opinions] listed", { + clusterId: args.clusterId, + count: opinionKeys.length, + keys: opinionKeys, + }); + if (!opinionKeys.length) return null; + + const rawOpinions = ( + await Promise.all( + opinionKeys.map(async (key) => { + devLog("[courtlistener/r2-opinions] downloading", { + clusterId: args.clusterId, + key, + }); + const bytes = await downloadFile(key); + if (!bytes) { + devLog("[courtlistener/r2-opinions] download missing", { + clusterId: args.clusterId, + key, + }); + return null; + } + try { + const parsed = JSON.parse( + Buffer.from(bytes).toString("utf8"), + ) as JsonRecord; + devLog("[courtlistener/r2-opinions] downloaded", { + clusterId: args.clusterId, + key, + bytes: bytes.byteLength, + opinionId: + asNumber(parsed.opinionId) ?? + asNumber(parsed.id) ?? + asNumber(parsed.opinion_id), + }); + return parsed; + } catch { + devLog("[courtlistener/r2-opinions] parse failed", { + clusterId: args.clusterId, + key, + bytes: bytes.byteLength, + }); + return null; + } + }), + ) + ).filter((opinion): opinion is JsonRecord => !!opinion); + devLog("[courtlistener/r2-opinions] parsed", { + clusterId: args.clusterId, + count: rawOpinions.length, + }); + if (!rawOpinions.length) return null; + + let compactCluster: + | ReturnType<typeof compactBulkCluster> + | { + id: number; + url: string | null; + } = { + id: args.clusterId, + url: + absoluteWebUrl(rawOpinions[0]?.url) ?? + absoluteWebUrl(rawOpinions[0]?.absolute_url) ?? + `${COURTLISTENER_WEB_BASE}/opinion/${args.clusterId}/`, + }; + if (args.db) { + const { data: cluster, error } = await args.db + .from("courtlistener_opinion_cluster_index") + .select( + "id, case_name, case_name_short, case_name_full, slug, date_filed, judges, filepath_pdf_harvard", + ) + .eq("id", args.clusterId) + .maybeSingle(); + if (error) { + devLog("[courtlistener/r2-opinions] cluster metadata query failed", { + clusterId: args.clusterId, + error: error.message, + }); + } else if (cluster) { + const { data: citationRows } = await args.db + .from("courtlistener_citation_index") + .select("volume, reporter, page") + .eq("cluster_id", args.clusterId) + .limit(20); + const citations = (citationRows ?? []) + .map((row) => + [row.volume, row.reporter, row.page] + .filter(Boolean) + .join(" "), + ) + .filter(Boolean); + compactCluster = compactBulkCluster(cluster as JsonRecord, citations); + } else { + devLog("[courtlistener/r2-opinions] cluster metadata missing", { + clusterId: args.clusterId, + }); + } + } + + return { + ...compactCluster, + opinions: rawOpinions + .filter( + (opinion): opinion is JsonRecord => + !!opinion && + typeof opinion === "object" && + !Array.isArray(opinion), + ) + .map((opinion) => { + const rawHtml = + asString(opinion.htmlWithCitations) ?? + asString(opinion.html_with_citations) ?? + asString(opinion.html) ?? + asString(opinion.htmlLawbox) ?? + asString(opinion.html_lawbox) ?? + asString(opinion.htmlColumbia) ?? + asString(opinion.html_columbia) ?? + asString(opinion.htmlWithCitationsLawbox) ?? + asString(opinion.html_with_citations_lawbox) ?? + asString(opinion.xmlHarvard) ?? + asString(opinion.xml_harvard) ?? + asString(opinion.xmlLawbox) ?? + asString(opinion.xml_lawbox) ?? + null; + const rawText = + asString(opinion.plainText) ?? + asString(opinion.plain_text) ?? + rawHtml ?? + null; + return { + opinionId: + asNumber(opinion.opinionId) ?? + asNumber(opinion.id) ?? + asNumber(opinion.opinion_id), + type: asString(opinion.type), + author: + asString(opinion.author) ?? + asString(opinion.author_str), + per_curiam: asString(opinion.per_curiam), + joined_by_str: asString(opinion.joined_by_str), + url: absoluteWebUrl(opinion.url), + text: truncate(stripOpinionMarkup(rawText), args.maxChars), + html: truncate(sanitizeOpinionHtml(rawHtml), args.maxChars), + }; + }), + source: "bulk", + }; +} + +export async function verifyCourtlistenerCitations(args: { + text?: string; + citations?: string[]; + db?: ServerSupabase; + apiToken?: string | null; +}) { + const citations = Array.isArray(args.citations) + ? args.citations + .map((c) => (typeof c === "string" ? c.trim() : "")) + .filter(Boolean) + .slice(0, 250) + : []; + const text = + typeof args.text === "string" && args.text.trim() + ? args.text.trim() + : citations.join("\n"); + if (!text) { + return { error: "Provide text or at least one citation." }; + } + + const bulk = await getBulkCitationLookup({ + db: args.db, + citations: citations.length + ? citations + : text.split(/\n+/).filter(Boolean), + }); + if (bulk) return bulk; + + const body = new URLSearchParams(); + body.set("text", text.slice(0, 64000)); + const results = await courtlistenerFetch<unknown[]>( + "/citation-lookup/", + { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body, + }, + args.apiToken, + ); + + const compactResults = (Array.isArray(results) ? results : []).map( + (item) => { + if (!item || typeof item !== "object") return item; + const row = item as JsonRecord; + return { + citation: + asString(row.citation) ?? + asString(row.normalized_citation) ?? + null, + status: row.status ?? null, + message: asString(row.message), + clusters: Array.isArray(row.clusters) + ? row.clusters.map(compactCluster) + : [], + }; + }, + ); + const citationLinks = compactResults.flatMap((result) => { + if (!result || typeof result !== "object") return []; + const row = result as { + citation?: string | null; + clusters?: ReturnType<typeof compactCluster>[]; + }; + return (row.clusters ?? []).flatMap((cluster) => { + if (!cluster.url) return []; + const label = [cluster.caseName, row.citation] + .filter(Boolean) + .join(", "); + return [ + { + clusterId: cluster.id, + citation: row.citation ?? null, + caseName: cluster.caseName, + court: cluster.court, + dateFiled: cluster.dateFiled, + judges: cluster.judges, + pdfUrl: cluster.pdfUrl, + url: cluster.url, + markdown: `[${label || cluster.url}](${cluster.url})`, + }, + ]; + }); + }); + + return { + citationsSubmitted: citations.length || undefined, + citationLinks, + results: compactResults, + }; +} + +export async function searchCourtlistenerCaseLaw(args: { + query?: string; + court?: string; + filedAfter?: string; + filedBefore?: string; + limit?: number; + apiToken?: string | null; +}) { + const query = args.query?.trim(); + if (!query) return { error: "query is required." }; + const limit = Math.max(1, Math.min(20, Math.floor(args.limit ?? 10))); + const params = new URLSearchParams({ + type: "o", + q: query, + }); + if (args.court?.trim()) params.set("court", args.court.trim()); + if (args.filedAfter?.trim()) + params.set("filed_after", args.filedAfter.trim()); + if (args.filedBefore?.trim()) + params.set("filed_before", args.filedBefore.trim()); + + const data = await courtlistenerFetch<JsonRecord>( + `/search/?${params}`, + undefined, + args.apiToken, + ); + const rawResults = Array.isArray(data.results) ? data.results : []; + return { + query, + results: rawResults.slice(0, limit).map((raw) => { + const r = raw as JsonRecord; + return { + clusterId: + asNumber(r.cluster_id) ?? + asNumber((r.cluster as JsonRecord | undefined)?.id), + caseName: + asString(r.caseName) ?? + asString(r.case_name) ?? + asString(r.caseNameFull), + citation: + asString(r.citation) ?? + (Array.isArray(r.citation) + ? r.citation + .map(citationLabel) + .filter(Boolean) + .join("; ") + : null), + court: + asString(r.court) ?? + asString(r.court_id) ?? + asString(r.court_citation_string), + dateFiled: asString(r.dateFiled) ?? asString(r.date_filed), + snippet: asString(r.snippet), + url: absoluteWebUrl(r.absolute_url), + }; + }), + }; +} + +export async function getCourtlistenerCaseOpinions(args: { + clusterId?: number; + includeFullText?: boolean; + maxChars?: number; + db?: ServerSupabase; + apiToken?: string | null; +}) { + if (!args.clusterId || !Number.isFinite(args.clusterId)) { + return { error: "clusterId is required." }; + } + const clusterId = Math.floor(args.clusterId); + const maxChars = Math.max(1000, Math.min(50000, args.maxChars ?? 12000)); + const bulk = await getBulkCourtlistenerCaseOpinions({ + db: args.db, + clusterId, + maxChars, + }); + if (bulk) return bulk; + + return fetchCaseOpinionsFromCourtlistenerOpinionsEndpoint({ + clusterId, + maxChars, + includeFullText: args.includeFullText, + apiToken: args.apiToken, + }); +} + +export async function getCourtlistenerCases(args: { + clusterIds?: number[]; + includeFullText?: boolean; + maxChars?: number; + db?: ServerSupabase; + apiToken?: string | null; +}) { + const clusterIds = Array.from( + new Set( + (args.clusterIds ?? []) + .filter((value) => Number.isFinite(value) && value > 0) + .map((value) => Math.floor(value)), + ), + ); + if (!clusterIds.length) { + return { error: "clusterIds is required.", cases: [] }; + } + + const cases = await Promise.all( + clusterIds.map(async (clusterId) => { + try { + const result = await getCourtlistenerCaseOpinions({ + clusterId, + includeFullText: args.includeFullText, + maxChars: args.maxChars, + db: args.db, + apiToken: args.apiToken, + }); + return { + clusterId, + ...(result && typeof result === "object" + ? (result as JsonRecord) + : { result }), + }; + } catch (err) { + return { + clusterId, + id: clusterId, + opinions: [], + error: + err instanceof Error + ? err.message + : "CourtListener case fetch failed.", + }; + } + }), + ); + + return { cases }; +} diff --git a/backend/src/lib/documentVersions.ts b/backend/src/lib/documentVersions.ts index 83c2ac4..d8e8759 100644 --- a/backend/src/lib/documentVersions.ts +++ b/backend/src/lib/documentVersions.ts @@ -9,6 +9,8 @@ interface DocRow { } interface VersionPathRow extends DocRow { + /** API/client alias for document_versions.filename of the active version. */ + filename?: string | null; /** Set from document_versions.storage_path of the active version. */ storage_path?: string | null; /** Set from document_versions.pdf_storage_path of the active version. */ @@ -16,6 +18,10 @@ interface VersionPathRow extends DocRow { current_version_id?: string | null; /** Set from document_versions.version_number of the active version. */ active_version_number?: number | null; + /** Active-version file metadata. */ + file_type?: string | null; + size_bytes?: number | null; + page_count?: number | null; } export interface ActiveVersion { @@ -23,8 +29,11 @@ export interface ActiveVersion { storage_path: string; pdf_storage_path: string | null; version_number: number | null; - display_name: string | null; + filename: string | null; source: string | null; + file_type: string | null; + size_bytes: number | null; + page_count: number | null; } /** @@ -54,7 +63,7 @@ export async function loadActiveVersion( const { data: v } = await db .from("document_versions") .select( - "id, document_id, storage_path, pdf_storage_path, version_number, display_name, source", + "id, document_id, storage_path, pdf_storage_path, version_number, filename, source, file_type, size_bytes, page_count", ) .eq("id", targetVersionId) .single(); @@ -64,8 +73,11 @@ export async function loadActiveVersion( storage_path: v.storage_path as string, pdf_storage_path: (v.pdf_storage_path as string | null) ?? null, version_number: (v.version_number as number | null) ?? null, - display_name: (v.display_name as string | null) ?? null, + filename: (v.filename as string | null) ?? null, source: (v.source as string | null) ?? null, + file_type: (v.file_type as string | null) ?? null, + size_bytes: (v.size_bytes as number | null) ?? null, + page_count: (v.page_count as number | null) ?? null, }; } @@ -85,14 +97,20 @@ export async function attachActiveVersionPaths<T extends VersionPathRow>( .filter((id): id is string => typeof id === "string"); if (versionIds.length === 0) { for (const d of docs) { + d.filename = "Untitled document"; d.storage_path = null; d.pdf_storage_path = null; + d.file_type = null; + d.size_bytes = null; + d.page_count = null; } return docs; } const { data: rows } = await db .from("document_versions") - .select("id, storage_path, pdf_storage_path, version_number") + .select( + "id, storage_path, pdf_storage_path, version_number, filename, file_type, size_bytes, page_count", + ) .in("id", versionIds); const byId = new Map< string, @@ -100,6 +118,10 @@ export async function attachActiveVersionPaths<T extends VersionPathRow>( storage_path: string | null; pdf_storage_path: string | null; version_number: number | null; + filename: string | null; + file_type: string | null; + size_bytes: number | null; + page_count: number | null; } >(); for (const r of (rows ?? []) as { @@ -107,11 +129,19 @@ export async function attachActiveVersionPaths<T extends VersionPathRow>( storage_path: string | null; pdf_storage_path: string | null; version_number: number | null; + filename: string | null; + file_type: string | null; + size_bytes: number | null; + page_count: number | null; }[]) { byId.set(r.id, { storage_path: r.storage_path ?? null, pdf_storage_path: r.pdf_storage_path ?? null, version_number: r.version_number ?? null, + filename: r.filename ?? null, + file_type: r.file_type ?? null, + size_bytes: r.size_bytes ?? null, + page_count: r.page_count ?? null, }); } for (const d of docs) { @@ -119,6 +149,10 @@ export async function attachActiveVersionPaths<T extends VersionPathRow>( d.storage_path = v?.storage_path ?? null; d.pdf_storage_path = v?.pdf_storage_path ?? null; d.active_version_number = v?.version_number ?? null; + d.filename = v?.filename?.trim() || "Untitled document"; + d.file_type = v?.file_type ?? null; + d.size_bytes = v?.size_bytes ?? null; + d.page_count = v?.page_count ?? null; } return docs; } diff --git a/backend/src/lib/legalSourcesTools/courtlistenerTools.ts b/backend/src/lib/legalSourcesTools/courtlistenerTools.ts new file mode 100644 index 0000000..09d2b2e --- /dev/null +++ b/backend/src/lib/legalSourcesTools/courtlistenerTools.ts @@ -0,0 +1,197 @@ +export type CourtlistenerToolEvent = + | { + type: "courtlistener_search_case_law"; + query: string; + result_count: number; + error?: string; + } + | { + type: "courtlistener_get_cases"; + cluster_ids: number[]; + case_count: number; + opinion_count: number; + cases?: { + cluster_id: number; + case_name: string | null; + citation: string | null; + dateFiled?: string | null; + url?: string | null; + }[]; + error?: string; + } + | { + type: "courtlistener_find_in_case"; + cluster_id: number | null; + query: string; + total_matches: number; + case_name?: string | null; + citation?: string | null; + searches?: { + cluster_id: number | null; + query: string; + total_matches: number; + case_name?: string | null; + citation?: string | null; + error?: string; + }[]; + error?: string; + } + | { + type: "courtlistener_read_case"; + cluster_id: number | null; + case_name?: string | null; + citation?: string | null; + opinion_count: number; + error?: string; + } + | { + type: "courtlistener_verify_citations"; + citation_count: number; + match_count: number; + error?: string; + }; + +export type CaseCitationEvent = { + type: "case_citation"; + cluster_id: number | null; + case_name: string | null; + citation: string | null; + url: string; + pdfUrl?: string | null; + dateFiled?: string | null; + judges?: string | null; +}; + +export const COURTLISTENER_TOOL_NAMES = { + searchCaseLaw: "courtlistener_search_case_law", + getCases: "courtlistener_get_cases", + findInCase: "courtlistener_find_in_case", + readCase: "courtlistener_read_case", + 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. +- If the user gives case names or reporter citations, use courtlistener_verify_citations for those names/citations. +- CourtListener keyword/issue search is not available. Do not attempt to search CourtListener for new candidate cases by legal issue or keywords. Work only from cases/citations supplied by the user, cases found in the provided documents, or citations already present in the conversation. +- If any CourtListener tool call reports that a CourtListener rate limit was exceeded, or returns a 429/throttled/rate-limit error, do not make any further CourtListener API/search calls in that turn. Do not retry, verify more citations, fetch more cases, or run additional CourtListener searches; answer with the information already available and briefly state that CourtListener is rate limiting requests. +- For cases you may cite or materially rely on, follow this sequence: first use courtlistener_verify_citations for case names/citations, then use courtlistener_get_cases to fetch/cache the relevant case clusters, then use courtlistener_find_in_case to search targeted keywords in the cached opinions, and only if those keyword snippets are insufficient use courtlistener_read_case to read selected opinion text. +- 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_TOOLS = [ + { + type: "function", + function: { + name: COURTLISTENER_TOOL_NAMES.getCases, + description: + "Fetch and cache one or more CourtListener case clusters and their opinions by cluster ID. This returns metadata/counts only, not full opinion text. After this, call courtlistener_find_in_case for targeted passages or courtlistener_read_case if broader full-case context is needed.", + parameters: { + type: "object", + properties: { + clusterIds: { + type: "array", + items: { type: "integer" }, + description: + "CourtListener cluster IDs from courtlistener_verify_citations or other case metadata already present in the conversation.", + }, + }, + required: ["clusterIds"], + }, + }, + }, + { + type: "function", + function: { + name: COURTLISTENER_TOOL_NAMES.findInCase, + description: + "Search within an already-fetched CourtListener case cluster for specific keyword(s) or phrases. Returns matches with surrounding opinion context. Call courtlistener_get_cases first; this tool does not fetch cases. Use no more than 3 calls to this tool in a single assistant turn.", + parameters: { + type: "object", + properties: { + clusterId: { + type: "integer", + description: + "CourtListener cluster ID previously fetched with courtlistener_get_cases.", + }, + query: { + type: "string", + description: + "Short term to search for, 1-3 words long and likely to appear exactly as written in the opinion text. Matching is case-insensitive and collapses whitespace.", + }, + max_results: { + type: "integer", + description: + "Maximum number of matches to return. Default 20.", + }, + context_chars: { + type: "integer", + description: + "Characters of surrounding context to include on each side of each match. Default 160.", + }, + }, + required: ["clusterId", "query"], + }, + }, + }, + { + type: "function", + function: { + name: COURTLISTENER_TOOL_NAMES.readCase, + description: + "Read selected opinion text from an already-fetched CourtListener case cluster in this turn's cache. Use after courtlistener_find_in_case if snippets are insufficient. If the case has multiple opinions, pass only the opinionId/opinionIds needed. Call courtlistener_get_cases first; this tool does not fetch cases.", + parameters: { + type: "object", + properties: { + clusterId: { + type: "integer", + description: + "CourtListener cluster ID previously fetched with courtlistener_get_cases.", + }, + opinionId: { + type: "integer", + description: + "Specific opinion ID to read. Use when one opinion is enough.", + }, + opinionIds: { + type: "array", + items: { type: "integer" }, + description: + "Specific opinion IDs to read. Use the smallest set needed; do not read all opinions unless the question requires it.", + }, + }, + required: ["clusterId"], + }, + }, + }, + { + type: "function", + function: { + name: COURTLISTENER_TOOL_NAMES.verifyCitations, + description: + "Verify legal case citations using CourtListener's citation lookup. Accepts raw text containing citations, or multiple citation strings. This returns citation metadata and clickable case refs; call courtlistener_get_cases only for matched cases that need full opinion text.", + parameters: { + type: "object", + properties: { + text: { + type: "string", + description: + "Raw text containing one or more legal citations. Max 64,000 characters sent to CourtListener.", + }, + citations: { + type: "array", + items: { type: "string" }, + description: + "Optional list of citation strings. Up to 250 will be joined into the request text field.", + }, + }, + }, + }, + }, +]; diff --git a/backend/src/lib/llm/claude.ts b/backend/src/lib/llm/claude.ts index 9f86b16..f470bfc 100644 --- a/backend/src/lib/llm/claude.ts +++ b/backend/src/lib/llm/claude.ts @@ -7,6 +7,7 @@ import type { NormalizedToolResult, } from "./types"; import { toClaudeTools } from "./tools"; +import { logRawLlmStream } from "./rawStreamLog"; type ContentBlock = | { type: "text"; text: string } @@ -41,6 +42,65 @@ function toNativeMessages( 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)); + 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; + } +} + +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}`; +} + +function abortError(): Error { + const err = new Error("Stream aborted."); + err.name = "AbortError"; + return err; +} + +function throwIfAborted(signal?: AbortSignal) { + if (signal?.aborted) throw abortError(); +} + export async function streamClaude( params: StreamChatParams, ): Promise<StreamChatResult> { @@ -61,6 +121,7 @@ export async function streamClaude( let fullText = ""; for (let iter = 0; iter < maxIter; iter++) { + throwIfAborted(params.abortSignal); const stream = anthropic.messages.stream({ model, system: systemPrompt, @@ -82,6 +143,35 @@ export async function streamClaude( }); 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("error", (error) => { + logRawLlmStream({ + provider: "claude", + model, + iteration: iter, + label: "error", + payload: error, + }); + }); stream.on("text", (delta) => { callbacks.onContentDelta?.(delta); @@ -93,8 +183,18 @@ export async function streamClaude( }); } - const final = await stream.finalMessage(); + 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[]; @@ -126,6 +226,7 @@ export async function streamClaude( } 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 @@ -152,12 +253,17 @@ export async function completeClaudeText(params: { apiKeys?: { claude?: string | null }; }): Promise<string> { const anthropic = client(params.apiKeys?.claude); - const resp = await anthropic.messages.create({ - model: params.model, - max_tokens: params.maxTokens ?? 512, - system: params.systemPrompt, - messages: [{ role: "user", content: params.user }], - }); + 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) diff --git a/backend/src/lib/llm/gemini.ts b/backend/src/lib/llm/gemini.ts index e40fc60..e6f639f 100644 --- a/backend/src/lib/llm/gemini.ts +++ b/backend/src/lib/llm/gemini.ts @@ -5,6 +5,7 @@ import type { NormalizedToolCall, } from "./types"; import { toGeminiTools } from "./tools"; +import { logRawLlmStream } from "./rawStreamLog"; type GeminiPart = { text?: string; @@ -49,6 +50,113 @@ function toNativeContents(messages: StreamChatParams["messages"]): GeminiContent })); } +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)}`; +} + +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; + } +} + +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 + : 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; +} + +function throwIfAborted(signal?: AbortSignal) { + if (signal?.aborted) throw abortError(); +} + export async function streamGemini( params: StreamChatParams, ): Promise<StreamChatResult> { @@ -61,61 +169,103 @@ export async function streamGemini( let fullText = ""; for (let iter = 0; iter < maxIter; iter++) { - const 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 }, - }, - }); + 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, + }); - for await (const chunk of stream) { - 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, + }); + 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); + 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); + } + } + 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 (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); - } + } + } 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 (sawThinking) callbacks.onReasoningBlockEnd?.(); + throwIfAborted(params.abortSignal); fullText += textParts.join(""); @@ -124,6 +274,7 @@ export async function streamGemini( } const results = await runTools(toolCalls); + throwIfAborted(params.abortSignal); // Append the model's turn (text + functionCall parts, in that order) // and the matching functionResponse turn. @@ -159,12 +310,17 @@ export async function completeGeminiText(params: { apiKeys?: { gemini?: string | null }; }): Promise<string> { const ai = client(params.apiKeys?.gemini); - const resp = await ai.models.generateContent({ - model: params.model, - contents: [{ role: "user", parts: [{ text: params.user }] }], - config: params.systemPrompt - ? { systemInstruction: params.systemPrompt } - : undefined, - }); + 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 ?? ""; } diff --git a/backend/src/lib/llm/models.ts b/backend/src/lib/llm/models.ts index ed4872e..f649c06 100644 --- a/backend/src/lib/llm/models.ts +++ b/backend/src/lib/llm/models.ts @@ -9,18 +9,18 @@ export const GEMINI_MAIN_MODELS = [ "gemini-3.1-pro-preview", "gemini-3-flash-preview", ] as const; -export const OPENAI_MAIN_MODELS = ["gpt-5.5", "gpt-5.4-mini"] as const; +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 OPENAI_MID_MODELS = ["gpt-5.4-mini"] as const; +export const OPENAI_MID_MODELS = ["gpt-5.4"] as const; // Low-tier (used for title generation, lightweight extractions) — user picks // one in account settings. export const CLAUDE_LOW_MODELS = ["claude-haiku-4-5"] as const; export const GEMINI_LOW_MODELS = ["gemini-3.1-flash-lite-preview"] as const; -export const OPENAI_LOW_MODELS = ["gpt-5.4-nano"] as const; +export const OPENAI_LOW_MODELS = ["gpt-5.4-lite"] as const; export const DEFAULT_MAIN_MODEL = "gemini-3-flash-preview"; export const DEFAULT_TITLE_MODEL = "gemini-3.1-flash-lite-preview"; diff --git a/backend/src/lib/llm/openai.ts b/backend/src/lib/llm/openai.ts index de07b5c..fd830e5 100644 --- a/backend/src/lib/llm/openai.ts +++ b/backend/src/lib/llm/openai.ts @@ -6,6 +6,7 @@ import type { StreamChatParams, StreamChatResult, } from "./types"; +import { logRawLlmStream } from "./rawStreamLog"; const OPENAI_RESPONSES_URL = "https://api.openai.com/v1/responses"; const MAX_OUTPUT_TOKENS = 16384; @@ -31,7 +32,13 @@ type ResponseFunctionCallItem = { type ResponseStreamEvent = { type?: string; delta?: string; - response?: { id?: string; output_text?: string }; + response?: { + id?: string; + output_text?: string; + status?: string; + error?: { code?: string; message?: string } | null; + }; + error?: { code?: string; message?: string } | null; item?: ResponseFunctionCallItem; }; @@ -104,6 +111,35 @@ function parseFunctionCall(item: ResponseFunctionCallItem): NormalizedToolCall { }; } +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 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; +} + +function throwIfAborted(signal?: AbortSignal) { + if (signal?.aborted) throw abortError(); +} + async function createResponse(params: { model: string; input: ResponseInputItem[]; @@ -114,6 +150,7 @@ async function createResponse(params: { previousResponseId?: string; reasoningSummary?: boolean; apiKey: string; + signal?: AbortSignal; }): Promise<Response> { const response = await fetch(OPENAI_RESPONSES_URL, { method: "POST", @@ -133,6 +170,7 @@ async function createResponse(params: { ? { summary: "auto" } : undefined, }), + signal: params.signal, }); if (!response.ok) { @@ -168,6 +206,7 @@ export async function streamOpenAI( const hasTools = responseTools.length > 0; for (let iter = 0; iter < maxIter; iter++) { + throwIfAborted(params.abortSignal); const response = await createResponse({ model, instructions: iter === 0 ? systemPrompt : undefined, @@ -177,6 +216,7 @@ export async function streamOpenAI( previousResponseId, reasoningSummary: !!enableThinking, apiKey: key, + signal: params.abortSignal, }); if (!response.body) throw new Error("OpenAI response had no body"); @@ -189,14 +229,36 @@ export async function streamOpenAI( let sawReasoning = false; while (true) { + throwIfAborted(params.abortSignal); const { done, value } = await reader.read(); if (done) break; - buffer += decoder.decode(value, { stream: true }); + 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; for (const event of extracted.events as ResponseStreamEvent[]) { + logRawLlmStream({ + provider: "openai", + model, + iteration: iter, + label: "sse_event", + payload: event, + }); + + const failureMessage = openAIStreamFailureMessage(event); + if (failureMessage) { + throw new Error(failureMessage); + } + if (event.response?.id) { previousResponseId = event.response.id; } @@ -244,6 +306,7 @@ export async function streamOpenAI( } if (sawReasoning) callbacks.onReasoningBlockEnd?.(); + throwIfAborted(params.abortSignal); if (!toolCalls.length || !runTools) { if (pendingText) { @@ -254,6 +317,7 @@ export async function streamOpenAI( } const results = await runTools(toolCalls); + throwIfAborted(params.abortSignal); input = results.map((result) => ({ type: "function_call_output", call_id: result.tool_use_id, diff --git a/backend/src/lib/llm/rawStreamLog.ts b/backend/src/lib/llm/rawStreamLog.ts new file mode 100644 index 0000000..9c08b13 --- /dev/null +++ b/backend/src/lib/llm/rawStreamLog.ts @@ -0,0 +1,19 @@ +export function logRawLlmStream(args: { + provider: string; + model: string; + iteration: number; + label: string; + payload: unknown; +}) { + if ( + process.env.NODE_ENV === "production" && + 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 }); +} diff --git a/backend/src/lib/llm/types.ts b/backend/src/lib/llm/types.ts index a8409d8..6a9f18a 100644 --- a/backend/src/lib/llm/types.ts +++ b/backend/src/lib/llm/types.ts @@ -40,6 +40,8 @@ export type UserApiKeys = { claude?: string | null; gemini?: string | null; openai?: string | null; + openrouter?: string | null; + courtlistener?: string | null; }; export type StreamChatParams = { @@ -58,6 +60,7 @@ export type StreamChatParams = { * one-shot completions should leave this off to save tokens and latency. */ enableThinking?: boolean; + abortSignal?: AbortSignal; }; export type StreamChatResult = { diff --git a/backend/src/lib/storage.ts b/backend/src/lib/storage.ts index dc28db2..dccf9e4 100644 --- a/backend/src/lib/storage.ts +++ b/backend/src/lib/storage.ts @@ -12,11 +12,14 @@ import { S3Client, PutObjectCommand, - GetObjectCommand, DeleteObjectCommand, + ListObjectsV2Command, } from "@aws-sdk/client-s3"; +import * as S3Commands from "@aws-sdk/client-s3"; import { getSignedUrl as awsGetSignedUrl } from "@aws-sdk/s3-request-presigner"; +const GetObjectCommand = (S3Commands as any).GetObjectCommand; + let cachedClient: S3Client | undefined; function getClient(): S3Client { @@ -79,9 +82,9 @@ export async function downloadFile(key: string): Promise<ArrayBuffer | null> { if (!storageEnabled) return null; try { const client = getClient(); - const response = await client.send( + const response = (await client.send( new GetObjectCommand({ Bucket: BUCKET, Key: key }), - ); + )) as any; if (!response.Body) return null; const bytes = await response.Body.transformToByteArray(); return bytes.buffer as ArrayBuffer; @@ -90,6 +93,27 @@ export async function downloadFile(key: string): Promise<ArrayBuffer | null> { } } +export async function listFiles(prefix: string): Promise<string[]> { + if (!storageEnabled) return []; + const client = getClient(); + const keys: string[] = []; + let ContinuationToken: string | undefined; + do { + const response = await client.send( + new ListObjectsV2Command({ + Bucket: BUCKET, + Prefix: prefix, + ContinuationToken, + }), + ); + for (const item of response.Contents ?? []) { + if (item.Key) keys.push(item.Key); + } + ContinuationToken = response.NextContinuationToken; + } while (ContinuationToken); + return keys; +} + // --------------------------------------------------------------------------- // Delete // --------------------------------------------------------------------------- @@ -123,7 +147,7 @@ export async function getSignedUrl( Bucket: BUCKET, Key: key, ResponseContentDisposition: responseContentDisposition, - }); + }) as any; return await awsGetSignedUrl(client, command, { expiresIn }); } catch { return null; diff --git a/backend/src/lib/userApiKeys.ts b/backend/src/lib/userApiKeys.ts index cbc3153..4975d9c 100644 --- a/backend/src/lib/userApiKeys.ts +++ b/backend/src/lib/userApiKeys.ts @@ -3,7 +3,12 @@ import { createServerSupabase } from "./supabase"; import type { UserApiKeys } from "./llm"; type Db = ReturnType<typeof createServerSupabase>; -export type ApiKeyProvider = "claude" | "gemini" | "openai"; +export type ApiKeyProvider = + | "claude" + | "gemini" + | "openai" + | "openrouter" + | "courtlistener"; export type ApiKeySource = "user" | "env" | null; export type ApiKeyStatus = Record<ApiKeyProvider, boolean> & { sources: Record<ApiKeyProvider, ApiKeySource>; @@ -16,7 +21,13 @@ type EncryptedKeyRow = { auth_tag: string; }; -const PROVIDERS: ApiKeyProvider[] = ["claude", "gemini", "openai"]; +const PROVIDERS: ApiKeyProvider[] = [ + "claude", + "gemini", + "openai", + "openrouter", + "courtlistener", +]; function envApiKey(provider: ApiKeyProvider): string | null { if (provider === "claude") { @@ -29,6 +40,12 @@ function envApiKey(provider: ApiKeyProvider): string | null { if (provider === "openai") { return process.env.OPENAI_API_KEY?.trim() || null; } + if (provider === "openrouter") { + return process.env.OPENROUTER_API_KEY?.trim() || null; + } + if (provider === "courtlistener") { + return process.env.COURTLISTENER_API_TOKEN?.trim() || null; + } return process.env.GEMINI_API_KEY?.trim() || null; } @@ -96,10 +113,14 @@ export async function getUserApiKeyStatus( claude: false, gemini: false, openai: false, + openrouter: false, + courtlistener: false, sources: { claude: null, gemini: null, openai: null, + openrouter: null, + courtlistener: null, }, }; @@ -135,6 +156,8 @@ export async function getUserApiKeys( claude: envApiKey("claude"), gemini: envApiKey("gemini"), openai: envApiKey("openai"), + openrouter: envApiKey("openrouter"), + courtlistener: envApiKey("courtlistener"), }; const { data, error } = await db diff --git a/backend/src/lib/userSettings.ts b/backend/src/lib/userSettings.ts index bfbeb0f..0240cbc 100644 --- a/backend/src/lib/userSettings.ts +++ b/backend/src/lib/userSettings.ts @@ -16,7 +16,7 @@ export type UserModelSettings = { // Title generation is a lightweight task — always routed to the cheapest model // of whichever provider the user has keys for: Gemini Flash Lite if Gemini is -// available, otherwise OpenAI nano, otherwise Claude Haiku. With no user keys +// available, otherwise OpenAI lite, otherwise Claude Haiku. With no user keys // set, defaults to Gemini (the dev-mode env fallback). function resolveTitleModel(apiKeys: UserApiKeys): string { if (apiKeys.gemini?.trim()) return DEFAULT_TITLE_MODEL; @@ -32,13 +32,13 @@ export async function getUserModelSettings( const client = db ?? createServerSupabase(); const { data } = await client .from("user_profiles") - .select("tabular_model") + .select("title_model, tabular_model") .eq("user_id", userId) .single(); const api_keys = await getStoredUserApiKeys(userId, client); return { - title_model: resolveTitleModel(api_keys), + title_model: resolveModel(data?.title_model, resolveTitleModel(api_keys)), tabular_model: resolveModel(data?.tabular_model, DEFAULT_TABULAR_MODEL), api_keys, }; diff --git a/backend/src/routes/caseLaw.ts b/backend/src/routes/caseLaw.ts new file mode 100644 index 0000000..ef32078 --- /dev/null +++ b/backend/src/routes/caseLaw.ts @@ -0,0 +1,84 @@ +import { Router } from "express"; +import { requireAuth } from "../middleware/auth"; +import { getCourtlistenerCaseOpinions } from "../lib/courtlistener"; +import { createServerSupabase } from "../lib/supabase"; +import { getUserModelSettings } from "../lib/userSettings"; + +export const caseLawRouter = Router(); + +caseLawRouter.use(requireAuth); + +const isDev = process.env.NODE_ENV !== "production"; +const devLog = (...args: Parameters<typeof console.log>) => { + if (isDev) console.log(...args); +}; + +const sidepanelOpinionFetches = new Map<string, Promise<unknown>>(); + +function cleanClusterId(value: unknown): number | null { + const numeric = + typeof value === "number" + ? value + : typeof value === "string" + ? Number.parseInt(value, 10) + : NaN; + return Number.isFinite(numeric) && numeric > 0 ? Math.floor(numeric) : null; +} + +caseLawRouter.post("/case-opinions", async (req, res) => { + const body = + req.body && typeof req.body === "object" && !Array.isArray(req.body) + ? (req.body as Record<string, unknown>) + : {}; + const clusterId = cleanClusterId(body.clusterId ?? body.cluster_id); + if (!clusterId) { + return res.status(400).json({ + detail: "cluster_id is required", + }); + } + + try { + const userId = String(res.locals.userId ?? ""); + const settings = await getUserModelSettings(userId); + devLog("[case-law/case-opinions] loading sidepanel opinions", { + clusterId, + }); + const db = createServerSupabase(); + const fetchKey = String(clusterId); + let fetchPromise = sidepanelOpinionFetches.get(fetchKey); + if (fetchPromise) { + devLog("[case-law/case-opinions] joining in-flight fetch", { + clusterId, + }); + } else { + fetchPromise = getCourtlistenerCaseOpinions({ + clusterId, + db, + includeFullText: true, + maxChars: 50000, + apiToken: settings.api_keys.courtlistener, + }).finally(() => { + sidepanelOpinionFetches.delete(fetchKey); + }); + sidepanelOpinionFetches.set(fetchKey, fetchPromise); + } + const fetched = await fetchPromise; + const fetchedRecord = + fetched && typeof fetched === "object" && !Array.isArray(fetched) + ? (fetched as Record<string, unknown>) + : {}; + const opinions = Array.isArray(fetchedRecord.opinions) + ? fetchedRecord.opinions + : []; + devLog("[case-law/case-opinions] returning sidepanel opinions", { + clusterId, + opinionCount: opinions.length, + }); + + return res.json({ opinions }); + } catch (err) { + const message = + err instanceof Error ? err.message : "Failed to fetch case opinions"; + return res.status(502).json({ detail: message }); + } +}); diff --git a/backend/src/routes/chat.ts b/backend/src/routes/chat.ts index 9a39e0a..0062125 100644 --- a/backend/src/routes/chat.ts +++ b/backend/src/routes/chat.ts @@ -6,8 +6,11 @@ import { buildMessages, enrichWithPriorEvents, buildWorkflowStore, + AssistantStreamError, extractAnnotations, + isAbortError, runLLMStream, + stripTransientAssistantEvents, type ChatMessage, } from "../lib/chatTools"; import { completeText } from "../lib/llm"; @@ -22,6 +25,14 @@ const devLog = (...args: Parameters<typeof console.log>) => { if (isDev) console.log(...args); }; +const TITLE_FALLBACK = "Misc. Query"; + +function normalizeGeneratedTitle(raw: string): string { + const title = raw.trim().replace(/^["'`]+|["'`.,:;!?]+$/g, "").trim(); + if (!title) return TITLE_FALLBACK; + return title.slice(0, 80); +} + type AccessibleChat = { id: string; title: string | null; @@ -225,11 +236,12 @@ chatRouter.get("/:chatId", requireAuth, async (req, res) => { res.json({ chat, messages: hydrated }); }); -// Stored message annotations/events capture the `status` at the time the -// assistant produced the edit (always "pending"). If the user later accepts -// or rejects, `document_edits.status` is updated but the stored message -// annotation is not. On chat load we merge the current DB status in so -// EditCards render with the real state. +// Stored doc_edited events capture the `status` at the time the assistant +// produced the edit (always "pending"). If the user later accepts or rejects, +// `document_edits.status` is updated but the stored event is not. On chat load +// we merge the current DB status in so EditCards render with the real state. +// Legacy rows may also have duplicate edit_data in top-level annotations, so +// keep patching that path until old data no longer matters. async function hydrateEditStatuses( messages: Record<string, unknown>[], db: ReturnType<typeof createServerSupabase>, @@ -401,11 +413,11 @@ chatRouter.post("/:chatId/generate-title", requireAuth, async (req, res) => { ); const titleText = await completeText({ model: title_model, - user: `Generate a concise title (3–6 words) for a chat in an AI Legal Platform that starts with this message. The title should describe the topic or document — do NOT include words like "Legal Assistant", "AI", "Chat", or any similar prefix. Return only the title, no quotes or punctuation.\n\nMessage: ${message.slice(0, 500)}`, + user: `Generate a concise title (3–6 words) for a chat in an AI Legal Platform that starts with this message. The title should describe the topic or document — do NOT include words like "Legal Assistant", "AI", "Chat", or any similar prefix. If there is not enough information to generate a title, return exactly "${TITLE_FALLBACK}". Return only the title, no quotes or punctuation.\n\nMessage: ${message.slice(0, 500)}`, maxTokens: 64, apiKeys: api_keys, }); - const title = titleText.trim() || message.slice(0, 60); + const title = normalizeGeneratedTitle(titleText); await db .from("chats") @@ -555,13 +567,18 @@ chatRouter.post("/", requireAuth, async (req, res) => { res.flushHeaders(); const write = (line: string) => res.write(line); + const streamAbort = new AbortController(); + let streamFinished = false; + res.on("close", () => { + if (!streamFinished) streamAbort.abort(); + }); const apiKeys = await getUserApiKeys(userId, db); try { write(`data: ${JSON.stringify({ type: "chat_id", chatId })}\n\n`); - const { fullText, events } = await runLLMStream({ + const { fullText, events, annotations } = await runLLMStream({ apiMessages, docStore, docIndex, @@ -571,6 +588,7 @@ chatRouter.post("/", requireAuth, async (req, res) => { workflowStore, model, apiKeys, + signal: streamAbort.signal, projectId: resolvedProjectId, }); @@ -579,11 +597,11 @@ chatRouter.post("/", requireAuth, async (req, res) => { eventCount: events?.length ?? 0, }); - const annotations = extractAnnotations(fullText, docIndex, events); + const persistedEvents = stripTransientAssistantEvents(events); await db.from("chat_messages").insert({ chat_id: chatId, role: "assistant", - content: events.length ? events : null, + content: persistedEvents.length ? persistedEvents : null, annotations: annotations.length ? annotations : null, }); @@ -594,16 +612,45 @@ chatRouter.post("/", requireAuth, async (req, res) => { .eq("id", chatId); } } catch (err) { + if (isAbortError(err)) { + devLog("[chat/stream] client aborted stream", { chatId }); + return; + } console.error("[chat/stream] error:", err); + const message = + err instanceof Error && err.message ? err.message : "Stream error"; + const errorEvents = err instanceof AssistantStreamError + ? stripTransientAssistantEvents(err.events) + : [{ type: "error" as const, message }]; + const errorFullText = + err instanceof AssistantStreamError ? err.fullText : ""; + try { + const annotations = extractAnnotations( + errorFullText, + docIndex, + errorEvents, + ); + const { error: saveError } = await db.from("chat_messages").insert({ + chat_id: chatId, + role: "assistant", + content: errorEvents.length ? errorEvents : null, + annotations: annotations.length ? annotations : null, + }); + if (saveError) + console.error("[chat/stream] failed to save error", saveError); + } catch (saveErr) { + console.error("[chat/stream] failed to save error", saveErr); + } try { write( - `data: ${JSON.stringify({ type: "error", message: "Stream error" })}\n\n`, + `data: ${JSON.stringify({ type: "error", message })}\n\n`, ); write("data: [DONE]\n\n"); } catch { /* ignore */ } } finally { + streamFinished = true; res.end(); } }); diff --git a/backend/src/routes/documents.ts b/backend/src/routes/documents.ts index 32f4b88..58c3cf5 100644 --- a/backend/src/routes/documents.ts +++ b/backend/src/routes/documents.ts @@ -26,6 +26,30 @@ import { singleFileUpload } from "../lib/upload"; export const documentsRouter = Router(); const ALLOWED_TYPES = new Set(["pdf", "docx", "doc"]); +const isDev = process.env.NODE_ENV !== "production"; +const devLog = (...args: Parameters<typeof console.log>) => { + if (isDev) console.log(...args); +}; + +async function deleteDocumentAndVersionFiles( + db: ReturnType<typeof createServerSupabase>, + documentId: string, +) { + // Storage lives on document_versions — fan out and delete each version's + // bytes (source + PDF rendition) before dropping the document row. + const { data: versions } = await db + .from("document_versions") + .select("storage_path, pdf_storage_path") + .eq("document_id", documentId); + await Promise.all( + (versions ?? []).flatMap((v) => + [v.storage_path, v.pdf_storage_path] + .filter((p): p is string => typeof p === "string" && p.length > 0) + .map((p) => deleteFile(p).catch(() => {})), + ), + ); + return db.from("documents").delete().eq("id", documentId); +} // GET /single-documents documentsRouter.get("/", requireAuth, async (req, res) => { @@ -74,20 +98,7 @@ documentsRouter.delete("/:documentId", requireAuth, async (req, res) => { if (error || !doc) return void res.status(404).json({ detail: "Document not found" }); - // Storage now lives on document_versions — fan out and delete each - // version's bytes (DOCX + PDF rendition) before dropping rows. - const { data: versions } = await db - .from("document_versions") - .select("storage_path, pdf_storage_path") - .eq("document_id", documentId); - await Promise.all( - (versions ?? []).flatMap((v) => - [v.storage_path, v.pdf_storage_path] - .filter((p): p is string => typeof p === "string" && p.length > 0) - .map((p) => deleteFile(p).catch(() => {})), - ), - ); - await db.from("documents").delete().eq("id", documentId); + await deleteDocumentAndVersionFiles(db, documentId); res.status(204).send(); }); @@ -104,7 +115,7 @@ documentsRouter.get("/:documentId/display", requireAuth, async (req, res) => { const { data: doc } = await db .from("documents") - .select("id, filename, file_type, user_id, project_id") + .select("id, user_id, project_id") .eq("id", documentId) .single(); if (!doc) @@ -117,8 +128,13 @@ documentsRouter.get("/:documentId/display", requireAuth, async (req, res) => { if (!active) return void res.status(404).json({ detail: "No file available" }); - const fileType = (doc.file_type as string) ?? ""; + const fileType = active.file_type ?? ""; const isDocx = fileType === "docx" || fileType === "doc"; + const displayFilename = downloadFilenameForVersion( + active.filename, + active.version_number, + active.source === "assistant_edit", + ); // For DOCX, prefer the per-version PDF rendition if one exists. const servePath = @@ -135,7 +151,7 @@ documentsRouter.get("/:documentId/display", requireAuth, async (req, res) => { res.setHeader("Content-Type", "application/pdf"); res.setHeader( "Content-Disposition", - buildContentDisposition("inline", doc.filename as string), + buildContentDisposition("inline", displayFilename), ); res.send(Buffer.from(raw)); } else { @@ -146,7 +162,7 @@ documentsRouter.get("/:documentId/display", requireAuth, async (req, res) => { ); res.setHeader( "Content-Disposition", - buildContentDisposition("inline", doc.filename as string), + buildContentDisposition("inline", displayFilename), ); res.send(Buffer.from(raw)); } @@ -164,7 +180,7 @@ documentsRouter.post("/download-zip", requireAuth, async (req, res) => { const db = createServerSupabase(); const { data: rawDocs, error } = await db .from("documents") - .select("id, filename, file_type, current_version_id, user_id, project_id") + .select("id, current_version_id, user_id, project_id") .in("id", document_ids); if (error) return void res.status(500).json({ detail: error.message }); @@ -182,7 +198,7 @@ documentsRouter.post("/download-zip", requireAuth, async (req, res) => { ); const docs = accessChecks .filter((x) => x.access.ok) - .map((x) => x.doc as { id: string; filename: string }); + .map((x) => x.doc as { id: string }); if (!docs || docs.length === 0) return void res.status(404).json({ detail: "No documents found" }); @@ -195,7 +211,14 @@ documentsRouter.post("/download-zip", requireAuth, async (req, res) => { if (!active) return; const raw = await downloadFile(active.storage_path); if (!raw) return; - zip.file(doc.filename, Buffer.from(raw)); + zip.file( + downloadFilenameForVersion( + active.filename, + active.version_number, + active.source === "assistant_edit", + ), + Buffer.from(raw), + ); }), ); @@ -217,7 +240,7 @@ documentsRouter.get("/:documentId/url", requireAuth, async (req, res) => { const { data: doc, error } = await db .from("documents") - .select("id, filename, user_id, project_id") + .select("id, user_id, project_id") .eq("id", documentId) .single(); if (error || !doc) @@ -230,10 +253,10 @@ documentsRouter.get("/:documentId/url", requireAuth, async (req, res) => { if (!active) return void res.status(404).json({ detail: "No file available" }); - const downloadFilename = resolveDownloadFilename( - doc.filename as string, - active.display_name, + const downloadFilename = downloadFilenameForVersion( + active.filename, active.version_number, + active.source === "assistant_edit", ); const url = await getSignedUrl( active.storage_path, @@ -268,7 +291,7 @@ documentsRouter.get("/:documentId/docx", requireAuth, async (req, res) => { const { data: doc, error } = await db .from("documents") - .select("id, filename, user_id, project_id") + .select("id, user_id, project_id") .eq("id", documentId) .single(); if (error || !doc) @@ -293,51 +316,29 @@ documentsRouter.get("/:documentId/docx", requireAuth, async (req, res) => { "Content-Disposition", buildContentDisposition( "inline", - resolveDownloadFilename( - doc.filename as string, - active.display_name, + downloadFilenameForVersion( + active.filename, active.version_number, + active.source === "assistant_edit", ), ), ); res.send(Buffer.from(raw)); }); -// Compose a download-friendly filename that carries the edit version -// marker: "Purchase Agreement.docx" → "Purchase Agreement [Edited V2].docx". -// Preserves the original extension (fallback: .docx). -function versionedFilename(filename: string, version: number | null): string { - if (!version || version < 1) return filename; - const dot = filename.lastIndexOf("."); - const stem = dot > 0 ? filename.slice(0, dot) : filename; - const ext = dot > 0 ? filename.slice(dot) : ".docx"; - return `${stem} [Edited V${version}]${ext}`; -} - -// Produce the filename a download should present to the user for a given -// (document, version) pair. Prefers the version's display_name (appending -// the original extension if the user didn't include one), falling back to -// the versionedFilename heuristic. -function resolveDownloadFilename( - originalFilename: string, - displayName: string | null | undefined, +// Produce the filename a download should present to the user. Version +// filenames are expected to include the real extension. +function downloadFilenameForVersion( + filename: string | null | undefined, versionNumber: number | null, + edited = false, ): string { - const dot = originalFilename.lastIndexOf("."); - const origExt = dot > 0 ? originalFilename.slice(dot) : ""; - if (displayName && displayName.trim()) { - const trimmed = displayName.trim(); - const trimmedDot = trimmed.lastIndexOf("."); - const hasExt = - trimmedDot > 0 && - trimmed - .slice(trimmedDot) - .toLowerCase() - .match(/^\.[a-z0-9]{1,6}$/); - if (hasExt) return trimmed; - return origExt ? `${trimmed}${origExt}` : trimmed; - } - return versionedFilename(originalFilename, versionNumber); + const resolved = filename?.trim() || "Untitled document.docx"; + if (!edited || !versionNumber || versionNumber < 1) return resolved; + const dot = resolved.lastIndexOf("."); + const stem = dot > 0 ? resolved.slice(0, dot) : resolved; + const ext = dot > 0 ? resolved.slice(dot) : ""; + return `${stem} [Edited V${versionNumber}]${ext}`; } // GET /single-documents/:documentId/versions @@ -362,7 +363,9 @@ documentsRouter.get("/:documentId/versions", requireAuth, async (req, res) => { const { data: rows } = await db .from("document_versions") - .select("id, version_number, source, created_at, display_name") + .select( + "id, version_number, source, created_at, filename, file_type, size_bytes, page_count", + ) .eq("document_id", documentId) .order("created_at", { ascending: true }); @@ -372,10 +375,204 @@ documentsRouter.get("/:documentId/versions", requireAuth, async (req, res) => { }); }); +// POST /single-documents/:documentId/versions/from-document +// Create a new version of documentId from another existing document's active +// bytes. This keeps signed storage URLs out of the browser fetch path. +documentsRouter.post( + "/:documentId/versions/from-document", + requireAuth, + async (req, res) => { + const userId = res.locals.userId as string; + const userEmail = res.locals.userEmail as string | undefined; + const { documentId } = req.params; + const sourceDocumentId = + typeof req.body?.source_document_id === "string" + ? req.body.source_document_id + : ""; + const db = createServerSupabase(); + + if (!sourceDocumentId) { + return void res + .status(400) + .json({ detail: "source_document_id is required" }); + } + if (sourceDocumentId === documentId) { + return void res + .status(400) + .json({ detail: "Source and target documents must be different." }); + } + + const { data: targetDoc } = await db + .from("documents") + .select("id, user_id, project_id") + .eq("id", documentId) + .single(); + if (!targetDoc) + return void res.status(404).json({ detail: "Document not found" }); + const targetAccess = await ensureDocAccess(targetDoc, userId, userEmail, db); + if (!targetAccess.ok) + return void res.status(404).json({ detail: "Document not found" }); + + const { data: sourceDoc } = await db + .from("documents") + .select("id, user_id, project_id") + .eq("id", sourceDocumentId) + .single(); + if (!sourceDoc) + return void res.status(404).json({ detail: "Source document not found" }); + const sourceAccess = await ensureDocAccess(sourceDoc, userId, userEmail, db); + if (!sourceAccess.ok) + return void res.status(404).json({ detail: "Source document not found" }); + + 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) + return void res + .status(404) + .json({ detail: "Source document bytes not available." }); + + const filename = + typeof req.body?.filename === "string" && req.body.filename.trim() + ? req.body.filename.trim().slice(0, 200) + : active.filename?.trim() || "Untitled document"; + const suffix = + sourceType || + (filename.includes(".") ? filename.split(".").pop()!.toLowerCase() : ""); + const versionSlug = crypto.randomUUID().replace(/-/g, ""); + const key = versionStorageKey(userId, documentId, versionSlug, filename); + const contentType = + suffix === "pdf" + ? "application/pdf" + : "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + + try { + await uploadFile(key, bytes, contentType); + } catch (e) { + console.error("[versions/copy] storage write failed", e); + return void res + .status(500) + .json({ detail: "Failed to create new version." }); + } + + let pdfStoragePath: string | null = null; + if (suffix === "pdf") { + pdfStoragePath = key; + } else if (active.pdf_storage_path) { + if (active.pdf_storage_path === active.storage_path) { + pdfStoragePath = key; + } else { + const pdfBytes = await downloadFile(active.pdf_storage_path); + if (pdfBytes) { + const pdfKey = `converted-pdfs/${userId}/${documentId}/${versionSlug}.pdf`; + await uploadFile(pdfKey, pdfBytes, "application/pdf"); + pdfStoragePath = pdfKey; + } + } + } else if (suffix === "docx" || suffix === "doc") { + try { + const pdfBuf = await docxToPdf(Buffer.from(bytes)); + 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/copy] DOCX→PDF conversion failed for ${filename}:`, + err, + ); + } + } + + const { data: maxRow } = await db + .from("document_versions") + .select("version_number") + .eq("document_id", documentId) + .in("source", ["upload", "user_upload", "assistant_edit"]) + .order("version_number", { ascending: false, nullsFirst: false }) + .limit(1) + .maybeSingle(); + const nextVersionNumber = + ((maxRow?.version_number as number | null) ?? 1) + 1; + + const { data: versionRow, error: verErr } = await db + .from("document_versions") + .insert({ + document_id: documentId, + storage_path: key, + pdf_storage_path: pdfStoragePath, + source: "user_upload", + version_number: nextVersionNumber, + filename: filename, + file_type: sourceType || null, + size_bytes: active.size_bytes ?? bytes.byteLength, + page_count: active.page_count, + }) + .select("id, version_number, source, created_at, filename") + .single(); + if (verErr || !versionRow) { + console.error("[versions/copy] insert failed", verErr); + return void res + .status(500) + .json({ detail: "Failed to record new version." }); + } + + const { error: updateDocErr } = await db + .from("documents") + .update({ + current_version_id: versionRow.id, + }) + .eq("id", documentId); + if (updateDocErr) { + console.error("[versions/copy] current version update failed", updateDocErr); + return void res + .status(500) + .json({ detail: "Failed to update document current version." }); + } + + if ( + sourceDoc.project_id && + targetDoc.project_id && + sourceDoc.project_id === targetDoc.project_id + ) { + const { error: deleteErr } = await deleteDocumentAndVersionFiles( + db, + sourceDocumentId, + ); + if (deleteErr) { + console.error("[versions/copy] source document delete failed", deleteErr); + return void res + .status(500) + .json({ detail: "Failed to delete source document." }); + } + } + + res.status(201).json(versionRow); + }, +); + // POST /single-documents/:documentId/versions // Upload a brand-new version of an existing document. The uploaded file -// becomes the new current_version_id. display_name defaults to the -// uploaded filename; client may override via the `display_name` form field. +// becomes the new current_version_id. filename defaults to the +// uploaded filename; client may override via the `filename` form field. documentsRouter.post( "/:documentId/versions", requireAuth, @@ -392,7 +589,7 @@ documentsRouter.post( const { data: doc } = await db .from("documents") - .select("id, filename, file_type, user_id, project_id") + .select("id, user_id, project_id, current_version_id") .eq("id", documentId) .single(); if (!doc) @@ -406,9 +603,17 @@ documentsRouter.post( const suffix = file.originalname.includes(".") ? file.originalname.split(".").pop()!.toLowerCase() : ""; - if (doc.file_type && suffix && doc.file_type !== suffix) { + if (!ALLOWED_TYPES.has(suffix)) { return void res.status(400).json({ - detail: `Uploaded file type (${suffix}) does not match document type (${doc.file_type}).`, + detail: `Unsupported file type: ${suffix}. Allowed: pdf, docx, doc`, + }); + } + + 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}).`, }); } @@ -469,6 +674,12 @@ documentsRouter.post( 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; + // Per-document sequential version_number — the upload is V1 and // user_upload + assistant_edit count forward from there. const { data: maxRow } = await db @@ -482,10 +693,10 @@ documentsRouter.post( const nextVersionNumber = ((maxRow?.version_number as number | null) ?? 1) + 1; - const defaultDisplayName = - typeof req.body?.display_name === "string" && - req.body.display_name.trim() - ? req.body.display_name.trim().slice(0, 200) + const requestedFilename = + typeof req.body?.filename === "string" && + req.body.filename.trim() + ? req.body.filename.trim().slice(0, 200) : file.originalname; const { data: versionRow, error: verErr } = await db @@ -496,9 +707,12 @@ documentsRouter.post( pdf_storage_path: pdfStoragePath, source: "user_upload", version_number: nextVersionNumber, - display_name: defaultDisplayName, + filename: requestedFilename, + file_type: suffix, + size_bytes: file.buffer.byteLength, + page_count: pageCount, }) - .select("id, version_number, source, created_at, display_name") + .select("id, version_number, source, created_at, filename") .single(); if (verErr || !versionRow) { console.error("[versions/upload] insert failed", verErr); @@ -507,30 +721,11 @@ documentsRouter.post( .json({ detail: "Failed to record new version." }); } - // Also propagate the user-provided display_name to the parent document's - // filename so the document's display name stays in sync across the UI. - // Preserve a sensible extension: if the display_name has none, append - // the uploaded file's extension (fallback: the existing doc's extension). - const documentsUpdate: Record<string, unknown> = { - current_version_id: versionRow.id, - }; - const providedDisplayName = - typeof req.body?.display_name === "string" && - req.body.display_name.trim() - ? req.body.display_name.trim().slice(0, 200) - : null; - if (providedDisplayName) { - const hasExt = /\.[a-z0-9]{1,6}$/i.test(providedDisplayName); - const existingExt = (doc.filename as string | null)?.match( - /\.[a-z0-9]{1,6}$/i, - )?.[0]; - const uploadedExt = suffix ? `.${suffix}` : ""; - const ext = hasExt ? "" : uploadedExt || existingExt || ""; - documentsUpdate.filename = `${providedDisplayName}${ext}`; - } await db .from("documents") - .update(documentsUpdate) + .update({ + current_version_id: versionRow.id, + }) .eq("id", documentId); res.status(201).json(versionRow); @@ -538,8 +733,7 @@ documentsRouter.post( ); // PATCH /single-documents/:documentId/versions/:versionId -// Rename a version's display_name. Pass `{ "display_name": "…" }`; an empty -// or missing value clears the override so the UI falls back to V{n}. +// Rename a version's filename. Pass `{ "filename": "…" }`. documentsRouter.patch( "/:documentId/versions/:versionId", requireAuth, @@ -560,16 +754,18 @@ documentsRouter.patch( if (!access.ok) return void res.status(404).json({ detail: "Document not found" }); - const raw = req.body?.display_name; - const displayName = + const raw = req.body?.filename; + const filename = typeof raw === "string" && raw.trim() ? raw.trim().slice(0, 200) : null; const { data: updated, error } = await db .from("document_versions") - .update({ display_name: displayName }) + .update({ filename }) .eq("id", versionId) .eq("document_id", documentId) - .select("id, version_number, source, created_at, display_name") + .select( + "id, version_number, source, created_at, filename, file_type, size_bytes, page_count", + ) .single(); if (error || !updated) { return void res.status(404).json({ detail: "Version not found" }); @@ -578,6 +774,104 @@ documentsRouter.patch( }, ); +// 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. +documentsRouter.delete( + "/:documentId/versions/:versionId", + requireAuth, + 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 { data: doc } = await db + .from("documents") + .select("id, user_id, project_id, current_version_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: versions, error: versionsErr } = await db + .from("document_versions") + .select("id, storage_path, pdf_storage_path, version_number, created_at") + .eq("document_id", documentId); + if (versionsErr) { + return void res.status(500).json({ detail: versionsErr.message }); + } + + const rows = (versions ?? []) as { + id: string; + storage_path: string | null; + pdf_storage_path: string | null; + version_number: number | null; + created_at: string | null; + }[]; + const target = rows.find((row) => row.id === versionId); + if (!target) + return void res.status(404).json({ detail: "Version not found" }); + if (rows.length <= 1) { + return void res + .status(400) + .json({ detail: "Cannot delete the only document version." }); + } + + const remaining = rows + .filter((row) => row.id !== versionId) + .sort((a, b) => { + const versionDelta = + (b.version_number ?? -1) - (a.version_number ?? -1); + if (versionDelta !== 0) return versionDelta; + return ( + new Date(b.created_at ?? 0).getTime() - + new Date(a.created_at ?? 0).getTime() + ); + }); + const nextCurrentVersionId = + doc.current_version_id === versionId + ? (remaining[0]?.id ?? null) + : doc.current_version_id; + + if (doc.current_version_id === versionId) { + const { error: updateErr } = await db + .from("documents") + .update({ + current_version_id: nextCurrentVersionId, + updated_at: new Date().toISOString(), + }) + .eq("id", documentId); + if (updateErr) { + return void res.status(500).json({ detail: updateErr.message }); + } + } + + const { error: deleteErr } = await db + .from("document_versions") + .delete() + .eq("id", versionId) + .eq("document_id", documentId); + if (deleteErr) { + return void res.status(500).json({ detail: deleteErr.message }); + } + + await Promise.all( + [target.storage_path, target.pdf_storage_path] + .filter((path): path is string => !!path) + .map((path) => deleteFile(path).catch(() => {})), + ); + + res.json({ + deleted_version_id: versionId, + current_version_id: nextCurrentVersionId, + }); + }, +); + // GET /single-documents/:documentId/tracked-change-ids // Returns the ordered list of { kind, w_id } for every w:ins / w:del in // the current (or specified) version's document.xml. The frontend uses @@ -632,7 +926,7 @@ async function handleEditResolution( const { documentId, editId } = req.params; const db = createServerSupabase(); - console.log(`[edit-resolution] incoming ${mode}`, { + devLog(`[edit-resolution] incoming ${mode}`, { userId, documentId, editId, @@ -644,31 +938,31 @@ async function handleEditResolution( .eq("id", editId) .eq("document_id", documentId) .single(); - console.log(`[edit-resolution] fetched edit row`, { edit, editErr }); + devLog(`[edit-resolution] fetched edit row`, { edit, editErr }); if (!edit) { - console.log(`[edit-resolution] edit not found, returning 404`); + devLog(`[edit-resolution] edit not found, returning 404`); return void res.status(404).json({ detail: "Edit not found" }); } // Idempotent: if the edit is already resolved, return the current doc // state so stale UI (e.g. an old chat reloaded in a new session) can // reconcile without throwing. if (edit.status !== "pending") { - console.log(`[edit-resolution] edit already resolved`, { + devLog(`[edit-resolution] edit already resolved`, { editId, status: edit.status, }); const { data: doc } = await db .from("documents") - .select("current_version_id, filename, user_id, project_id") + .select("current_version_id, user_id, project_id") .eq("id", documentId) .single(); if (!doc) { - console.log(`[edit-resolution] doc not found for resolved edit`); + devLog(`[edit-resolution] doc not found for resolved edit`); return void res.status(404).json({ detail: "Document not found" }); } const accessResolved = await ensureDocAccess(doc, userId, userEmail, db); if (!accessResolved.ok) { - console.log(`[edit-resolution] doc access denied for resolved edit`); + devLog(`[edit-resolution] doc access denied for resolved edit`); return void res.status(404).json({ detail: "Document not found" }); } const activeForResolved = await loadActiveVersion(documentId, db); @@ -680,12 +974,16 @@ async function handleEditResolution( download_url: activeForResolved ? buildDownloadUrl( activeForResolved.storage_path, - (doc.filename as string) ?? "document.docx", + downloadFilenameForVersion( + activeForResolved.filename, + activeForResolved.version_number, + activeForResolved.source === "assistant_edit", + ), ) : null, remaining_pending: 0, }; - console.log(`[edit-resolution] returning already-resolved payload`, payload); + devLog(`[edit-resolution] returning already-resolved payload`, payload); return void res.status(200).json(payload); } @@ -694,7 +992,7 @@ async function handleEditResolution( .select("id, current_version_id, user_id, project_id") .eq("id", documentId) .single(); - console.log(`[edit-resolution] fetched doc`, { doc, docErr }); + devLog(`[edit-resolution] fetched doc`, { doc, docErr }); if (!doc) return void res.status(404).json({ detail: "Document not found" }); const access = await ensureDocAccess(doc, userId, userEmail, db); @@ -703,7 +1001,7 @@ async function handleEditResolution( const active = await loadActiveVersion(documentId, db); const latestPath = active?.storage_path ?? null; - console.log(`[edit-resolution] resolved latestPath`, { + devLog(`[edit-resolution] resolved latestPath`, { latestPath, current_version_id: doc.current_version_id, }); @@ -711,7 +1009,7 @@ async function handleEditResolution( return void res.status(404).json({ detail: "No file to edit" }); const raw = await downloadFile(latestPath); - console.log(`[edit-resolution] downloaded bytes`, { + devLog(`[edit-resolution] downloaded bytes`, { byteLength: raw?.byteLength ?? 0, }); if (!raw) @@ -725,7 +1023,7 @@ async function handleEditResolution( wIds, mode, ); - console.log(`[edit-resolution] resolveTrackedChange result`, { + devLog(`[edit-resolution] resolveTrackedChange result`, { mode, change_id: edit.change_id, wIds, @@ -733,7 +1031,7 @@ async function handleEditResolution( resolvedByteLength: resolvedBytes?.byteLength ?? 0, }); if (!found) { - console.log( + devLog( `[edit-resolution] change_id not found in docx — updating status only`, ); // Still update DB status so the UI reflects the decision — the change @@ -742,22 +1040,21 @@ async function handleEditResolution( .from("document_edits") .update({ status: mode === "accept" ? "accepted" : "rejected", resolved_at: new Date().toISOString() }) .eq("id", editId); - console.log(`[edit-resolution] status-only update`, { updErr }); - const { data: filenameRow } = await db - .from("documents") - .select("filename") - .eq("id", documentId) - .single(); + devLog(`[edit-resolution] status-only update`, { updErr }); const payload = { ok: true, version_id: doc.current_version_id, download_url: buildDownloadUrl( latestPath, - (filenameRow?.filename as string) ?? "document.docx", + downloadFilenameForVersion( + active?.filename, + active?.version_number ?? null, + active?.source === "assistant_edit", + ), ), remaining_pending: 0, }; - console.log(`[edit-resolution] returning not-found payload`, payload); + devLog(`[edit-resolution] returning not-found payload`, payload); return void res.status(200).json(payload); } @@ -770,7 +1067,7 @@ async function handleEditResolution( resolvedBytes.byteOffset, resolvedBytes.byteOffset + resolvedBytes.byteLength, ) as ArrayBuffer; - console.log(`[edit-resolution] overwriting bytes in place`, { + devLog(`[edit-resolution] overwriting bytes in place`, { latestPath, byteLength: ab.byteLength, }); @@ -787,7 +1084,7 @@ async function handleEditResolution( resolved_at: new Date().toISOString(), }) .eq("id", editId); - console.log(`[edit-resolution] updated document_edits status`, { + devLog(`[edit-resolution] updated document_edits status`, { editId, newStatus: mode === "accept" ? "accepted" : "rejected", statusErr, @@ -798,23 +1095,22 @@ async function handleEditResolution( .select("id", { count: "exact", head: true }) .eq("document_id", documentId) .eq("status", "pending"); - console.log(`[edit-resolution] remaining pending count`, { remainingPending }); + devLog(`[edit-resolution] remaining pending count`, { remainingPending }); - const { data: filenameRow } = await db - .from("documents") - .select("filename") - .eq("id", documentId) - .single(); const payload = { ok: true, version_id: doc.current_version_id, download_url: buildDownloadUrl( latestPath, - (filenameRow?.filename as string) ?? "document.docx", + downloadFilenameForVersion( + active?.filename, + active?.version_number ?? null, + active?.source === "assistant_edit", + ), ), remaining_pending: remainingPending ?? 0, }; - console.log(`[edit-resolution] returning success payload`, payload); + devLog(`[edit-resolution] returning success payload`, payload); res.json(payload); } @@ -857,13 +1153,19 @@ async function handleDocumentUpload( .insert({ project_id: projectId, user_id: userId, - filename, - file_type: suffix, - size_bytes: content.byteLength, status: "processing", }) .select("*") .single(); + + if (insertErr || !doc) + console.error("[single-documents/upload] failed to create document row", { + userId, + projectId, + filename, + suffix, + error: insertErr, + }); if (insertErr || !doc) return void res .status(500) @@ -889,7 +1191,6 @@ async function handleDocumentUpload( content.byteOffset, content.byteOffset + content.byteLength, ) as ArrayBuffer; - const tree = await extractStructureTree(rawBuf, suffix, filename); const pageCount = suffix === "pdf" ? await countPdfPages(rawBuf) : null; // Convert DOCX/DOC → PDF for display. PDFs are their own rendition. @@ -928,7 +1229,10 @@ async function handleDocumentUpload( pdf_storage_path: pdfStoragePath, source: "upload", version_number: 1, - display_name: filename, + filename: filename, + file_type: suffix, + size_bytes: content.byteLength, + page_count: pageCount, }) .select("id") .single(); @@ -942,9 +1246,6 @@ async function handleDocumentUpload( .from("documents") .update({ current_version_id: versionRow.id, - size_bytes: content.byteLength, - page_count: pageCount, - structure_tree: tree ?? null, status: "ready", updated_at: new Date().toISOString(), }) @@ -957,7 +1258,16 @@ async function handleDocumentUpload( .single(); // Surface storage paths to the caller for backward compatibility. const responseDoc = updated - ? { ...updated, storage_path: key, pdf_storage_path: pdfStoragePath } + ? { + ...updated, + filename, + storage_path: key, + pdf_storage_path: pdfStoragePath, + file_type: suffix, + size_bytes: content.byteLength, + page_count: pageCount, + active_version_number: 1, + } : updated; return void res.status(201).json(responseDoc); } catch (e) { @@ -983,62 +1293,3 @@ async function countPdfPages(buf: ArrayBuffer): Promise<number | null> { return null; } } - -async function extractStructureTree( - content: ArrayBuffer, - fileType: string, - _filename: string, -): Promise<unknown[] | null> { - try { - if (fileType === "pdf") { - const pdfjsLib = await import( - "pdfjs-dist/legacy/build/pdf.mjs" as string - ); - const pdf = await ( - pdfjsLib as unknown as { - getDocument: (opts: unknown) => { - promise: Promise<{ - numPages: number; - getOutline: () => Promise<{ title?: string }[]>; - }>; - }; - } - ).getDocument({ data: new Uint8Array(content) }).promise; - if (pdf.numPages <= 5) return null; - const outline = await pdf.getOutline(); - if (outline?.length) - return outline.map((item, i) => ({ - id: `h1-${i}`, - title: item.title ?? `Item ${i + 1}`, - level: 1, - page_number: null, - children: [], - })); - return Array.from({ length: pdf.numPages }, (_, i) => ({ - id: `page-${i + 1}`, - title: `Page ${i + 1}`, - level: 1, - page_number: i + 1, - children: [], - })); - } else { - const mammoth = await import("mammoth"); - const result = await mammoth.extractRawText({ - buffer: Buffer.from(content), - }); - const lines = result.value.split("\n").filter((l) => l.trim()); - const nodes = lines - .slice(0, 30) - .map((line, i) => ({ - id: `h1-${i}`, - title: line.slice(0, 100), - level: 1, - page_number: null, - children: [], - })); - return nodes.length ? nodes : null; - } - } catch { - return null; - } -} diff --git a/backend/src/routes/projectChat.ts b/backend/src/routes/projectChat.ts index 5e29961..d376d49 100644 --- a/backend/src/routes/projectChat.ts +++ b/backend/src/routes/projectChat.ts @@ -6,8 +6,11 @@ import { buildMessages, buildWorkflowStore, enrichWithPriorEvents, + AssistantStreamError, extractAnnotations, + isAbortError, runLLMStream, + stripTransientAssistantEvents, PROJECT_EXTRA_TOOLS, type ChatMessage, } from "../lib/chatTools"; @@ -151,13 +154,18 @@ projectChatRouter.post("/", requireAuth, async (req, res) => { res.flushHeaders(); const write = (line: string) => res.write(line); + const streamAbort = new AbortController(); + let streamFinished = false; + res.on("close", () => { + if (!streamFinished) streamAbort.abort(); + }); const apiKeys = await getUserApiKeys(userId, db); try { write(`data: ${JSON.stringify({ type: "chat_id", chatId })}\n\n`); - const { fullText, events } = await runLLMStream({ + const { events, annotations } = await runLLMStream({ apiMessages, docStore, docIndex, @@ -168,14 +176,15 @@ projectChatRouter.post("/", requireAuth, async (req, res) => { workflowStore, model, apiKeys, + signal: streamAbort.signal, projectId, }); - const annotations = extractAnnotations(fullText, docIndex, events); + const persistedEvents = stripTransientAssistantEvents(events); await db.from("chat_messages").insert({ chat_id: chatId, role: "assistant", - content: events.length ? events : null, + content: persistedEvents.length ? persistedEvents : null, annotations: annotations.length ? annotations : null, }); @@ -186,16 +195,47 @@ projectChatRouter.post("/", requireAuth, async (req, res) => { .eq("id", chatId); } } catch (err) { + if (isAbortError(err)) { + console.log("[project-chat/stream] client aborted stream", { + chatId, + }); + return; + } console.error("[project-chat/stream] error:", err); + const message = + err instanceof Error && err.message ? err.message : "Stream error"; + const errorEvents = err instanceof AssistantStreamError + ? stripTransientAssistantEvents(err.events) + : [{ type: "error" as const, message }]; + const errorFullText = + err instanceof AssistantStreamError ? err.fullText : ""; + try { + const annotations = extractAnnotations( + errorFullText, + docIndex, + errorEvents, + ); + const { error: saveError } = await db.from("chat_messages").insert({ + chat_id: chatId, + role: "assistant", + content: errorEvents.length ? errorEvents : null, + annotations: annotations.length ? annotations : null, + }); + if (saveError) + console.error("[project-chat/stream] failed to save error", saveError); + } catch (saveErr) { + console.error("[project-chat/stream] failed to save error", saveErr); + } try { write( - `data: ${JSON.stringify({ type: "error", message: "Stream error" })}\n\n`, + `data: ${JSON.stringify({ type: "error", message })}\n\n`, ); write("data: [DONE]\n\n"); } catch { /* ignore */ } } finally { + streamFinished = true; res.end(); } }); diff --git a/backend/src/routes/projects.ts b/backend/src/routes/projects.ts index 38e38b2..f470d21 100644 --- a/backend/src/routes/projects.ts +++ b/backend/src/routes/projects.ts @@ -6,7 +6,12 @@ import { attachActiveVersionPaths, attachLatestVersionNumbers, } from "../lib/documentVersions"; -import { downloadFile, uploadFile, storageKey } from "../lib/storage"; +import { + deleteFile, + downloadFile, + uploadFile, + storageKey, +} from "../lib/storage"; import { docxToPdf, convertedPdfKey } from "../lib/convert"; import { checkProjectAccess } from "../lib/access"; import { singleFileUpload } from "../lib/upload"; @@ -367,6 +372,10 @@ projectsRouter.post( .single(); if (!doc) return void res.status(404).json({ detail: "Document not found" }); + await attachActiveVersionPaths( + db, + [doc as { id: string; current_version_id?: string | null }], + ); // Already in this project — idempotent if (doc.project_id === projectId) return void res.json(doc); @@ -381,22 +390,49 @@ projectsRouter.post( .single(); if (error || !updated) return void res.status(500).json({ detail: "Failed to update document" }); + await attachActiveVersionPaths( + db, + [updated as { id: string; current_version_id?: string | null }], + ); return void res.json(updated); } else { // Belongs to another project → duplicate record AND copy the // underlying storage objects so each project's copy is fully // independent (edits/version bumps on one don't leak into the // other). + if (!doc.current_version_id) { + return void res + .status(404) + .json({ detail: "Source document has no active version" }); + } + + const { data: srcV } = await db + .from("document_versions") + .select( + "storage_path, pdf_storage_path, version_number, filename, source, file_type, size_bytes, page_count", + ) + .eq("id", doc.current_version_id) + .single(); + if (!srcV?.storage_path) { + return void res + .status(404) + .json({ detail: "Source document has no active version" }); + } + + const activeVersionFilename = + (srcV.filename as string | null)?.trim() || "Untitled document"; + const srcBytes = await downloadFile(srcV.storage_path); + if (!srcBytes) { + return void res + .status(500) + .json({ detail: "Failed to read source document bytes" }); + } + const { data: copy, error } = await db .from("documents") .insert({ project_id: projectId, user_id: userId, - filename: doc.filename, - file_type: doc.file_type, - size_bytes: doc.size_bytes, - page_count: doc.page_count, - structure_tree: doc.structure_tree, status: doc.status, }) .select("*") @@ -404,69 +440,90 @@ projectsRouter.post( if (error || !copy) return void res.status(500).json({ detail: "Failed to copy document" }); - let copyVersionRowId: string | null = null; - if (doc.current_version_id) { - const { data: srcV } = await db - .from("document_versions") - .select( - "storage_path, pdf_storage_path, version_number, display_name, source", - ) - .eq("id", doc.current_version_id) - .single(); - if (srcV?.storage_path) { - const srcBytes = await downloadFile(srcV.storage_path); - if (!srcBytes) { - return void res - .status(500) - .json({ detail: "Failed to read source document bytes" }); - } - const newKey = storageKey(userId, copy.id as string, doc.filename); - const contentType = - doc.file_type === "pdf" - ? "application/pdf" - : "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; - await uploadFile(newKey, srcBytes, contentType); + const newKey = storageKey( + userId, + copy.id as string, + activeVersionFilename, + ); + let newPdfPath: string | null = null; + try { + const contentType = + ((srcV.file_type as string | null) ?? doc.file_type) === "pdf" + ? "application/pdf" + : "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + await uploadFile(newKey, srcBytes, contentType); - // PDFs share one object for source + display rendition. DOCX - // store the converted PDF at a separate `converted-pdfs/` key — - // copy that too if it exists so the copy renders without going - // back through libreoffice. - let newPdfPath: string | null = null; - if (srcV.pdf_storage_path) { - if (srcV.pdf_storage_path === srcV.storage_path) { - newPdfPath = newKey; - } else { - const pdfBytes = await downloadFile(srcV.pdf_storage_path); - if (pdfBytes) { - const newPdfKey = convertedPdfKey(userId, copy.id as string); - await uploadFile(newPdfKey, pdfBytes, "application/pdf"); - newPdfPath = newPdfKey; - } + // PDFs share one object for source + display rendition. DOCX + // store the converted PDF at a separate `converted-pdfs/` key — + // copy that too if it exists so the copy renders without going + // back through libreoffice. + if (srcV.pdf_storage_path) { + if (srcV.pdf_storage_path === srcV.storage_path) { + newPdfPath = newKey; + } else { + const pdfBytes = await downloadFile(srcV.pdf_storage_path); + if (pdfBytes) { + const newPdfKey = convertedPdfKey(userId, copy.id as string); + await uploadFile(newPdfKey, pdfBytes, "application/pdf"); + newPdfPath = newPdfKey; } } - - const { data: newV } = await db - .from("document_versions") - .insert({ - document_id: copy.id, - storage_path: newKey, - pdf_storage_path: newPdfPath, - source: (srcV.source as string | null) ?? "upload", - version_number: srcV.version_number ?? 1, - display_name: srcV.display_name ?? doc.filename, - }) - .select("id") - .single(); - copyVersionRowId = (newV?.id as string | null) ?? null; - if (copyVersionRowId) { - await db - .from("documents") - .update({ current_version_id: copyVersionRowId }) - .eq("id", copy.id); - } } + + const { data: newV, error: newVError } = await db + .from("document_versions") + .insert({ + document_id: copy.id, + storage_path: newKey, + pdf_storage_path: newPdfPath, + source: (srcV.source as string | null) ?? "upload", + version_number: srcV.version_number ?? 1, + filename: activeVersionFilename, + file_type: (srcV.file_type as string | null) ?? doc.file_type, + size_bytes: + (srcV.size_bytes as number | null) ?? doc.size_bytes ?? null, + page_count: + (srcV.page_count as number | null) ?? doc.page_count ?? null, + }) + .select("id") + .single(); + const copyVersionRowId = (newV?.id as string | null) ?? null; + if (newVError || !copyVersionRowId) { + throw new Error( + `Failed to create copied document version: ${newVError?.message ?? "unknown"}`, + ); + } + + const { data: updatedCopy, error: updateCopyError } = await db + .from("documents") + .update({ + current_version_id: copyVersionRowId, + }) + .eq("id", copy.id) + .select("*") + .single(); + if (updateCopyError || !updatedCopy) { + throw new Error( + `Failed to activate copied document version: ${updateCopyError?.message ?? "unknown"}`, + ); + } + + await attachActiveVersionPaths( + db, + [updatedCopy as { id: string; current_version_id?: string | null }], + ); + return void res.status(201).json(updatedCopy); + } catch (err) { + console.error("[projects/documents/copy] failed", err); + await Promise.all([ + deleteFile(newKey).catch(() => {}), + newPdfPath && newPdfPath !== newKey + ? deleteFile(newPdfPath).catch(() => {}) + : Promise.resolve(), + db.from("documents").delete().eq("id", copy.id), + ]); + return void res.status(500).json({ detail: "Failed to copy document" }); } - return void res.status(201).json(copy); } }, ); @@ -484,20 +541,33 @@ projectsRouter.patch("/:projectId/documents/:documentId", requireAuth, async (re const { data: doc } = await db .from("documents") - .select("id, filename, current_version_id") + .select("id, current_version_id") .eq("id", documentId) .eq("project_id", projectId) .single(); if (!doc) return void res.status(404).json({ detail: "Document not found" }); - const filename = normalizeDocumentFilename(req.body?.filename, doc.filename as string); + const active = doc.current_version_id + ? await db + .from("document_versions") + .select("filename") + .eq("id", doc.current_version_id) + .eq("document_id", documentId) + .single() + : null; + const currentName = + typeof active?.data?.filename === "string" && + active.data.filename.trim() + ? active.data.filename.trim() + : "Untitled document"; + const filename = normalizeDocumentFilename(req.body?.filename, currentName); if (!filename) return void res.status(400).json({ detail: "filename is required" }); const { data: updated, error } = await db .from("documents") - .update({ filename, updated_at: new Date().toISOString() }) + .update({ updated_at: new Date().toISOString() }) .eq("id", documentId) .eq("project_id", projectId) .select("*") @@ -508,12 +578,15 @@ projectsRouter.patch("/:projectId/documents/:documentId", requireAuth, async (re if (doc.current_version_id) { await db .from("document_versions") - .update({ display_name: filename }) + .update({ filename }) .eq("id", doc.current_version_id) .eq("document_id", documentId); } - res.json(updated); + res.json({ + ...updated, + filename, + }); }); // POST /projects/:projectId/documents @@ -714,9 +787,6 @@ export async function handleDocumentUpload( .insert({ project_id: projectId, user_id: userId, - filename, - file_type: suffix, - size_bytes: content.byteLength, status: "processing", }) .select("*") @@ -747,7 +817,6 @@ export async function handleDocumentUpload( content.byteOffset, content.byteOffset + content.byteLength, ) as ArrayBuffer; - const tree = await extractStructureTree(rawBuf, suffix, filename); const pageCount = suffix === "pdf" ? await countPdfPages(rawBuf) : null; // Convert DOCX/DOC → PDF for display. PDFs are their own rendition. @@ -785,7 +854,10 @@ export async function handleDocumentUpload( pdf_storage_path: pdfStoragePath, source: "upload", version_number: 1, - display_name: filename, + filename, + file_type: suffix, + size_bytes: content.byteLength, + page_count: pageCount, }) .select("id") .single(); @@ -799,9 +871,6 @@ export async function handleDocumentUpload( .from("documents") .update({ current_version_id: versionRow.id, - size_bytes: content.byteLength, - page_count: pageCount, - structure_tree: tree ?? null, status: "ready", updated_at: new Date().toISOString(), }) @@ -813,10 +882,15 @@ export async function handleDocumentUpload( .eq("id", docId) .single(); const responseDoc = updated - ? { + ? { ...updated, + filename, storage_path: key, pdf_storage_path: pdfStoragePath, + file_type: suffix, + size_bytes: content.byteLength, + page_count: pageCount, + active_version_number: 1, } : updated; return void res.status(201).json(responseDoc); @@ -843,63 +917,3 @@ async function countPdfPages(buf: ArrayBuffer): Promise<number | null> { return null; } } - -async function extractStructureTree( - content: ArrayBuffer, - fileType: string, - filename: string, -): Promise<unknown[] | null> { - try { - if (fileType === "pdf") { - const pdfjsLib = await import( - "pdfjs-dist/legacy/build/pdf.mjs" as string - ); - const pdf = await ( - pdfjsLib as unknown as { - getDocument: (opts: unknown) => { - promise: Promise<{ - numPages: number; - getOutline: () => Promise<{ title?: string }[]>; - }>; - }; - } - ).getDocument({ data: new Uint8Array(content) }).promise; - if (pdf.numPages <= 5) return null; - const outline = await pdf.getOutline(); - if (outline?.length) { - return outline.map((item, i) => ({ - id: `h1-${i}`, - title: item.title ?? `Item ${i + 1}`, - level: 1, - page_number: null, - children: [], - })); - } - return Array.from({ length: pdf.numPages }, (_, i) => ({ - id: `page-${i + 1}`, - title: `Page ${i + 1}`, - level: 1, - page_number: i + 1, - children: [], - })); - } else { - const mammoth = await import("mammoth"); - const result = await mammoth.extractRawText({ - buffer: Buffer.from(content), - }); - const lines = result.value.split("\n").filter((l) => l.trim()); - const nodes = lines - .slice(0, 30) - .map((line, i) => ({ - id: `h1-${i}`, - title: line.slice(0, 100), - level: 1, - page_number: null, - children: [], - })); - return nodes.length ? nodes : null; - } - } catch { - return null; - } -} diff --git a/backend/src/routes/tabular.ts b/backend/src/routes/tabular.ts index 6cf6495..5bc0049 100644 --- a/backend/src/routes/tabular.ts +++ b/backend/src/routes/tabular.ts @@ -2,10 +2,16 @@ import { Router } from "express"; import { requireAuth } from "../middleware/auth"; import { createServerSupabase } from "../lib/supabase"; import { downloadFile } from "../lib/storage"; -import { loadActiveVersion } from "../lib/documentVersions"; +import { + attachActiveVersionPaths, + loadActiveVersion, +} from "../lib/documentVersions"; import { normalizeDocxZipPaths } from "../lib/convert"; import { + AssistantStreamError, + isAbortError, runLLMStream, + stripTransientAssistantEvents, TABULAR_TOOLS, type ChatMessage, type TabularCellStore, @@ -370,6 +376,11 @@ tabularRouter.get("/:reviewId", requireAuth, async (req, res) => { docIds.length > 0 ? await db.from("documents").select("*").in("id", docIds) : { data: [] as Record<string, unknown>[] }; + const docs = (docsResult.data ?? []) as unknown as { + id: string; + current_version_id?: string | null; + }[]; + await attachActiveVersionPaths(db, docs); res.json({ review: { ...review, is_owner: access.isOwner }, @@ -377,7 +388,7 @@ tabularRouter.get("/:reviewId", requireAuth, async (req, res) => { ...cell, content: parseCellContent(cell.content), })), - documents: docsResult.data ?? [], + documents: docs, }); }); @@ -471,8 +482,19 @@ tabularRouter.patch("/:reviewId", requireAuth, async (req, res) => { if (req.body.title != null) updates.title = req.body.title; if (req.body.columns_config != null) updates.columns_config = req.body.columns_config; - if (req.body.project_id !== undefined) - updates.project_id = req.body.project_id; + const projectIdUpdateProvided = req.body.project_id !== undefined; + const projectIdUpdate = + req.body.project_id === null + ? null + : typeof req.body.project_id === "string" && + req.body.project_id.trim() + ? req.body.project_id.trim() + : undefined; + if (projectIdUpdateProvided && projectIdUpdate === undefined) { + return void res.status(400).json({ + detail: "project_id must be a non-empty string or null", + }); + } // shared_with edits are owner-only — gated below after we know who's // making the call. Normalize lowercase + dedupe + drop empties. let sharedWithUpdate: string[] | undefined; @@ -519,6 +541,27 @@ tabularRouter.patch("/:reviewId", requireAuth, async (req, res) => { .json({ detail: "Only the review owner can change sharing" }); updates.shared_with = sharedWithUpdate; } + if (projectIdUpdateProvided) { + if (!access.isOwner) { + return void res.status(403).json({ + detail: "Only the review owner can move a review", + }); + } + if (projectIdUpdate) { + const projectAccess = await checkProjectAccess( + projectIdUpdate, + userId, + userEmail, + db, + ); + if (!projectAccess.ok) { + return void res + .status(404) + .json({ detail: "Target project not found" }); + } + } + updates.project_id = projectIdUpdate; + } const { data: updatedReview, error: updateError } = await db .from("tabular_reviews") @@ -744,7 +787,7 @@ tabularRouter.post( return void res.status(404).json({ detail: "Document not found" }); const { data: doc } = await db .from("documents") - .select("id, filename, file_type") + .select("id, current_version_id") .eq("id", document_id) .single(); if (!doc) @@ -776,7 +819,7 @@ tabularRouter.post( if (buf) { try { markdown = - (doc.file_type as string) === "pdf" + docActive.file_type === "pdf" ? await extractPdfMarkdown(buf) : await extractDocxMarkdown(buf); } catch (err) { @@ -790,7 +833,7 @@ tabularRouter.post( const result = await queryTabularCell( tabular_model, - doc.filename as string, + docActive?.filename?.trim() || "Untitled document", markdown, column.prompt, column.format, @@ -866,18 +909,25 @@ tabularRouter.post("/:reviewId/generate", requireAuth, async (req, res) => { filteredIds.length > 0 ? await db .from("documents") - .select("id, filename, file_type, page_count") + .select("id, current_version_id") .in("id", filteredIds) : { data: [] as Record<string, unknown>[] }; docs = data ?? []; } else if (review.project_id) { const { data } = await db .from("documents") - .select("id, filename, file_type, page_count") + .select("id, current_version_id") .eq("project_id", review.project_id) .order("created_at", { ascending: true }); docs = data ?? []; } + await attachActiveVersionPaths( + db, + docs as { + id: string; + current_version_id?: string | null; + }[], + ); const { tabular_model, api_keys } = await getUserModelSettings(userId, db); const missingKey = missingModelApiKey(tabular_model, api_keys); @@ -900,16 +950,22 @@ tabularRouter.post("/:reviewId/generate", requireAuth, async (req, res) => { await Promise.all( docs.map(async (doc) => { const docId = doc.id as string; - const filename = doc.filename as string; let markdown = ""; - const active = await loadActiveVersion(docId, db); - if (active) { - const buf = await downloadFile(active.storage_path); + const filename = + (typeof doc.filename === "string" && doc.filename.trim() + ? doc.filename.trim() + : "Untitled document"); + const storagePath = + typeof doc.storage_path === "string" ? doc.storage_path : ""; + const fileType = + typeof doc.file_type === "string" ? doc.file_type : ""; + if (storagePath) { + const buf = await downloadFile(storagePath); if (buf) { try { markdown = - (doc.file_type as string) === "pdf" + fileType === "pdf" ? await extractPdfMarkdown(buf) : await extractDocxMarkdown(buf); } catch (err) { @@ -1253,14 +1309,29 @@ tabularRouter.post("/:reviewId/chat", requireAuth, async (req, res) => { const docIds = [ ...new Set((cells ?? []).map((c: any) => c.document_id as string)), ]; - let docs: { id: string; filename: string }[] = []; + let docs: { + id: string; + filename: string; + current_version_id?: string | null; + }[] = []; if (docIds.length > 0) { const { data } = await db .from("documents") - .select("id, filename") + .select("id, current_version_id") .in("id", docIds) .order("created_at", { ascending: true }); - docs = (data ?? []) as { id: string; filename: string }[]; + const attachedDocs = (data ?? []) as { + id: string; + current_version_id?: string | null; + filename?: string | null; + }[]; + await attachActiveVersionPaths(db, attachedDocs); + docs = attachedDocs.map((doc) => ({ + ...doc, + filename: + (typeof doc.filename === "string" && doc.filename.trim()) || + "Untitled document", + })); } const sortedColumns = ( @@ -1339,6 +1410,11 @@ tabularRouter.post("/:reviewId/chat", requireAuth, async (req, res) => { res.setHeader("X-Accel-Buffering", "no"); res.flushHeaders(); const write = (line: string) => res.write(line); + const streamAbort = new AbortController(); + let streamFinished = false; + res.on("close", () => { + if (!streamFinished) streamAbort.abort(); + }); if (chatId) { write(`data: ${JSON.stringify({ type: "chat_id", chatId })}\n\n`); @@ -1353,20 +1429,23 @@ tabularRouter.post("/:reviewId/chat", requireAuth, async (req, res) => { db, write, extraTools: TABULAR_TOOLS, + includeResearchTools: false, tabularStore, buildCitations: (text) => extractTabularAnnotations(text, tabularStore), model: tabular_model, apiKeys: api_keys, + signal: streamAbort.signal, }); + const persistedEvents = stripTransientAssistantEvents(events); const annotations = extractTabularAnnotations(fullText, tabularStore); if (chatId) { await db.from("tabular_review_chat_messages").insert({ chat_id: chatId, role: "assistant", - content: events.length ? events : null, + content: persistedEvents.length ? persistedEvents : null, annotations: annotations.length ? annotations : null, }); await db @@ -1398,16 +1477,48 @@ tabularRouter.post("/:reviewId/chat", requireAuth, async (req, res) => { } } } catch (err) { + if (isAbortError(err)) { + console.log("[tabular/chat] client aborted stream", { chatId }); + return; + } console.error("[tabular/chat] error", err); + const message = + err instanceof Error && err.message ? err.message : "Stream error"; + const errorEvents = err instanceof AssistantStreamError + ? stripTransientAssistantEvents(err.events) + : [{ type: "error" as const, message }]; + const errorFullText = + err instanceof AssistantStreamError ? err.fullText : ""; + if (chatId) { + try { + const annotations = extractTabularAnnotations( + errorFullText, + tabularStore, + ); + const { error: saveError } = await db + .from("tabular_review_chat_messages") + .insert({ + chat_id: chatId, + role: "assistant", + content: errorEvents.length ? errorEvents : null, + annotations: annotations.length ? annotations : null, + }); + if (saveError) + console.error("[tabular/chat] failed to save error", saveError); + } catch (saveErr) { + console.error("[tabular/chat] failed to save error", saveErr); + } + } try { write( - `data: ${JSON.stringify({ type: "error", message: String(err) })}\n\n`, + `data: ${JSON.stringify({ type: "error", message })}\n\n`, ); write("data: [DONE]\n\n"); } catch { /* ignore */ } } finally { + streamFinished = true; res.end(); } }); diff --git a/backend/src/routes/user.ts b/backend/src/routes/user.ts index 0df2021..1b7657e 100644 --- a/backend/src/routes/user.ts +++ b/backend/src/routes/user.ts @@ -1,7 +1,13 @@ import { Router } from "express"; import { requireAuth } from "../middleware/auth"; import { createServerSupabase } from "../lib/supabase"; -import { DEFAULT_TABULAR_MODEL, resolveModel } from "../lib/llm"; +import { + DEFAULT_TABULAR_MODEL, + DEFAULT_TITLE_MODEL, + CLAUDE_LOW_MODELS, + OPENAI_LOW_MODELS, + resolveModel, +} from "../lib/llm"; import { type ApiKeyStatus, getUserApiKeyStatus, @@ -20,14 +26,85 @@ type UserProfileRow = { message_credits_used: number; credits_reset_date: string; tier: string; + title_model: string | null; tabular_model: string; }; +function errorMessage(error: unknown): string { + if (error instanceof Error && error.message) return error.message; + if (error && typeof error === "object") { + const record = error as { + message?: unknown; + details?: unknown; + hint?: unknown; + code?: unknown; + }; + return [record.message, record.details, record.hint, record.code] + .filter((value): value is string => typeof value === "string" && !!value) + .join(" ") + || JSON.stringify(error); + } + return String(error); +} + +const PROFILE_SELECT = + "display_name, organisation, message_credits_used, credits_reset_date, tier, title_model, tabular_model"; +const LEGACY_PROFILE_SELECT = + "display_name, organisation, message_credits_used, credits_reset_date, tier, tabular_model"; + +function isMissingProfileModelColumn(error: unknown): boolean { + const record = + error && typeof error === "object" + ? (error as { code?: unknown; message?: unknown }) + : {}; + const message = typeof record.message === "string" ? record.message : ""; + return ( + record.code === "42703" || + message.includes("title_model") + ); +} + +async function selectProfile( + db: ReturnType<typeof createServerSupabase>, + userId: string, + mode: "maybe" | "single", +) { + const query = db + .from("user_profiles") + .select(PROFILE_SELECT) + .eq("user_id", userId); + const result = mode === "single" ? await query.single() : await query.maybeSingle(); + if (!result.error || !isMissingProfileModelColumn(result.error)) { + return result; + } + + const legacyQuery = db + .from("user_profiles") + .select(LEGACY_PROFILE_SELECT) + .eq("user_id", userId); + const legacy = + mode === "single" ? await legacyQuery.single() : await legacyQuery.maybeSingle(); + if (legacy.data && typeof legacy.data === "object") { + const row = legacy.data as Record<string, unknown>; + Object.assign(row, { + title_model: null, + }); + } + return legacy; +} + function serializeProfile( row: UserProfileRow, apiKeyStatus?: ApiKeyStatus, ) { const creditsUsed = row.message_credits_used ?? 0; + const titleFallback = apiKeyStatus?.gemini + ? DEFAULT_TITLE_MODEL + : apiKeyStatus?.openai + ? OPENAI_LOW_MODELS[0] + : apiKeyStatus?.claude + ? CLAUDE_LOW_MODELS[0] + : DEFAULT_TITLE_MODEL; return { displayName: row.display_name, organisation: row.organisation, @@ -35,6 +112,7 @@ function serializeProfile( creditsResetDate: row.credits_reset_date, creditsRemaining: Math.max(MONTHLY_CREDIT_LIMIT - creditsUsed, 0), tier: row.tier || "Free", + titleModel: resolveModel(row.title_model, titleFallback), tabularModel: resolveModel(row.tabular_model, DEFAULT_TABULAR_MODEL), ...(apiKeyStatus ? { apiKeyStatus } : {}), }; @@ -46,6 +124,7 @@ function validateProfilePayload(body: unknown): update: { display_name?: string | null; organisation?: string | null; + title_model?: string; tabular_model?: string; updated_at: string; }; @@ -59,6 +138,7 @@ function validateProfilePayload(body: unknown): const allowedFields = new Set([ "displayName", "organisation", + "titleModel", "tabularModel", ]); const invalidField = Object.keys(raw).find((key) => !allowedFields.has(key)); @@ -69,6 +149,7 @@ function validateProfilePayload(body: unknown): const update: { display_name?: string | null; organisation?: string | null; + title_model?: string; tabular_model?: string; updated_at: string; } = { updated_at: new Date().toISOString() }; @@ -98,6 +179,17 @@ function validateProfilePayload(body: unknown): update.tabular_model = resolved; } + if ("titleModel" in raw) { + if (typeof raw.titleModel !== "string") { + return { ok: false, detail: "titleModel must be a string" }; + } + const resolved = resolveModel(raw.titleModel, ""); + if (!resolved) { + return { ok: false, detail: "Unsupported titleModel" }; + } + update.title_model = resolved; + } + return { ok: true, update }; } @@ -117,15 +209,9 @@ async function ensureProfileRow( async function loadProfile( db: ReturnType<typeof createServerSupabase>, userId: string, - options: { repairMissing?: boolean } = {}, + options: { repairMissing?: boolean; apiKeyStatus?: ApiKeyStatus } = {}, ) { - let { data, error } = await db - .from("user_profiles") - .select( - "display_name, organisation, message_credits_used, credits_reset_date, tier, tabular_model", - ) - .eq("user_id", userId) - .maybeSingle(); + let { data, error } = await selectProfile(db, userId, "maybe"); if (error) return { data: null, error }; if (!data) { @@ -136,13 +222,7 @@ async function loadProfile( const ensureError = await ensureProfileRow(db, userId); if (ensureError) return { data: null, error: ensureError }; - const created = await db - .from("user_profiles") - .select( - "display_name, organisation, message_credits_used, credits_reset_date, tier, tabular_model", - ) - .eq("user_id", userId) - .single(); + const created = await selectProfile(db, userId, "single"); if (created.error) return { data: null, error: created.error }; data = created.data; } @@ -151,24 +231,26 @@ async function loadProfile( if (row.credits_reset_date && new Date() > new Date(row.credits_reset_date)) { const creditsResetDate = new Date(); creditsResetDate.setDate(creditsResetDate.getDate() + 30); - const { data: resetData, error: resetError } = await db + const { error: resetError } = await db .from("user_profiles") .update({ message_credits_used: 0, credits_reset_date: creditsResetDate.toISOString(), updated_at: new Date().toISOString(), }) - .eq("user_id", userId) - .select( - "display_name, organisation, message_credits_used, credits_reset_date, tier, tabular_model", - ) - .single(); + .eq("user_id", userId); if (resetError) return { data: null, error: resetError }; + const { data: resetData, error: resetLoadError } = await selectProfile( + db, + userId, + "single", + ); + if (resetLoadError) return { data: null, error: resetLoadError }; row = resetData as UserProfileRow; } - return { data: serializeProfile(row), error: null }; + return { data: serializeProfile(row, options.apiKeyStatus), error: null }; } // POST /user/profile @@ -184,11 +266,12 @@ userRouter.post("/profile", requireAuth, async (_req, res) => { userRouter.get("/profile", requireAuth, async (_req, res) => { const userId = res.locals.userId as string; const db = createServerSupabase(); + const apiKeyStatus = await getUserApiKeyStatus(userId, db); const { data, error } = await loadProfile(db, userId, { repairMissing: true, + apiKeyStatus, }); if (error) return void res.status(500).json({ detail: error.message }); - const apiKeyStatus = await getUserApiKeyStatus(userId, db); res.json({ ...data, apiKeyStatus }); }); @@ -210,9 +293,9 @@ userRouter.patch("/profile", requireAuth, async (req, res) => { if (updateError) return void res.status(500).json({ detail: updateError.message }); - const { data, error } = await loadProfile(db, userId); - if (error) return void res.status(500).json({ detail: error.message }); const apiKeyStatus = await getUserApiKeyStatus(userId, db); + const { data, error } = await loadProfile(db, userId, { apiKeyStatus }); + if (error) return void res.status(500).json({ detail: error.message }); res.json({ ...data, apiKeyStatus }); }); @@ -245,11 +328,12 @@ userRouter.put("/api-keys/:provider", requireAuth, async (req, res) => { const status = await getUserApiKeyStatus(userId, db); res.json(status); } catch (err) { + const detail = errorMessage(err); console.error("[user/api-keys] save failed", { provider, - error: err instanceof Error ? err.message : String(err), + error: detail, }); - res.status(500).json({ detail: "Failed to save API key" }); + res.status(500).json({ detail }); } }); diff --git a/frontend/.env.local.example b/frontend/.env.local.example index 4e00a72..c0ceb71 100644 --- a/frontend/.env.local.example +++ b/frontend/.env.local.example @@ -1,4 +1,3 @@ NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY=your-supabase-anon-key -SUPABASE_SECRET_KEY=your-supabase-service-role-key NEXT_PUBLIC_API_BASE_URL=http://localhost:3001 diff --git a/frontend/bun.lock b/frontend/bun.lock index daf9608..30af134 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -24,6 +24,7 @@ "clsx": "^2.1.1", "docx": "^9.6.1", "docx-preview": "^0.3.7", + "dompurify": "^3.4.8", "exceljs": "^4.4.0", "katex": "^0.16.27", "lucide-react": "^0.553.0", @@ -820,6 +821,8 @@ "@types/tough-cookie": ["@types/tough-cookie@4.0.5", "", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="], + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], @@ -1144,6 +1147,8 @@ "docx-preview": ["docx-preview@0.3.7", "", { "dependencies": { "jszip": ">=3.0.0" } }, "sha512-Lav69CTA/IYZPJTsKH7oYeoZjyg96N0wEJMNslGJnZJ+dMUZK85Lt5ASC79yUlD48ecWjuv+rkcmFt6EVPV0Xg=="], + "dompurify": ["dompurify@3.4.8", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ=="], + "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], "duck": ["duck@0.1.12", "", { "dependencies": { "underscore": "^1.13.1" } }, "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg=="], diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d7445eb..198e40f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -27,6 +27,7 @@ "clsx": "^2.1.1", "docx": "^9.6.1", "docx-preview": "^0.3.7", + "dompurify": "^3.4.8", "exceljs": "^4.4.0", "katex": "^0.16.27", "lucide-react": "^0.553.0", @@ -6430,6 +6431,13 @@ "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -8741,6 +8749,15 @@ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "license": "MIT" }, + "node_modules/dompurify": { + "version": "3.4.8", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz", + "integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 2ea610b..2be1583 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -32,6 +32,7 @@ "clsx": "^2.1.1", "docx": "^9.6.1", "docx-preview": "^0.3.7", + "dompurify": "^3.4.8", "exceljs": "^4.4.0", "katex": "^0.16.27", "lucide-react": "^0.553.0", diff --git a/frontend/src/app/(pages)/account/api-keys/page.tsx b/frontend/src/app/(pages)/account/api-keys/page.tsx new file mode 100644 index 0000000..6ddf147 --- /dev/null +++ b/frontend/src/app/(pages)/account/api-keys/page.tsx @@ -0,0 +1,222 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Check, Eye, EyeOff, Save, Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useUserProfile } from "@/contexts/UserProfileContext"; + +const MODEL_API_KEY_FIELDS = [ + { + provider: "claude", + label: "Anthropic (Claude) API Key", + placeholder: "sk-ant-...", + }, + { + provider: "gemini", + label: "Google (Gemini) API Key", + placeholder: "AI...", + }, + { + provider: "openai", + label: "OpenAI API Key", + placeholder: "sk-...", + }, + { + provider: "openrouter", + label: "OpenRouter API Key", + placeholder: "sk-or-...", + }, +] as const; + +const OTHER_API_KEY_FIELDS = [ + { + provider: "courtlistener", + label: "CourtListener API Key", + placeholder: "Token...", + description: + "Add a CourtListener API key if you want the latest CourtListener data. Otherwise, Mike will use the bulk data hosted by us.", + }, +] as const; + +export default function ApiKeysPage() { + const { profile, updateApiKey } = useUserProfile(); + + return ( + <div> + <h2 className="mb-3 text-2xl font-medium font-serif text-gray-900"> + API Keys + </h2> + <p className="text-sm text-gray-500 mb-4"> + You must provide your own API keys for the app to work or add + your API keys into the .env file if you are running your own + instance of Mike. All API keys are encrypted in storage. + </p> + <div className="overflow-hidden rounded-xl border border-gray-200 bg-white divide-y divide-gray-200"> + {MODEL_API_KEY_FIELDS.map((field) => ( + <ApiKeyField + key={field.provider} + label={field.label} + placeholder={field.placeholder} + hasSavedKey={ + !!profile?.apiKeys[field.provider].configured + } + isServerConfigured={ + profile?.apiKeys[field.provider].source === "env" + } + onSave={(value) => + updateApiKey(field.provider, value.trim() || null) + } + onRemove={() => updateApiKey(field.provider, null)} + /> + ))} + </div> + + <div className="mt-8 overflow-hidden rounded-xl border border-gray-200 bg-white divide-y divide-gray-200"> + {OTHER_API_KEY_FIELDS.map((field) => ( + <ApiKeyField + key={field.provider} + label={field.label} + description={field.description} + placeholder={field.placeholder} + hasSavedKey={ + !!profile?.apiKeys[field.provider].configured + } + isServerConfigured={ + profile?.apiKeys[field.provider].source === "env" + } + onSave={(value) => + updateApiKey(field.provider, value.trim() || null) + } + onRemove={() => updateApiKey(field.provider, null)} + /> + ))} + </div> + </div> + ); +} + +function ApiKeyField({ + label, + description, + placeholder, + hasSavedKey, + isServerConfigured, + onSave, + onRemove, +}: { + label: string; + description?: string; + placeholder: string; + hasSavedKey: boolean; + isServerConfigured: boolean; + onSave: (value: string) => Promise<boolean>; + onRemove: () => Promise<boolean>; +}) { + const [value, setValue] = useState(""); + const [reveal, setReveal] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [saved, setSaved] = useState(false); + + useEffect(() => { + setValue(""); + }, [hasSavedKey]); + + const dirty = value.trim().length > 0; + + const handleSave = async () => { + setIsSaving(true); + const ok = await onSave(value); + setIsSaving(false); + if (ok) { + setValue(""); + setSaved(true); + setTimeout(() => setSaved(false), 2000); + } else { + alert(`Failed to save ${label}.`); + } + }; + + const handleRemove = async () => { + setIsSaving(true); + const ok = await onRemove(); + setIsSaving(false); + if (!ok) alert(`Failed to remove ${label}.`); + }; + + return ( + <div className="px-4 py-5"> + <label className="text-sm font-medium text-gray-700 block mb-2"> + {label} + </label> + {description && ( + <p className="text-sm text-gray-500 mb-3">{description}</p> + )} + <div className="flex gap-2"> + <div className="relative flex-1"> + <Input + type={reveal ? "text" : "password"} + value={value} + onChange={(e) => setValue(e.target.value)} + placeholder={ + isServerConfigured + ? "Server .env key configured" + : hasSavedKey + ? "Saved key hidden" + : placeholder + } + className="bg-gray-50 pr-10 shadow-none disabled:text-gray-700 disabled:placeholder:text-gray-700" + autoComplete="off" + spellCheck={false} + disabled={isServerConfigured} + /> + <button + type="button" + onClick={() => setReveal((r) => !r)} + disabled={isServerConfigured} + className="absolute inset-y-0 right-2 flex items-center text-gray-400 hover:text-gray-600 disabled:cursor-not-allowed disabled:opacity-40" + aria-label={reveal ? "Hide key" : "Show key"} + > + {reveal ? ( + <EyeOff className="h-4 w-4" /> + ) : ( + <Eye className="h-4 w-4" /> + )} + </button> + </div> + <Button + onClick={handleSave} + variant="outline" + disabled={isServerConfigured || isSaving || !dirty || saved} + className="h-9 min-w-[74px] gap-1.5 bg-white px-2.5 text-xs text-gray-700 shadow-none hover:bg-gray-50" + > + {isSaving ? ( + "Saving..." + ) : saved ? ( + <> + <Check className="h-3.5 w-3.5" /> + Saved + </> + ) : ( + <> + <Save className="h-3.5 w-3.5" /> + Save + </> + )} + </Button> + {hasSavedKey && !isServerConfigured && ( + <Button + type="button" + variant="outline" + onClick={handleRemove} + disabled={isSaving} + className="h-9 gap-1.5 bg-white px-2.5 text-xs text-red-600 shadow-none hover:bg-red-50 hover:text-red-700" + > + <Trash2 className="h-3.5 w-3.5" /> + Remove + </Button> + )} + </div> + </div> + ); +} diff --git a/frontend/src/app/(pages)/account/layout.tsx b/frontend/src/app/(pages)/account/layout.tsx index 32ceb0a..d475b1f 100644 --- a/frontend/src/app/(pages)/account/layout.tsx +++ b/frontend/src/app/(pages)/account/layout.tsx @@ -13,7 +13,8 @@ interface TabDef { const TABS: TabDef[] = [ { id: "general", label: "General", href: "/account" }, - { id: "models", label: "Models & API Keys", href: "/account/models" }, + { id: "models", label: "Model Preferences", href: "/account/models" }, + { id: "api-keys", label: "API Keys", href: "/account/api-keys" }, ]; export default function AccountLayout({ @@ -33,7 +34,7 @@ export default function AccountLayout({ if (authLoading) { return ( - <div className="h-dvh bg-white flex items-center justify-center"> + <div className="h-dvh flex items-center justify-center"> <Loader2 className="h-8 w-8 animate-spin text-blue-600" /> </div> ); diff --git a/frontend/src/app/(pages)/account/models/page.tsx b/frontend/src/app/(pages)/account/models/page.tsx index c83d681..55039e3 100644 --- a/frontend/src/app/(pages)/account/models/page.tsx +++ b/frontend/src/app/(pages)/account/models/page.tsx @@ -1,9 +1,7 @@ "use client"; -import { useEffect, useState } from "react"; -import { AlertCircle, Check, ChevronDown, Eye, EyeOff } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; +import { useEffect, useRef, useState } from "react"; +import { AlertCircle, Check, ChevronDown, Loader2 } from "lucide-react"; import { DropdownMenu, DropdownMenuContent, @@ -14,123 +12,133 @@ import { } from "@/components/ui/dropdown-menu"; import { useUserProfile } from "@/contexts/UserProfileContext"; import type { ApiKeyState } from "@/app/lib/mikeApi"; -import { MODELS } from "@/app/components/assistant/ModelToggle"; +import { + MODELS, + SETTINGS_MODELS, + type ModelOption, +} from "@/app/components/assistant/ModelToggle"; import { isModelAvailable, modelGroupToProvider, providerLabel, } from "@/app/lib/modelAvailability"; -const API_KEY_FIELDS = [ - { - provider: "claude", - label: "Anthropic (Claude) API Key", - placeholder: "sk-ant-…", - }, - { - provider: "gemini", - label: "Google (Gemini) API Key", - placeholder: "AI…", - }, - { - provider: "openai", - label: "OpenAI API Key", - placeholder: "sk-…", - }, -] as const; +type ModelPreferenceField = "titleModel" | "tabularModel"; -export default function ModelsAndApiKeysPage() { - const { profile, updateModelPreference, updateApiKey } = useUserProfile(); +export default function ModelPreferencesPage() { + const { profile, updateModelPreference } = useUserProfile(); + const [savingField, setSavingField] = useState<ModelPreferenceField | null>( + null, + ); + const [savedField, setSavedField] = useState<ModelPreferenceField | null>( + null, + ); + const [optimisticValues, setOptimisticValues] = useState< + Partial<Record<ModelPreferenceField, string>> + >({}); + const savedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); + + useEffect(() => { + return () => { + if (savedTimerRef.current) clearTimeout(savedTimerRef.current); + }; + }, []); + + const handleModelChange = async ( + field: ModelPreferenceField, + id: string, + ) => { + setOptimisticValues((current) => ({ ...current, [field]: id })); + setSavedField(null); + setSavingField(field); + const ok = await updateModelPreference(field, id); + setSavingField((current) => (current === field ? null : current)); + if (ok) { + setSavedField(field); + if (savedTimerRef.current) clearTimeout(savedTimerRef.current); + savedTimerRef.current = setTimeout(() => { + setSavedField((current) => (current === field ? null : current)); + }, 1600); + } else { + setOptimisticValues((current) => { + const next = { ...current }; + delete next[field]; + return next; + }); + } + }; return ( - <div className="space-y-4"> - {/* Model Preferences */} - <div className="pb-6"> - <div className="flex items-center gap-2 mb-4"> - <h2 className="text-2xl font-medium font-serif"> - Model Preferences - </h2> - </div> - <div className="space-y-4 max-w-md"> - <div> - <label className="text-sm text-gray-600 block mb-2"> - Tabular review model - </label> - <p className="text-xs text-gray-400 mb-2"> - We recommend using a smaller model for tabular - reviews to reduce token costs. - </p> - <TabularModelDropdown - value={ - profile?.tabularModel ?? - "gemini-3-flash-preview" - } - apiKeys={profile?.apiKeys} - onChange={(id) => - updateModelPreference("tabularModel", id) - } - /> - </div> - </div> + <div> + <div className="flex items-center gap-2 mb-4"> + <h2 className="text-2xl font-medium font-serif"> + Model Preferences + </h2> </div> - - {/* API Keys */} - <div className="py-6"> - <div className="flex items-center gap-2 mb-2"> - <h2 className="text-2xl font-medium font-serif"> - API Keys - </h2> + <div className="overflow-hidden rounded-xl border border-gray-200 bg-white divide-y divide-gray-200"> + <div className="px-4 py-5"> + <label className="text-sm font-medium text-gray-700 block mb-2"> + Title generation model + </label> + <p className="text-xs text-gray-400 mb-2"> + Used for naming chats and other lightweight titles. + </p> + <ModelPreferenceDropdown + value={ + optimisticValues.titleModel ?? + profile?.titleModel ?? + "gemini-3.1-flash-lite-preview" + } + options={SETTINGS_MODELS} + apiKeys={profile?.apiKeys} + isSaving={savingField === "titleModel"} + isSaved={savedField === "titleModel"} + onChange={(id) => handleModelChange("titleModel", id)} + /> </div> - <p className="text-sm text-gray-500 mb-4 max-w-xl"> - You must provide your own API keys for the app to work or - add your API keys into the .env file if you are running your - own instance of Mike. - </p> - <p className="text-xs text-gray-400 mb-4 max-w-xl"> - Title generation automatically routes to the cheapest - configured provider model. - </p> - <div className="space-y-4 max-w-xl"> - {API_KEY_FIELDS.map((field) => ( - <ApiKeyField - key={field.provider} - label={field.label} - placeholder={field.placeholder} - hasSavedKey={ - !!profile?.apiKeys[field.provider].configured - } - isServerConfigured={ - profile?.apiKeys[field.provider].source === - "env" - } - onSave={(value) => - updateApiKey( - field.provider, - value.trim() || null, - ) - } - onRemove={() => - updateApiKey(field.provider, null) - } - /> - ))} + <div className="px-4 py-5"> + <label className="text-sm font-medium text-gray-700 block mb-2"> + Tabular review model + </label> + <p className="text-xs text-gray-400 mb-2"> + We recommend using a smaller model for tabular reviews + to reduce token costs. + </p> + <ModelPreferenceDropdown + value={ + optimisticValues.tabularModel ?? + profile?.tabularModel ?? + "gemini-3-flash-preview" + } + options={MODELS} + apiKeys={profile?.apiKeys} + isSaving={savingField === "tabularModel"} + isSaved={savedField === "tabularModel"} + onChange={(id) => handleModelChange("tabularModel", id)} + /> </div> </div> </div> ); } -function TabularModelDropdown({ +function ModelPreferenceDropdown({ value, onChange, apiKeys, + options, + isSaving, + isSaved, }: { value: string; onChange: (id: string) => void; apiKeys?: ApiKeyState; + options: ModelOption[]; + isSaving?: boolean; + isSaved?: boolean; }) { const [isOpen, setIsOpen] = useState(false); - const selected = MODELS.find((m) => m.id === value); + const selected = options.find((m) => m.id === value); const selectedAvailable = apiKeys ? isModelAvailable(value, apiKeys) : true; const groups: ("Anthropic" | "Google" | "OpenAI")[] = [ "Anthropic", @@ -143,7 +151,8 @@ function TabularModelDropdown({ <DropdownMenuTrigger asChild> <button type="button" - className="w-full h-9 rounded-md border border-gray-300 bg-white px-3 text-sm shadow-sm flex items-center justify-between gap-2 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-black/10" + disabled={isSaving} + className="w-full h-9 rounded-md border border-gray-300 bg-gray-50 px-3 text-sm flex items-center justify-between gap-2 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-black/10" > <span className="flex items-center gap-2 min-w-0"> {!selectedAvailable && ( @@ -153,9 +162,15 @@ function TabularModelDropdown({ {selected?.label ?? "Select a model"} </span> </span> - <ChevronDown - className={`h-3.5 w-3.5 shrink-0 text-gray-500 transition-transform duration-200 ${isOpen ? "rotate-180" : ""}`} - /> + {isSaving ? ( + <Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-gray-500" /> + ) : isSaved ? ( + <Check className="h-3.5 w-3.5 shrink-0 text-green-600" /> + ) : ( + <ChevronDown + className={`h-3.5 w-3.5 shrink-0 text-gray-500 transition-transform duration-200 ${isOpen ? "rotate-180" : ""}`} + /> + )} </button> </DropdownMenuTrigger> <DropdownMenuContent @@ -164,7 +179,7 @@ function TabularModelDropdown({ align="start" > {groups.map((group, gi) => { - const items = MODELS.filter((m) => m.group === group); + const items = options.filter((m) => m.group === group); if (items.length === 0) return null; return ( <div key={group}> @@ -209,133 +224,3 @@ function TabularModelDropdown({ </DropdownMenu> ); } - -function ApiKeyField({ - label, - placeholder, - hasSavedKey, - isServerConfigured, - onSave, - onRemove, -}: { - label: string; - placeholder: string; - hasSavedKey: boolean; - isServerConfigured: boolean; - onSave: (value: string) => Promise<boolean>; - onRemove: () => Promise<boolean>; -}) { - const [value, setValue] = useState(""); - const [reveal, setReveal] = useState(false); - const [isSaving, setIsSaving] = useState(false); - const [saved, setSaved] = useState(false); - - useEffect(() => { - setValue(""); - }, [hasSavedKey]); - - const dirty = value.trim().length > 0; - - const handleSave = async () => { - setIsSaving(true); - const ok = await onSave(value); - setIsSaving(false); - if (ok) { - setValue(""); - setSaved(true); - setTimeout(() => setSaved(false), 2000); - } else { - alert(`Failed to save ${label}.`); - } - }; - - const handleRemove = async () => { - setIsSaving(true); - const ok = await onRemove(); - setIsSaving(false); - if (!ok) alert(`Failed to remove ${label}.`); - }; - - return ( - <div> - <label className="text-sm text-gray-600 block mb-2">{label}</label> - {isServerConfigured && ( - <div className="mb-2 rounded-md border border-blue-100 bg-blue-50 px-3 py-2"> - <p className="text-xs text-blue-800"> - A server .env key is configured for this provider. - Browser API-key edits are disabled. - </p> - {hasSavedKey && ( - <p className="mt-1 text-xs text-blue-800"> - The server key will be used for this provider. - </p> - )} - </div> - )} - {hasSavedKey && !isServerConfigured && ( - <p className="text-xs text-gray-500 mb-2"> - A key is saved. Paste a new key to replace it. - </p> - )} - <div className="flex gap-2"> - <div className="relative flex-1"> - <Input - type={reveal ? "text" : "password"} - value={value} - onChange={(e) => setValue(e.target.value)} - placeholder={ - isServerConfigured - ? "Server .env key configured" - : hasSavedKey - ? "Saved key hidden" - : placeholder - } - className="pr-10" - autoComplete="off" - spellCheck={false} - disabled={isServerConfigured} - /> - <button - type="button" - onClick={() => setReveal((r) => !r)} - disabled={isServerConfigured} - className="absolute inset-y-0 right-2 flex items-center text-gray-400 hover:text-gray-600 disabled:cursor-not-allowed disabled:opacity-40" - aria-label={reveal ? "Hide key" : "Show key"} - > - {reveal ? ( - <EyeOff className="h-4 w-4" /> - ) : ( - <Eye className="h-4 w-4" /> - )} - </button> - </div> - <Button - onClick={handleSave} - disabled={isServerConfigured || isSaving || !dirty || saved} - className="min-w-[80px] transition-all bg-black hover:bg-gray-900 text-white" - > - {isSaving ? ( - "Saving..." - ) : saved ? ( - <> - <Check className="h-4 w-3" /> - Saved - </> - ) : ( - "Save" - )} - </Button> - {hasSavedKey && !isServerConfigured && ( - <Button - type="button" - variant="outline" - onClick={handleRemove} - disabled={isSaving} - > - Remove - </Button> - )} - </div> - </div> - ); -} diff --git a/frontend/src/app/(pages)/account/page.tsx b/frontend/src/app/(pages)/account/page.tsx index 1c18aa4..4361584 100644 --- a/frontend/src/app/(pages)/account/page.tsx +++ b/frontend/src/app/(pages)/account/page.tsx @@ -4,7 +4,7 @@ import { useState, useEffect } from "react"; import { useRouter } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { LogOut, Check } from "lucide-react"; +import { LogOut, Check, Save } from "lucide-react"; import { useAuth } from "@/contexts/AuthContext"; import { useUserProfile } from "@/contexts/UserProfileContext"; import { deleteAccount } from "@/app/lib/mikeApi"; @@ -78,163 +78,188 @@ export default function AccountPage() { if (!user) return null; return ( - <div className="space-y-4"> + <div className="space-y-8"> {/* Profile Settings */} - <div className="pb-6"> - <div className="flex items-center gap-2 mb-4"> - <h2 className="text-2xl font-medium font-serif">Profile</h2> - </div> - <div className="space-y-4"> - <div> - <label className="text-sm text-gray-600 block mb-2"> - Display Name - </label> - <div className="flex gap-2"> + <section className="space-y-3"> + <h2 className="text-2xl font-medium font-serif text-gray-900"> + Profile + </h2> + <div className="overflow-hidden rounded-xl border border-gray-200 bg-white p-4"> + <div className="space-y-4"> + <div> + <label className="text-sm text-gray-600 block mb-2"> + Display Name + </label> + <div className="flex gap-2"> + <Input + type="text" + value={displayName} + onChange={(e) => + setDisplayName(e.target.value) + } + placeholder="Enter your name" + className="flex-1 bg-gray-50 shadow-none" + /> + <Button + onClick={handleSaveDisplayName} + variant="outline" + disabled={ + isSavingName || + !displayName.trim() || + saved + } + className="h-9 min-w-[74px] gap-1.5 bg-white px-2.5 text-xs text-gray-700 shadow-none hover:bg-gray-50" + > + {isSavingName ? ( + "Saving..." + ) : saved ? ( + <> + <Check className="h-3.5 w-3.5" /> + Saved + </> + ) : ( + <> + <Save className="h-3.5 w-3.5" /> + Save + </> + )} + </Button> + </div> + </div> + <div> + <label className="text-sm text-gray-600 block mb-2"> + Organisation + </label> + <div className="flex gap-2"> + <Input + type="text" + value={organisation} + onChange={(e) => + setOrganisation(e.target.value) + } + placeholder="Enter your organisation" + className="flex-1 bg-gray-50 shadow-none" + /> + <Button + onClick={handleSaveOrganisation} + variant="outline" + disabled={ + isSavingOrg || + organisation.trim() === + (profile?.organisation ?? "") || + orgSaved + } + className="h-9 min-w-[74px] gap-1.5 bg-white px-2.5 text-xs text-gray-700 shadow-none hover:bg-gray-50" + > + {isSavingOrg ? ( + "Saving..." + ) : orgSaved ? ( + <> + <Check className="h-3.5 w-3.5" /> + Saved + </> + ) : ( + <> + <Save className="h-3.5 w-3.5" /> + Save + </> + )} + </Button> + </div> + </div> + <div> + <label className="text-sm text-gray-600 block mb-2"> + Email + </label> <Input - type="text" - value={displayName} - onChange={(e) => setDisplayName(e.target.value)} - placeholder="Enter your name" - className="flex-1" + type="email" + value={user?.email ?? ""} + disabled + className="bg-gray-50 shadow-none disabled:text-gray-700 disabled:opacity-100" /> - <Button - onClick={handleSaveDisplayName} - disabled={ - isSavingName || !displayName.trim() || saved - } - className="min-w-[80px] transition-all bg-black hover:bg-gray-900 text-white" - > - {isSavingName ? ( - "Saving..." - ) : saved ? ( - <> - <Check className="h-4 w-3" /> - Saved - </> - ) : ( - "Save" - )} - </Button> </div> </div> - <div> - <label className="text-sm text-gray-600 block mb-2"> - Organisation - </label> - <div className="flex gap-2"> - <Input - type="text" - value={organisation} - onChange={(e) => - setOrganisation(e.target.value) - } - placeholder="Enter your organisation" - className="flex-1" - /> - <Button - onClick={handleSaveOrganisation} - disabled={ - isSavingOrg || - organisation.trim() === - (profile?.organisation ?? "") || - orgSaved - } - className="min-w-[80px] transition-all bg-black hover:bg-gray-900 text-white" - > - {isSavingOrg ? ( - "Saving..." - ) : orgSaved ? ( - <> - <Check className="h-4 w-3" /> - Saved - </> - ) : ( - "Save" - )} - </Button> - </div> - </div> - <div> - <label className="text-sm text-gray-600 block mb-2"> - Email - </label> - <p className="text-base">{user?.email}</p> - </div> </div> - </div> + </section> {/* Plan */} - <div className="py-6"> - <div className="flex items-center gap-2 mb-4"> - <h2 className="text-2xl font-medium font-serif"> - Usage Plan - </h2> + <section className="space-y-3"> + <h2 className="text-2xl font-medium font-serif text-gray-900"> + Usage Plan + </h2> + <div className="overflow-hidden rounded-xl border border-gray-200 bg-white p-4"> + <div> + <p className="text-base font-medium text-gray-500 capitalize"> + {profile?.tier || "Free"} + </p> + </div> </div> - <div> - <p className="text-base font-medium text-gray-500 capitalize"> - {profile?.tier || "Free"} - </p> - </div> - </div> + </section> {/* Actions */} - <div className="py-6"> - <h2 className="text-2xl font-medium font-serif mb-4"> + <section className="space-y-3"> + <h2 className="text-2xl font-medium font-serif text-gray-900"> Actions </h2> - <Button - variant="outline" - onClick={handleLogout} - className="w-full sm:w-auto" - > - <LogOut className="h-4 w-4 mr-2" /> - Sign Out - </Button> - </div> - - {/* Danger Zone */} - <div className="py-6"> - <h2 className="text-2xl font-medium font-serif mb-1 text-red-600"> - Danger Zone - </h2> - <p className="text-sm text-gray-500 mb-4"> - Permanently delete your account and all associated data. - This action cannot be undone. - </p> - {deleteConfirm ? ( - <div className="rounded-lg border border-red-200 bg-red-50 p-4 space-y-3 max-w-sm"> - <p className="text-sm font-medium text-red-700"> - Are you sure? This will permanently delete your - account. - </p> - <div className="flex gap-2"> - <Button - variant="outline" - onClick={() => setDeleteConfirm(false)} - disabled={isDeleting} - className="text-sm" - > - Cancel - </Button> - <Button - onClick={handleDeleteAccount} - disabled={isDeleting} - className="text-sm bg-red-600 hover:bg-red-700 text-white" - > - {isDeleting ? "Deleting…" : "Delete Account"} - </Button> - </div> - </div> - ) : ( + <div className="overflow-hidden rounded-xl border border-gray-200 bg-white p-4"> <Button variant="outline" - onClick={() => setDeleteConfirm(true)} - className="w-full sm:w-auto border-red-200 text-red-600 hover:bg-red-50 hover:text-red-700" + onClick={handleLogout} + className="w-full shadow-none sm:w-auto" > - Delete Account + <LogOut className="h-4 w-4 mr-2" /> + Sign Out </Button> - )} - </div> + </div> + </section> + + {/* Danger Zone */} + <section className="space-y-3"> + <h2 className="text-2xl font-medium font-serif text-red-600"> + Danger Zone + </h2> + <div className="overflow-hidden rounded-xl border border-gray-200 bg-white p-4"> + <p className="text-sm text-gray-500 mb-4"> + Permanently delete your account and all associated data. + This action cannot be undone. + </p> + {deleteConfirm ? ( + <div className="rounded-lg border border-red-200 bg-red-50 p-4 space-y-3 max-w-sm"> + <p className="text-sm font-medium text-red-700"> + Are you sure? This will permanently delete your + account. + </p> + <div className="flex gap-2"> + <Button + variant="outline" + onClick={() => setDeleteConfirm(false)} + disabled={isDeleting} + className="text-sm shadow-none" + > + Cancel + </Button> + <Button + onClick={handleDeleteAccount} + disabled={isDeleting} + className="bg-red-600 text-sm text-white shadow-none hover:bg-red-700" + > + {isDeleting + ? "Deleting…" + : "Delete Account"} + </Button> + </div> + </div> + ) : ( + <Button + variant="outline" + onClick={() => setDeleteConfirm(true)} + className="w-full border-red-200 text-red-600 shadow-none hover:bg-red-50 hover:text-red-700 sm:w-auto" + > + Delete Account + </Button> + )} + </div> + </section> </div> ); } diff --git a/frontend/src/app/(pages)/assistant/chat/[id]/page.tsx b/frontend/src/app/(pages)/assistant/chat/[id]/page.tsx index 1c3847f..0046d5c 100644 --- a/frontend/src/app/(pages)/assistant/chat/[id]/page.tsx +++ b/frontend/src/app/(pages)/assistant/chat/[id]/page.tsx @@ -61,6 +61,7 @@ export default function AssistantChatPage() { return ( <ChatView + chatId={id} messages={messages} isResponseLoading={isResponseLoading} handleChat={handleChat} diff --git a/frontend/src/app/(pages)/assistant/page.tsx b/frontend/src/app/(pages)/assistant/page.tsx index 8ba06f3..48ef56e 100644 --- a/frontend/src/app/(pages)/assistant/page.tsx +++ b/frontend/src/app/(pages)/assistant/page.tsx @@ -4,14 +4,20 @@ import { useRouter } from "next/navigation"; import { useAssistantChat } from "@/app/hooks/useAssistantChat"; import { InitialView } from "@/app/components/assistant/InitialView"; import { ChatView } from "@/app/components/assistant/ChatView"; -import type { MikeMessage } from "@/app/components/shared/types"; +import type { Message } from "@/app/components/shared/types"; export default function AssistantPage() { const router = useRouter(); - const { messages, isResponseLoading, handleChat, handleNewChat, cancel } = - useAssistantChat(); + const { + messages, + isResponseLoading, + handleChat, + handleNewChat, + cancel, + chatId, + } = useAssistantChat(); - async function handleInitialSubmit(message: MikeMessage) { + async function handleInitialSubmit(message: Message) { const chatId = await handleNewChat(message); if (chatId) router.push(`/assistant/chat/${chatId}`); } @@ -26,6 +32,7 @@ export default function AssistantPage() { return ( <ChatView + chatId={chatId} messages={messages} isResponseLoading={isResponseLoading} handleChat={handleChat} diff --git a/frontend/src/app/(pages)/layout.tsx b/frontend/src/app/(pages)/layout.tsx index d21c747..37c8258 100644 --- a/frontend/src/app/(pages)/layout.tsx +++ b/frontend/src/app/(pages)/layout.tsx @@ -79,13 +79,20 @@ export default function MikeLayout({ <SidebarContext.Provider value={{ setSidebarOpen: (open) => { + const isSmall = + typeof window !== "undefined" && + window.innerWidth < 768; + if (isSmall) { + if (!open) setIsSidebarOpen(false); + return; + } setIsSidebarOpen(open); setIsSidebarOpenDesktop(open); }, }} > - <div className="h-dvh bg-white flex flex-col"> - <div className="flex-1 flex overflow-hidden"> + <div className="h-dvh flex flex-col bg-gray-50/80"> + <div className="flex-1 flex min-w-0 overflow-visible"> <AppSidebar isOpen={isSidebarOpen} onToggle={handleSidebarToggle} diff --git a/frontend/src/app/(pages)/projects/[id]/assistant/chat/[chatId]/page.tsx b/frontend/src/app/(pages)/projects/[id]/assistant/chat/[chatId]/page.tsx index 42bd17b..524b326 100644 --- a/frontend/src/app/(pages)/projects/[id]/assistant/chat/[chatId]/page.tsx +++ b/frontend/src/app/(pages)/projects/[id]/assistant/chat/[chatId]/page.tsx @@ -15,8 +15,6 @@ import { ChevronRight, FileText, Loader2, - Plus, - Trash2, Upload, X, } from "lucide-react"; @@ -46,13 +44,14 @@ import { MikeIcon } from "@/components/chat/mike-icon"; import { useAuth } from "@/contexts/AuthContext"; import { useUserProfile } from "@/contexts/UserProfileContext"; import { useSidebar } from "@/app/contexts/SidebarContext"; +import { PageHeader } from "@/app/components/shared/PageHeader"; import type { CitationQuote, - MikeCitationAnnotation, - MikeDocument, - MikeEditAnnotation, - MikeMessage, - MikeProject, + CitationAnnotation, + Document, + EditAnnotation, + Message, + Project, } from "@/app/components/shared/types"; import { expandCitationToEntries } from "@/app/components/shared/types"; @@ -206,7 +205,7 @@ export default function ProjectAssistantChatPage({ params }: Props) { const username = profile?.displayName?.trim() || user?.email?.split("@")[0] || "there"; - const [project, setProject] = useState<MikeProject | null>(null); + const [project, setProject] = useState<Project | null>(null); const [chatTitle, setChatTitle] = useState<string | null>(null); const [chatOwnerId, setChatOwnerId] = useState<string | null>(null); const [ownerOnlyAction, setOwnerOnlyAction] = useState<string | null>(null); @@ -254,7 +253,7 @@ export default function ProjectAssistantChatPage({ params }: Props) { chats, saveChat, } = useChatHistoryContext(); - const [initialMessages] = useState<MikeMessage[]>(newChatMessages ?? []); + const [initialMessages] = useState<Message[]>(newChatMessages ?? []); const { messages, isResponseLoading, handleChat, setMessages, cancel } = useAssistantChat({ initialMessages, chatId, projectId }); @@ -470,7 +469,7 @@ export default function ProjectAssistantChatPage({ params }: Props) { // ── Handlers ────────────────────────────────────────────────────────────── const handleSubmit = useCallback( - (message: MikeMessage) => { + (message: Message) => { if (!activeTab) return handleChat(message); return handleChat(message, { displayedDoc: { @@ -482,11 +481,12 @@ export default function ProjectAssistantChatPage({ params }: Props) { [activeTab, handleChat], ); - const handleDocClick = (doc: MikeDocument) => { + const handleDocClick = (doc: Document) => { openTab(doc.id, doc.filename); }; - const handleCitationClick = (citation: MikeCitationAnnotation) => { + const handleCitationClick = (citation: CitationAnnotation) => { + if (citation.kind === "case") return; openTab( citation.document_id, citation.filename, @@ -503,7 +503,7 @@ export default function ProjectAssistantChatPage({ params }: Props) { openTab(args.documentId, args.filename, undefined, args.versionId); }; - const handleEditViewClick = (ann: MikeEditAnnotation, filename: string) => { + const handleEditViewClick = (ann: EditAnnotation, filename: string) => { openTab(ann.document_id, filename, undefined, ann.version_id ?? null); setEditScrollTarget({ key: `${ann.edit_id}-${Date.now()}`, @@ -753,77 +753,54 @@ export default function ProjectAssistantChatPage({ params }: Props) { return ( <div className="flex flex-col h-full"> {/* Page header */} - <div className="flex items-center justify-between px-8 py-4 shrink-0"> - <div className="flex items-center gap-1.5 text-2xl font-medium font-serif"> - <button - onClick={() => router.push("/projects")} - className="text-gray-500 hover:text-gray-700 transition-colors" - > - Projects - </button> - <span className="text-gray-300">›</span> - {project ? ( - <button - onClick={() => - router.push(`/projects/${projectId}`) - } - className="text-gray-500 hover:text-gray-700 transition-colors" - > - {project.name} - {project.cm_number && ( - <span className="ml-1 text-gray-400"> - (#{project.cm_number}) - </span> - )} - </button> - ) : ( - <div className="h-6 w-32 rounded bg-gray-100 animate-pulse" /> - )} - <span className="text-gray-300">›</span> - <button - onClick={() => - router.push(`/projects/${projectId}?tab=assistant`) - } - className="text-gray-500 hover:text-gray-700 transition-colors" - > - Assistant - </button> - <span className="text-gray-300">›</span> - {chatLoaded ? ( - <span className="text-gray-900 truncate max-w-xs"> - {chatTitle ?? "Untitled New Chat"} - </span> - ) : ( - <div className="h-6 w-40 rounded bg-gray-100 animate-pulse" /> - )} - </div> - <div className="flex items-center gap-2"> - <button - onClick={handleNewChat} - disabled={creatingChat} - title="New chat" - className="flex items-center justify-center p-1.5 text-gray-500 hover:text-gray-900 transition-colors disabled:opacity-40" - > - {creatingChat ? ( - <Loader2 className="h-4 w-4 animate-spin" /> - ) : ( - <Plus className="h-4 w-4" /> - )} - </button> - <button - onClick={handleDeleteChat} - disabled={deletingChat} - title="Delete chat" - className="flex items-center justify-center p-1.5 text-gray-500 hover:text-red-600 transition-colors disabled:opacity-40" - > - {deletingChat ? ( - <Loader2 className="h-4 w-4 animate-spin" /> - ) : ( - <Trash2 className="h-4 w-4" /> - )} - </button> - </div> - </div> + <PageHeader + shrink + breadcrumbs={[ + { + label: "Projects", + onClick: () => router.push("/projects"), + }, + project + ? { + label: project.name, + suffix: project.cm_number ? ( + <span className="ml-1 text-gray-400"> + (#{project.cm_number}) + </span> + ) : null, + onClick: () => router.push(`/projects/${projectId}`), + title: "Back to project", + } + : { + loading: true, + skeletonClassName: "w-32", + onClick: () => router.push(`/projects/${projectId}`), + title: "Back to project", + }, + chatLoaded + ? { + label: chatTitle ?? "Untitled New Chat", + } + : { + loading: true, + skeletonClassName: "w-40", + }, + ]} + actions={[ + { + type: "new", + onClick: handleNewChat, + loading: creatingChat, + title: "New chat", + }, + { + type: "delete", + onClick: handleDeleteChat, + loading: deletingChat, + title: "Delete chat", + }, + ]} + /> {/* Three-panel body */} <div className="flex flex-1 min-h-0 border-t border-gray-200 overflow-hidden"> @@ -1124,8 +1101,7 @@ export default function ProjectAssistantChatPage({ params }: Props) { onDragOver={(e) => e.preventDefault()} onDrop={handleChatDrop} > - <div className="h-10 flex items-center gap-2 px-4 border-b border-gray-200 shrink-0"> - <MikeIcon size={16} /> + <div className="h-10 flex items-center px-4 border-b border-gray-200 shrink-0"> <span className="text-xs text-gray-700"> Project Assistant </span> @@ -1191,6 +1167,9 @@ export default function ProjectAssistantChatPage({ params }: Props) { } isError={!!(msg as any).error} annotations={msg.annotations} + citationStatus={ + msg.citationStatus + } onCitationClick={ handleCitationClick } diff --git a/frontend/src/app/(pages)/tabular-reviews/page.tsx b/frontend/src/app/(pages)/tabular-reviews/page.tsx index 9d6786d..2fb936a 100644 --- a/frontend/src/app/(pages)/tabular-reviews/page.tsx +++ b/frontend/src/app/(pages)/tabular-reviews/page.tsx @@ -2,8 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; -import { Plus, Loader2, ChevronDown, Check, Table2 } from "lucide-react"; -import { HeaderSearchBtn } from "@/app/components/shared/HeaderSearchBtn"; +import { ChevronDown, Check, Table2 } from "lucide-react"; import { RowActions } from "@/app/components/shared/RowActions"; import { deleteTabularReview, @@ -12,16 +11,16 @@ import { listProjects, updateTabularReview, } from "@/app/lib/mikeApi"; -import type { TabularReview, MikeProject } from "@/app/components/shared/types"; +import type { TabularReview, Project } from "@/app/components/shared/types"; import { ToolbarTabs } from "@/app/components/shared/ToolbarTabs"; import { AddNewTRModal } from "@/app/components/tabular/AddNewTRModal"; import { OwnerOnlyModal } from "@/app/components/shared/OwnerOnlyModal"; import { useAuth } from "@/contexts/AuthContext"; +import { PageHeader } from "@/app/components/shared/PageHeader"; type Tab = "all" | "in-project" | "standalone"; -const CHECK_W = "w-8 shrink-0"; -const NAME_COL_W = "w-[300px] shrink-0"; +const NAME_COL_W = "w-[332px] shrink-0"; const TABS: { id: Tab; label: string }[] = [ { id: "all", label: "All" }, @@ -39,7 +38,7 @@ function formatDate(iso: string) { export default function TabularReviewsPage() { const [reviews, setReviews] = useState<TabularReview[]>([]); - const [projects, setProjects] = useState<MikeProject[]>([]); + const [projects, setProjects] = useState<Project[]>([]); const [loading, setLoading] = useState(true); const [creating, setCreating] = useState(false); const [newTROpen, setNewTROpen] = useState(false); @@ -56,6 +55,7 @@ export default function TabularReviewsPage() { const actionsRef = useRef<HTMLDivElement>(null); const router = useRouter(); const { user } = useAuth(); + const stickyCellBg = "bg-[#fcfcfd]"; useEffect(() => { Promise.all([ @@ -266,27 +266,28 @@ export default function TabularReviewsPage() { ); return ( - <div className="flex-1 overflow-y-auto bg-white"> + <div className="flex-1 overflow-y-auto"> {/* Page header */} - <div className="mb-1 flex items-center justify-between px-4 py-3 md:px-10"> + <PageHeader + actions={[ + { + type: "search", + value: search, + onChange: setSearch, + placeholder: "Search reviews…", + }, + { + type: "new", + onClick: () => setNewTROpen(true), + loading: creating, + title: "New tabular review", + }, + ]} + > <h1 className="text-2xl font-medium font-serif text-gray-900"> Tabular Reviews </h1> - <div className="flex items-center gap-2"> - <HeaderSearchBtn value={search} onChange={setSearch} placeholder="Search reviews…" /> - <button - onClick={() => setNewTROpen(true)} - disabled={creating} - className="flex items-center justify-center p-1.5 text-gray-500 hover:text-gray-900 transition-colors disabled:opacity-40" - > - {creating ? ( - <Loader2 className="h-4 w-4 animate-spin" /> - ) : ( - <Plus className="h-4 w-4" /> - )} - </button> - </div> - </div> + </PageHeader> <ToolbarTabs tabs={TABS} @@ -299,8 +300,10 @@ export default function TabularReviewsPage() { <div className="w-full overflow-x-auto"> <div className="min-w-max"> <div className="flex items-center h-8 pr-3 md:pr-10 border-b border-gray-200 text-xs text-gray-500 font-medium select-none"> - <div className={`sticky left-0 z-[60] ${CHECK_W} relative bg-white flex items-center justify-center self-stretch before:absolute before:inset-x-0 before:bottom-0 before:h-px before:bg-white`}> - {!loading && ( + <div className={`sticky left-0 z-[60] ${NAME_COL_W} ${stickyCellBg} flex items-center gap-4 self-stretch pl-4 pr-2 text-left`}> + {loading ? ( + <div className="h-2.5 w-2.5 shrink-0 rounded bg-gray-100 animate-pulse" /> + ) : ( <input type="checkbox" checked={allSelected} @@ -311,9 +314,7 @@ export default function TabularReviewsPage() { className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black" /> )} - </div> - <div className={`sticky left-8 z-[60] ${NAME_COL_W} bg-white pl-2 text-left`}> - Name + <span>Name</span> </div> <div className="ml-auto w-24 shrink-0">Columns</div> <div className="w-24 shrink-0">Documents</div> @@ -329,8 +330,8 @@ export default function TabularReviewsPage() { key={i} className="flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50" > - <div className="w-8 shrink-0" /> - <div className="flex-1 min-w-0 pl-3 pr-4"> + <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> <div className="w-24 shrink-0"> @@ -383,7 +384,7 @@ export default function TabularReviewsPage() { ); const rowBg = selectedIds.includes(review.id) ? "bg-gray-50" - : "bg-white"; + : stickyCellBg; return ( <div key={review.id} @@ -395,57 +396,57 @@ export default function TabularReviewsPage() { : `/tabular-reviews/${review.id}`, ); }} - className="group flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors" + className="group flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50 hover:bg-gray-100 cursor-pointer transition-colors" > - <div - className={`sticky left-0 z-[60] ${CHECK_W} p-2 flex items-center justify-center ${rowBg} group-hover:bg-gray-50`} - onClick={(e) => e.stopPropagation()} - > - <input - type="checkbox" - checked={selectedIds.includes( - review.id, - )} - onChange={() => - toggleOne(review.id) - } - className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black" - /> - </div> - <div className={`sticky left-8 z-[60] ${NAME_COL_W} bg-white p-2 group-hover:bg-gray-50`}> - {renamingId === review.id ? ( + <div className={`sticky left-0 z-[60] ${NAME_COL_W} ${rowBg} py-2 pl-4 pr-2 transition-colors group-hover:bg-gray-100`}> + <div className="flex min-w-0 items-center gap-4"> <input - autoFocus - value={renameValue} - onChange={(e) => - setRenameValue( - e.target.value, - ) - } - onKeyDown={(e) => { - if (e.key === "Enter") - handleRenameSubmit( - review.id, - ); - if (e.key === "Escape") - setRenamingId(null); - }} - onBlur={() => - handleRenameSubmit( - review.id, - ) + type="checkbox" + checked={selectedIds.includes( + review.id, + )} + onChange={() => + toggleOne(review.id) } onClick={(e) => e.stopPropagation() } - className="w-full text-sm text-gray-800 bg-transparent outline-none" + className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-pointer accent-black" /> - ) : ( - <span className="text-sm text-gray-800 truncate block"> - {review.title ?? - "Untitled Review"} - </span> - )} + {renamingId === review.id ? ( + <input + autoFocus + value={renameValue} + onChange={(e) => + setRenameValue( + e.target.value, + ) + } + onKeyDown={(e) => { + if (e.key === "Enter") + handleRenameSubmit( + review.id, + ); + if (e.key === "Escape") + setRenamingId(null); + }} + onBlur={() => + handleRenameSubmit( + review.id, + ) + } + onClick={(e) => + e.stopPropagation() + } + className="min-w-0 flex-1 text-sm text-gray-800 bg-transparent outline-none" + /> + ) : ( + <span className="min-w-0 flex-1 truncate text-sm text-gray-800"> + {review.title ?? + "Untitled Review"} + </span> + )} + </div> </div> <div className="ml-auto w-24 shrink-0 text-sm text-gray-500 truncate"> {review.columns_config?.length ?? 0} diff --git a/frontend/src/app/(pages)/workflows/[id]/page.tsx b/frontend/src/app/(pages)/workflows/[id]/page.tsx index 2ead38c..4f5f9f1 100644 --- a/frontend/src/app/(pages)/workflows/[id]/page.tsx +++ b/frontend/src/app/(pages)/workflows/[id]/page.tsx @@ -9,13 +9,14 @@ import { ShareWorkflowModal } from "@/app/components/workflows/ShareWorkflowModa import { WFEditColumnModal } from "@/app/components/workflows/WFEditColumnModal"; import { WFColumnViewModal } from "@/app/components/workflows/WFColumnViewModal"; import { AddColumnModal } from "@/app/components/tabular/AddColumnModal"; -import type { ColumnConfig, MikeWorkflow } from "@/app/components/shared/types"; +import type { ColumnConfig, Workflow } from "@/app/components/shared/types"; import { BUILT_IN_IDS, BUILT_IN_WORKFLOWS, } from "@/app/components/workflows/builtinWorkflows"; import { formatIcon, formatLabel } from "@/app/components/tabular/columnFormat"; import { RenameableTitle } from "@/app/components/shared/RenameableTitle"; +import { PageHeader } from "@/app/components/shared/PageHeader"; // dynamic import keeps Tiptap (browser-only) out of the SSR bundle const WorkflowPromptEditor = dynamic( () => @@ -31,8 +32,7 @@ interface Props { type SaveStatus = "idle" | "saving" | "saved"; -const CHECK_W = "w-8 shrink-0"; -const NAME_COL_W = "w-[300px] shrink-0"; +const NAME_COL_W = "w-[332px] shrink-0"; // --------------------------------------------------------------------------- // Page @@ -40,8 +40,9 @@ const NAME_COL_W = "w-[300px] shrink-0"; export default function WorkflowDetailPage({ params }: Props) { const { id } = use(params); const router = useRouter(); + const stickyCellBg = "bg-[#fcfcfd]"; - const [workflow, setWorkflow] = useState<MikeWorkflow | null>(null); + const [workflow, setWorkflow] = useState<Workflow | null>(null); const [loading, setLoading] = useState(true); const [notFound, setNotFound] = useState(false); @@ -191,13 +192,13 @@ export default function WorkflowDetailPage({ params }: Props) { return ( <div className="flex flex-col h-full"> {/* Header skeleton */} - <div className="flex items-center justify-between px-8 py-4 shrink-0"> - <div className="flex items-center gap-1.5"> - <div className="h-6 w-24 rounded bg-gray-100 animate-pulse" /> - <span className="text-gray-300">›</span> - <div className="h-6 w-40 rounded bg-gray-100 animate-pulse" /> - </div> - </div> + <PageHeader + shrink + breadcrumbs={[ + { label: "Workflows" }, + { loading: true, skeletonClassName: "w-40" }, + ]} + /> {/* Toolbar skeleton */} <div className="flex items-center px-8 h-10 border-b border-gray-200 shrink-0"> @@ -206,8 +207,8 @@ export default function WorkflowDetailPage({ params }: Props) { {/* Table header skeleton */} <div className="flex items-center h-8 pr-8 border-b border-gray-200 shrink-0"> - <div className="w-8 shrink-0 border-r border-gray-100 self-stretch" /> - <div className="flex-1 pl-3"> + <div className={`${NAME_COL_W} flex shrink-0 items-center gap-4 self-stretch pl-4 pr-2`}> + <div className="h-2.5 w-2.5 rounded bg-gray-100 animate-pulse" /> <div className="h-2.5 w-20 rounded bg-gray-100 animate-pulse" /> </div> <div className="w-36 shrink-0"> @@ -223,8 +224,8 @@ export default function WorkflowDetailPage({ params }: Props) { <div className="flex-1 overflow-hidden"> {[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="w-8 shrink-0 border-r border-gray-100 self-stretch" /> - <div className="flex-1 pl-3 pr-4"> + <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 rounded bg-gray-100 animate-pulse" style={{ width: `${40 + (i * 13) % 35}%` }} /> </div> <div className="w-36 shrink-0"> @@ -252,52 +253,58 @@ export default function WorkflowDetailPage({ params }: Props) { return ( <div className="flex flex-col h-full"> {/* Page header */} - <div className="flex items-center justify-between px-8 py-4 shrink-0"> - <div className="flex items-center gap-1.5 text-2xl font-medium font-serif"> - <button - onClick={() => router.push("/workflows")} - className="text-gray-500 hover:text-gray-700 transition-colors" - > - Workflows - </button> - <span className="text-gray-300">›</span> - {readOnly ? ( - <span className="text-gray-900 truncate max-w-xs">{workflow.title}</span> - ) : ( - <RenameableTitle value={workflow.title} onCommit={handleTitleCommit} /> - )} - </div> - - <div className="flex items-center gap-3"> - {/* Save status */} - <span className="text-xs text-gray-400"> - {saveStatus === "saving" - ? "Saving…" - : saveStatus === "saved" - ? "Saved" - : ""} - </span> - - {/* Share button (custom workflows only) */} - {canShare && ( - <button - onClick={() => setShareOpen(true)} - aria-label="Open workflow people" - title="People" - className="flex items-center text-gray-500 hover:text-gray-900 transition-colors" - > - <Users className="h-4 w-4" /> - </button> - )} - {shareOpen && ( - <ShareWorkflowModal - workflowId={id} - workflowName={workflow.title} - onClose={() => setShareOpen(false)} - /> - )} - </div> - </div> + <PageHeader + shrink + actionGap="md" + breadcrumbs={[ + { + label: "Workflows", + onClick: () => router.push("/workflows"), + title: "Back to Workflows", + }, + { + label: readOnly ? ( + <span className="text-gray-900 truncate max-w-xs"> + {workflow.title} + </span> + ) : ( + <RenameableTitle + value={workflow.title} + onCommit={handleTitleCommit} + /> + ), + }, + ]} + actions={[ + { + type: "custom", + render: ( + <span className="text-xs text-gray-400"> + {saveStatus === "saving" + ? "Saving…" + : saveStatus === "saved" + ? "Saved" + : ""} + </span> + ), + }, + canShare + ? { + onClick: () => setShareOpen(true), + title: "Open workflow people", + iconOnly: true, + icon: <Users className="h-4 w-4" />, + } + : null, + ]} + /> + {shareOpen && ( + <ShareWorkflowModal + workflowId={id} + workflowName={workflow.title} + onClose={() => setShareOpen(false)} + /> + )} {/* Read-only badge for built-in workflows */} {readOnly && ( @@ -366,7 +373,7 @@ export default function WorkflowDetailPage({ params }: Props) { <div className="min-w-max flex min-h-full flex-col"> {/* Table header */} <div className="flex items-center h-8 pr-8 border-b border-gray-200 text-xs text-gray-500 font-medium shrink-0 select-none"> - <div className={`sticky left-0 z-[60] ${CHECK_W} relative bg-white flex items-center justify-center self-stretch before:absolute before:inset-x-0 before:bottom-0 before:h-px before:bg-white`}> + <div className={`sticky left-0 z-[60] ${NAME_COL_W} ${stickyCellBg} flex items-center gap-4 self-stretch pl-4 pr-2 text-left`}> {columns.length > 0 && ( <input type="checkbox" @@ -376,9 +383,7 @@ export default function WorkflowDetailPage({ params }: Props) { className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black" /> )} - </div> - <div className={`sticky left-8 z-[60] ${NAME_COL_W} bg-white pl-2 text-left`}> - Column Title + <span>Column Title</span> </div> <div className="ml-auto w-36 shrink-0">Format</div> <div className="flex-1 min-w-0">Prompt</div> @@ -413,23 +418,21 @@ export default function WorkflowDetailPage({ params }: Props) { <div key={col.index} onClick={() => readOnly ? setViewingColumn(col) : setEditingColumn(col)} - className="group flex items-center h-10 pr-8 border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors" + className="group flex items-center h-10 pr-8 border-b border-gray-50 hover:bg-gray-100 cursor-pointer transition-colors" > - <div - className={`sticky left-0 z-[60] ${CHECK_W} p-2 flex items-center justify-center ${isChecked ? "bg-gray-50" : "bg-white"} group-hover:bg-gray-50`} - onClick={(e) => e.stopPropagation()} - > - <input - type="checkbox" - checked={isChecked} - onChange={() => setSelectedColIndices((prev) => prev.includes(col.index) ? prev.filter((i) => i !== col.index) : [...prev, col.index])} - className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black" - /> - </div> - <div className={`sticky left-8 z-[60] ${NAME_COL_W} p-2 ${isChecked ? "bg-gray-50" : "bg-white"} group-hover:bg-gray-50`}> - <span className="text-sm text-gray-800 truncate block"> - {col.name} - </span> + <div className={`sticky left-0 z-[60] ${NAME_COL_W} py-2 pl-4 pr-2 ${isChecked ? "bg-gray-50" : stickyCellBg} transition-colors group-hover:bg-gray-100`}> + <div className="flex min-w-0 items-center gap-4"> + <input + type="checkbox" + checked={isChecked} + onChange={() => setSelectedColIndices((prev) => prev.includes(col.index) ? prev.filter((i) => i !== col.index) : [...prev, col.index])} + onClick={(e) => e.stopPropagation()} + className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-pointer accent-black" + /> + <span className="min-w-0 flex-1 truncate text-sm text-gray-800"> + {col.name} + </span> + </div> </div> <div className="ml-auto w-36 shrink-0"> <span className="inline-flex items-center gap-1.5 text-xs text-gray-600"> diff --git a/frontend/src/app/components/assistant/AddDocButton.tsx b/frontend/src/app/components/assistant/AddDocButton.tsx index 1eede47..5446b9b 100644 --- a/frontend/src/app/components/assistant/AddDocButton.tsx +++ b/frontend/src/app/components/assistant/AddDocButton.tsx @@ -9,15 +9,21 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { uploadStandaloneDocument } from "@/app/lib/mikeApi"; -import type { MikeDocument } from "../shared/types"; +import type { Document } from "../shared/types"; interface Props { - onSelectDoc: (doc: MikeDocument) => void; + onSelectDoc: (doc: Document) => void; onBrowseAll: () => void; selectedDocIds?: string[]; + hideLabel?: boolean; } -export function AddDocButton({ onSelectDoc, onBrowseAll, selectedDocIds = [] }: Props) { +export function AddDocButton({ + onSelectDoc, + onBrowseAll, + selectedDocIds = [], + hideLabel = false, +}: Props) { const [isOpen, setIsOpen] = useState(false); const [uploading, setUploading] = useState(false); const fileInputRef = useRef<HTMLInputElement>(null); @@ -67,7 +73,7 @@ export function AddDocButton({ onSelectDoc, onBrowseAll, selectedDocIds = [] }: className={`h-4 w-4 shrink-0 transition-transform duration-300 ${isOpen ? "rotate-[135deg]" : ""}`} /> )} - <span className="hidden sm:inline"> + <span className={hideLabel ? "hidden" : "hidden sm:inline"}> {selectedDocIds.length === 1 ? "Document" : "Documents"} diff --git a/frontend/src/app/components/assistant/AssistantMessage.tsx b/frontend/src/app/components/assistant/AssistantMessage.tsx index f33dfb0..534e273 100644 --- a/frontend/src/app/components/assistant/AssistantMessage.tsx +++ b/frontend/src/app/components/assistant/AssistantMessage.tsx @@ -1,23 +1,37 @@ "use client"; import { useId, useRef, useEffect, useState } from "react"; -import ReactMarkdown from "react-markdown"; +import ReactMarkdown, { defaultUrlTransform } from "react-markdown"; import remarkMath from "remark-math"; import remarkGfm from "remark-gfm"; import rehypeKatex from "rehype-katex"; import "katex/dist/katex.min.css"; -import { Copy, Check, ChevronDown, Download, Loader2 } from "lucide-react"; +import { + Copy, + Check, + ChevronDown, + Download, + File, + FileText, + Loader2, + Scale, +} from "lucide-react"; import { MikeIcon } from "@/components/chat/mike-icon"; import { displayCitationQuote, formatCitationPage } from "../shared/types"; import type { AssistantEvent, - MikeCitationAnnotation, - MikeEditAnnotation, + CitationAnnotation, + EditAnnotation, } from "../shared/types"; import { EditCard, applyOptimisticResolution } from "./EditCard"; import { PreResponseWrapper } from "../shared/PreResponseWrapper"; import { supabase } from "@/lib/supabase"; +const RESPONSE_GLASS_SURFACE = + "rounded-xl border border-white/70 bg-white/55 shadow-[0_3px_9px_rgba(15,23,42,0.03),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-4px_9px_rgba(255,255,255,0.05)] backdrop-blur-2xl"; +const RESPONSE_GLASS_ANNOTATION = + "inline-flex h-4 w-4 items-center justify-center rounded-full border border-gray-200/60 bg-gray-200/80 text-[12px] font-serif font-medium text-gray-800 shadow-[0_1px_2px_rgba(15,23,42,0.04),inset_0_1px_0_rgba(243,244,246,0.85),inset_0_-2px_4px_rgba(229,231,235,0.65)] backdrop-blur-xl transition-colors hover:bg-gray-200 hover:text-gray-950"; + function toolCallLabel(name: string): string { if (name === "generate_docx") return "Creating document..."; if (name === "edit_document") return "Editing document..."; @@ -28,6 +42,13 @@ function toolCallLabel(name: string): string { if (name === "read_workflow") return "Loading workflow..."; if (name === "list_workflows") return "Loading workflows..."; if (name === "list_documents") return "Loading documents..."; + if (name === "courtlistener_search_case_law") + return "Searching case law..."; + if (name === "courtlistener_get_cases") return "Fetching cases..."; + if (name === "courtlistener_find_in_case") return "Searching case..."; + if (name === "courtlistener_read_case") return "Reading case..."; + if (name === "courtlistener_verify_citations") + return "Verifying citations..."; return name ? `Running ${name}...` : "Working..."; } @@ -51,11 +72,11 @@ function BulkEditActions({ onError, }: { pending: { - annotation: MikeEditAnnotation; + annotation: EditAnnotation; filename: string; }[]; filenameByDocId: Map<string, string>; - onViewClick?: (ann: MikeEditAnnotation, filename: string) => void; + onViewClick?: (ann: EditAnnotation, filename: string) => void; onResolveStart?: (args: { editId: string; documentId: string; @@ -233,13 +254,13 @@ function EditCardsSection({ onError, }: { pending: { - annotation: MikeEditAnnotation; + annotation: EditAnnotation; filename: string; }[]; filenameByDocId: Map<string, string>; cards: React.ReactNode[]; resolvedCount: number; - onViewClick?: (ann: MikeEditAnnotation, filename: string) => void; + onViewClick?: (ann: EditAnnotation, filename: string) => void; onResolveStart?: (args: { editId: string; documentId: string; @@ -353,6 +374,14 @@ function ResponseStatus({ status }: { status: StatusState }) { ); } +function eventErrorMessage(event: AssistantEvent): string | null { + if (event.type === "error") return event.message; + if ("error" in event && typeof event.error === "string" && event.error) { + return event.error; + } + return null; +} + // --------------------------------------------------------------------------- // Event block components // --------------------------------------------------------------------------- @@ -364,6 +393,8 @@ const THINKING_PHRASES = [ "Reviewing...", "Reasoning...", ]; +const REASONING_COLLAPSED_MAX_LINES = 6; +const REASONING_COLLAPSED_MAX_HEIGHT_REM = 9; function ReasoningBlock({ text, @@ -374,8 +405,13 @@ function ReasoningBlock({ isStreaming: boolean; showConnector?: boolean; }) { - const [isOpen, setIsOpen] = useState(false); + const [isContentOpen, setIsContentOpen] = useState(false); + const [isExpanded, setIsExpanded] = useState(false); + const [userToggledContent, setUserToggledContent] = useState(false); + const [isOverflowing, setIsOverflowing] = useState(false); + const [hasMeasured, setHasMeasured] = useState(false); const [thinkingIndex, setThinkingIndex] = useState(0); + const contentRef = useRef<HTMLDivElement | null>(null); useEffect(() => { if (!isStreaming) return; @@ -385,7 +421,20 @@ function ReasoningBlock({ return () => clearInterval(interval); }, [isStreaming]); - const showContent = isOpen || isStreaming; + useEffect(() => { + const el = contentRef.current; + if (!el) return; + const lineHeight = parseFloat(getComputedStyle(el).lineHeight) || 24; + const maxHeight = lineHeight * REASONING_COLLAPSED_MAX_LINES; + const nextOverflowing = el.scrollHeight > maxHeight + 2; + setIsOverflowing(nextOverflowing); + setHasMeasured(true); + if (!userToggledContent) setIsContentOpen(isStreaming); + if (!nextOverflowing) setIsExpanded(false); + }, [isStreaming, text, userToggledContent]); + + const showContent = isContentOpen || isStreaming || !hasMeasured; + const isCollapsed = isContentOpen && isOverflowing && !isExpanded; return ( <div className="relative"> @@ -393,7 +442,11 @@ function ReasoningBlock({ <div className="absolute left-0 top-0 bottom-0 w-[1px] bg-gray-300 top-[13px] left-[2.5px] h-[calc(100%+11px)]" /> )} <button - onClick={() => !isStreaming && setIsOpen((v) => !v)} + onClick={() => { + if (isStreaming) return; + setUserToggledContent(true); + setIsContentOpen((v) => !v); + }} className="flex items-center text-sm font-serif text-gray-500 hover:text-gray-600 transition-colors" > {isStreaming ? ( @@ -409,25 +462,64 @@ function ReasoningBlock({ {!isStreaming && ( <ChevronDown size={10} - className={`ml-1 self-center transition-transform duration-200 ${isOpen ? "" : "-rotate-90"}`} + className={`relative top-px ml-1 transition-transform duration-200 ${isContentOpen ? "" : "-rotate-90"}`} /> )} </button> {showContent && ( - <div className="mt-2 ml-[14px] text-sm font-serif text-gray-400 prose prose-sm max-w-none [&>*]:text-gray-400 [&>*]:text-sm"> - <ReactMarkdown - remarkPlugins={[remarkGfm]} - components={{ - code: ({ node, ...props }) => ( - <code - className="font-serif text-gray-600" - {...props} - /> - ), - }} + <div className="mt-2 ml-[14px]"> + <div + className={`relative ${isCollapsed ? "overflow-hidden" : ""}`} + style={ + isCollapsed + ? { + maxHeight: `${REASONING_COLLAPSED_MAX_HEIGHT_REM}rem`, + } + : undefined + } > - {text} - </ReactMarkdown> + <div + ref={contentRef} + className="text-sm font-serif text-gray-400 prose prose-sm max-w-none [&>*]:text-gray-400 [&>*]:text-sm" + > + <ReactMarkdown + remarkPlugins={[remarkGfm]} + components={{ + code: ({ node, ...props }) => ( + <code + className="font-serif text-gray-600" + {...props} + /> + ), + }} + > + {text} + </ReactMarkdown> + </div> + {isCollapsed && ( + <> + <div className="pointer-events-none absolute inset-x-0 bottom-0 h-10 bg-gradient-to-b from-white/0 to-white" /> + <button + type="button" + onClick={() => setIsExpanded(true)} + className="absolute left-1/2 bottom-2 z-10 -translate-x-1/2 text-gray-400 transition-colors hover:text-gray-600" + aria-label="Expand thought process" + > + <ChevronDown className="h-3.5 w-3.5" /> + </button> + </> + )} + </div> + {isOverflowing && isContentOpen && isExpanded && ( + <button + type="button" + onClick={() => setIsExpanded(false)} + className="mx-auto mt-2 flex text-gray-400 transition-colors hover:text-gray-600" + aria-label="Minimise thought process" + > + <ChevronDown className="h-3.5 w-3.5 rotate-180" /> + </button> + )} </div> )} </div> @@ -565,7 +657,11 @@ function DocReplicatedBlock({ }) { const label = isStreaming ? "Replicating" : "Replicated"; const suffix = - !isStreaming && count > 1 ? ` ${count} times` : isStreaming ? "..." : ""; + !isStreaming && count > 1 + ? ` ${count} times` + : isStreaming + ? "..." + : ""; return ( <div className="flex items-start text-sm font-serif text-gray-500 relative"> {showConnector && ( @@ -665,7 +761,7 @@ function DocDownloadBlock({ {basename} </p> {hasVersion && ( - <span className="shrink-0 inline-flex items-center rounded-md border border-gray-200 bg-white px-1.5 py-0.5 text-[10px] font-medium text-gray-500"> + <span className="shrink-0 inline-flex items-center rounded-md border border-white/70 bg-white/55 px-1.5 py-0.5 text-[10px] font-medium text-gray-500 shadow-[inset_0_1px_0_rgba(255,255,255,0.8)] backdrop-blur-xl"> V{versionNumber} </span> )} @@ -678,7 +774,7 @@ function DocDownloadBlock({ const downloadIcon = spinning ? ( <div aria-disabled - className="shrink-0 flex items-center border-l border-gray-200 px-6 bg-white text-gray-400 cursor-not-allowed" + className="shrink-0 flex items-center bg-white/25 px-6 text-gray-400 cursor-not-allowed" > <Loader2 size={13} className="animate-spin" /> </div> @@ -686,7 +782,7 @@ function DocDownloadBlock({ <button type="button" onClick={handleDownload} - className="shrink-0 flex items-center border-l border-gray-200 px-6 bg-white text-gray-400 hover:bg-gray-100 hover:text-gray-600 transition-colors cursor-pointer" + className="shrink-0 flex items-center bg-white/25 px-6 text-gray-500 transition-colors hover:bg-white/55 hover:text-gray-700 cursor-pointer" > <Download size={13} /> </button> @@ -694,11 +790,13 @@ function DocDownloadBlock({ if (onOpen) { return ( - <div className="flex items-stretch border border-gray-200 rounded-lg overflow-hidden w-full font-sans bg-gray-50"> + <div + className={`flex items-stretch overflow-hidden w-full font-sans ${RESPONSE_GLASS_SURFACE}`} + > <button type="button" onClick={onOpen} - className="flex items-stretch flex-1 min-w-0 text-left hover:bg-gray-100 transition-colors cursor-pointer" + className="flex items-stretch flex-1 min-w-0 text-left transition-colors hover:bg-white/45 cursor-pointer" > {body} </button> @@ -709,7 +807,9 @@ function DocDownloadBlock({ if (spinning) { return ( - <div className="flex items-stretch border border-gray-200 rounded-lg overflow-hidden w-full font-sans bg-gray-50"> + <div + className={`flex items-stretch overflow-hidden w-full font-sans ${RESPONSE_GLASS_SURFACE}`} + > {body} {downloadIcon} </div> @@ -717,11 +817,13 @@ function DocDownloadBlock({ } return ( - <div className="flex items-stretch border border-gray-200 rounded-lg overflow-hidden w-full font-sans bg-gray-50"> + <div + className={`flex items-stretch overflow-hidden w-full font-sans ${RESPONSE_GLASS_SURFACE}`} + > <button type="button" onClick={handleDownload} - className="flex items-stretch flex-1 min-w-0 text-left hover:bg-gray-100 transition-colors cursor-pointer" + className="flex items-stretch flex-1 min-w-0 text-left transition-colors hover:bg-white/45 cursor-pointer" > {body} </button> @@ -762,6 +864,118 @@ function WorkflowAppliedBlock({ ); } +type CourtListenerBlockItem = { + caseName: string | null; + citation: string | null; + dateFiled?: string | null; + url?: string | null; + query?: string; + totalMatches?: number; + hasError?: boolean; +}; + +function CourtListenerBlock({ + label, + detail, + isStreaming, + hasError, + showConnector, + items, +}: { + label: string; + detail?: string; + isStreaming?: boolean; + hasError?: boolean; + showConnector?: boolean; + items?: CourtListenerBlockItem[]; +}) { + const [isOpen, setIsOpen] = useState(false); + const hasItems = !!items && items.length > 0; + return ( + <div className="relative"> + {showConnector && ( + <div className="absolute bottom-0 w-[1px] bg-gray-300 top-[13px] left-[2.5px] h-[calc(100%+11px)]" /> + )} + <div className="flex items-start text-sm font-serif text-gray-500"> + {isStreaming ? ( + <div className="mt-2 w-1.5 h-1.5 rounded-full border border-gray-400 border-t-transparent animate-spin shrink-0" /> + ) : ( + <div + className={`mt-2 w-1.5 h-1.5 rounded-full shrink-0 ${hasError ? "bg-red-500" : "bg-green-400"}`} + /> + )} + <div className="ml-2 min-w-0 flex-1 whitespace-normal break-words"> + {hasItems ? ( + <button + onClick={() => setIsOpen((v) => !v)} + className="text-left hover:text-gray-700 transition-colors inline-flex items-center" + > + <span className="font-medium">{label}</span> + {detail ? <span> {detail}</span> : null} + {isStreaming ? <span>...</span> : null} + <ChevronDown + size={10} + className={`relative top-px ml-1 transition-transform duration-200 ${isOpen ? "" : "-rotate-90"}`} + /> + </button> + ) : ( + <> + <span className="font-medium">{label}</span> + {detail ? <span> {detail}</span> : null} + {isStreaming ? <span>...</span> : null} + </> + )} + </div> + </div> + {isOpen && hasItems && ( + <ul className="mt-2 ml-[14px] flex flex-col gap-1 text-sm font-serif text-gray-500"> + {items!.map((item, idx) => { + const label = [item.caseName, item.citation] + .filter(Boolean) + .join(", "); + const primary = label || item.url || "Unknown case"; + const searchText = item.query + ? `Searched for "${item.query}" in ${primary}${ + typeof item.totalMatches === "number" + ? ` (${item.totalMatches} ${ + item.totalMatches === 1 + ? "match" + : "matches" + })` + : "" + }` + : null; + return ( + <li key={idx}> + <div + className={ + item.hasError ? "text-red-500" : "" + } + > + {item.url ? ( + <a + href={item.url} + target="_blank" + rel="noreferrer" + className="hover:text-gray-700 hover:underline underline-offset-2" + > + {searchText ?? primary} + </a> + ) : searchText ? ( + <span>{searchText}</span> + ) : ( + <span>{primary}</span> + )} + </div> + </li> + ); + })} + </ul> + )} + </div> + ); +} + function DocEditedBlock({ filename, showConnector, @@ -805,11 +1019,11 @@ function DocEditedBlock({ function preprocessCitations( text: string, - annotations: MikeCitationAnnotation[], - citationsList: MikeCitationAnnotation[], + annotations: CitationAnnotation[], + citationsList: CitationAnnotation[], ): string { // Replace [N] or [N, M, ...] inline markers with internal §idx§ tokens backed by annotations - return text.replace(/\[(\d+(?:,\s*\d+)*)\]/g, (full, refsStr) => { + return text.replace(/\[(\d+(?:,\s*\d+)*)\]/g, (full, refsStr, offset) => { const refs = (refsStr as string) .split(",") .map((s: string) => parseInt(s.trim(), 10)); @@ -828,17 +1042,44 @@ function preprocessCitations( // Markdown renderer (shared config) // --------------------------------------------------------------------------- +function internalCaseHref( + value: string | number | null | undefined, +): string | null { + if (typeof value === "number") return `us-case-${value}`; + if (!value) return null; + const match = value.match(/^us-case-(\d+)$/); + return match ? `us-case-${match[1]}` : null; +} + function MarkdownContent({ text, citationsList, + caseCitations, + caseOpinions, onCitationClick, + onCaseClick, divRef, }: { text: string; - citationsList: MikeCitationAnnotation[]; - onCitationClick?: (c: MikeCitationAnnotation) => void; + citationsList: CitationAnnotation[]; + caseCitations: Map< + string, + Extract<AssistantEvent, { type: "case_citation" }> + >; + caseOpinions: Map< + number, + Extract<AssistantEvent, { type: "case_opinions" }>["case"] + >; + onCitationClick?: (c: CitationAnnotation) => void; + onCaseClick?: ( + c: Extract<AssistantEvent, { type: "case_citation" }>, + ) => void; divRef?: React.RefObject<HTMLDivElement | null>; }) { + function findCaseCitation(href: string) { + return caseCitations.get(internalCaseHref(href) ?? ""); + } + return ( <div ref={divRef} @@ -850,21 +1091,24 @@ function MarkdownContent({ remarkGfm, ]} rehypePlugins={[rehypeKatex]} + urlTransform={(url) => + /^us-case-\d+$/.test(url) ? url : defaultUrlTransform(url) + } components={{ table: ({ node, ...props }) => ( - <div className="overflow-x-auto my-4"> + <div className="overflow-x-auto my-4 rounded-lg"> <table - className="min-w-full divide-y divide-gray-300 border border-gray-200 rounded-lg overflow-hidden" + className="min-w-full divide-y divide-gray-300 overflow-hidden" {...props} /> </div> ), thead: ({ node, ...props }) => ( - <thead className="bg-gray-50" {...props} /> + <thead className="bg-gray-100" {...props} /> ), tbody: ({ node, ...props }) => ( <tbody - className="divide-y divide-gray-200 bg-white" + className="divide-y divide-gray-200" {...props} /> ), @@ -948,14 +1192,11 @@ function MarkdownContent({ const tooltipText = `${formatCitationPage(annotation)}: "${displayCitationQuote(annotation)}"`; return ( <button - onClick={() => { - console.log( - "[AssistantMessage] citation clicked", - annotation, - ); - onCitationClick?.(annotation); - }} - className="mx-0.5 inline-flex items-center justify-center rounded-full w-4 h-4 text-[10px] font-medium transition-colors align-super bg-gray-100 text-gray-900 hover:bg-gray-200" + onClick={() => + onCitationClick?.(annotation) + } + data-citation-ref={idx + 1} + className={`${RESPONSE_GLASS_ANNOTATION} mx-0.5 align-super`} title={tooltipText} > {idx + 1} @@ -978,17 +1219,74 @@ function MarkdownContent({ {...props} /> ), - a: ({ node, href, children, ...props }) => ( - <a - href={href} - className="text-blue-600 hover:text-blue-700 underline" - target="_blank" - rel="noopener noreferrer" - {...props} - > - {children} - </a> - ), + a: ({ node, href, children, ...props }) => { + if (href) { + const isInternalCaseHref = !!internalCaseHref(href); + const citation = findCaseCitation(href); + if (citation && onCaseClick) { + return ( + <button + type="button" + onClick={() => + onCaseClick({ + ...citation, + case: + citation.cluster_id !== null + ? caseOpinions.get( + citation.cluster_id, + ) + : undefined, + }) + } + className="text-left text-blue-600 hover:text-blue-700 underline" + > + {children} + </button> + ); + } + if (citation) { + return ( + <a + href={citation.url} + className="text-blue-600 hover:text-blue-700 underline" + target="_blank" + rel="noopener noreferrer" + > + {children} + </a> + ); + } + if (isInternalCaseHref) { + return ( + <span className="text-blue-600 underline"> + {children} + </span> + ); + } + return ( + <a + href={href} + className="text-blue-600 hover:text-blue-700 underline" + target="_blank" + rel="noopener noreferrer" + {...props} + > + {children} + </a> + ); + } + return ( + <a + href={href} + className="text-blue-600 hover:text-blue-700 underline" + target="_blank" + rel="noopener noreferrer" + {...props} + > + {children} + </a> + ); + }, hr: ({ node, ...props }) => ( <hr className="my-6 border-gray-200" {...props} /> ), @@ -1000,6 +1298,270 @@ function MarkdownContent({ ); } +// --------------------------------------------------------------------------- +// Citations block +// --------------------------------------------------------------------------- + +type CitationSourceRow = { + key: string; + label: string; + source: CitationAnnotation; + entries: { annotation: CitationAnnotation; index: number }[]; +}; + +function citationSourceKey(annotation: CitationAnnotation): string { + if (annotation.kind === "case") { + return `case:${annotation.cluster_id}`; + } + return `document:${annotation.document_id}`; +} + +function citationSourceLabel(annotation: CitationAnnotation): string { + if (annotation.kind === "case") { + const caseName = annotation.case_name?.trim(); + const citation = annotation.citation?.trim(); + if (caseName && citation) return `${caseName}, ${citation}`; + return caseName || citation || `Case ${annotation.cluster_id}`; + } + return annotation.filename; +} + +function documentExtension(filename: string): string { + return filename.split(".").pop()?.toLowerCase() ?? ""; +} + +function CitationSourceIcon({ + annotation, +}: { + annotation: CitationAnnotation; +}) { + if (annotation.kind === "case") { + return <Scale className="h-3.5 w-3.5 text-slate-600" />; + } + const ext = documentExtension(annotation.filename); + if (ext === "pdf") return <File className="h-3.5 w-3.5 text-red-500" />; + return <FileText className="h-3.5 w-3.5 text-blue-500" />; +} + +function buildCitationSourceRows( + citations: CitationAnnotation[], +): CitationSourceRow[] { + const rows = new Map<string, CitationSourceRow>(); + citations.forEach((annotation, index) => { + const key = citationSourceKey(annotation); + const existing = rows.get(key); + if (existing) { + existing.entries.push({ annotation, index }); + return; + } + rows.set(key, { + key, + label: citationSourceLabel(annotation), + source: annotation, + entries: [{ annotation, index }], + }); + }); + return Array.from(rows.values()); +} + +function escapeHtmlText(value: string): string { + return value + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function ensureTerminalPeriod(value: string): string { + return /[.!?]$/.test(value.trim()) ? value.trim() : `${value.trim()}.`; +} + +function buildCitationAppendix(citations: CitationAnnotation[]) { + if (citations.length === 0) return { html: "", text: "" }; + let previousSourceKey: string | null = null; + const entries = citations.map((annotation, index) => { + const sourceKey = citationSourceKey(annotation); + const label = + sourceKey === previousSourceKey + ? "Id." + : citationSourceLabel(annotation); + previousSourceKey = sourceKey; + return { + number: index + 1, + label, + quote: displayCitationQuote(annotation).trim(), + }; + }); + const textLines = [ + "", + "Citations", + ...entries.map((entry) => { + const quote = entry.quote ? ` "${entry.quote}"` : ""; + return `${entry.number} ${ensureTerminalPeriod(entry.label)}${quote}`; + }), + ]; + const html = [ + `<section class="copied-citations">`, + `<h3>Citations</h3>`, + ...entries.map((entry) => { + const label = escapeHtmlText(ensureTerminalPeriod(entry.label)); + const quote = entry.quote + ? ` "${escapeHtmlText(entry.quote)}"` + : ""; + return `<p><sup>${entry.number}</sup> ${label}${quote}</p>`; + }), + `</section>`, + ].join(""); + return { html, text: textLines.join("\n") }; +} + +function CitationsBlock({ + citationsList, + onCitationClick, + onOpenSource, + canOpenSource, + showWhenEmpty = false, + isLoading = false, +}: { + citationsList: CitationAnnotation[]; + onCitationClick?: (citation: CitationAnnotation) => void; + onOpenSource?: (citation: CitationAnnotation) => void; + canOpenSource?: (citation: CitationAnnotation) => boolean; + showWhenEmpty?: boolean; + isLoading?: boolean; +}) { + const rows = buildCitationSourceRows(citationsList); + if (rows.length === 0 && !showWhenEmpty) return null; + + return ( + <div className="mt-2 mb-3"> + <div className={`overflow-hidden ${RESPONSE_GLASS_SURFACE}`}> + <div className="flex items-center justify-between gap-3 bg-white/25 px-3 py-2"> + <h3 className="text-base font-serif text-gray-900"> + Citations + </h3> + {isLoading && ( + <Loader2 className="h-3.5 w-3.5 animate-spin text-gray-400" /> + )} + </div> + <div> + {rows.map((row) => { + const sourceIsClickable = + !!onOpenSource && + (canOpenSource?.(row.source) ?? true); + return ( + <div + key={row.key} + className="flex items-center gap-3 px-3 py-3" + > + <button + type="button" + onClick={() => onOpenSource?.(row.source)} + disabled={!sourceIsClickable} + className="flex min-w-0 flex-1 items-center gap-2 rounded-lg text-left text-sm font-serif text-gray-700 transition-colors enabled:hover:text-gray-950 disabled:cursor-default" + > + <CitationSourceIcon + annotation={row.source} + /> + <span className="truncate"> + {row.label} + </span> + </button> + <div className="flex shrink-0 flex-wrap justify-end gap-1"> + {row.entries.map( + ({ annotation, index }) => ( + <button + key={`${row.key}:${index}`} + type="button" + onClick={() => + onCitationClick?.( + annotation, + ) + } + className={ + RESPONSE_GLASS_ANNOTATION + } + title={`${formatCitationPage(annotation)}: "${displayCitationQuote(annotation)}"`} + > + {index + 1} + </button> + ), + )} + </div> + </div> + ); + })} + </div> + </div> + </div> + ); +} + +// --------------------------------------------------------------------------- +// Stream smoothing +// --------------------------------------------------------------------------- + +/** + * Hide jitter from arrival of streamed text chunks by revealing characters at + * a smooth, rate-paced clip rather than rendering every chunk verbatim. + * + * Returns a prefix of `text` whose length grows over time toward the full + * length. When `active` is false (stream ended, message replayed from + * history, etc.), snaps to the full text immediately. + * + * Rate adapts to backlog: small backlogs reveal at a 40 cps floor; large + * backlogs catch up within ~0.4s, so the smoothing never lags noticeably + * behind the server. + */ +function useSmoothedReveal(text: string, active: boolean): string { + const [revealedInt, setRevealedInt] = useState(text.length); + const revealedFloat = useRef<number>(text.length); + + useEffect(() => { + if (!active) { + revealedFloat.current = text.length; + setRevealedInt(text.length); + return; + } + + // Defensive clamp in case the text was edited / replaced shorter. + if (revealedFloat.current > text.length) { + revealedFloat.current = text.length; + setRevealedInt(text.length); + } + + let lastTick = performance.now(); + let raf = 0; + let cancelled = false; + + const step = (now: number) => { + if (cancelled) return; + const dt = Math.max(0, (now - lastTick) / 1000); + lastTick = now; + const target = text.length; + const prev = revealedFloat.current; + if (prev < target) { + const backlog = target - prev; + const cps = Math.max(40, backlog / 0.4); + const next = Math.min(target, prev + cps * dt); + revealedFloat.current = next; + const nextInt = Math.floor(next); + setRevealedInt((cur) => (cur === nextInt ? cur : nextInt)); + } + raf = requestAnimationFrame(step); + }; + + raf = requestAnimationFrame(step); + return () => { + cancelled = true; + cancelAnimationFrame(raf); + }; + }, [text.length, active]); + + return text.slice(0, Math.min(revealedInt, text.length)); +} + // --------------------------------------------------------------------------- // Main component // --------------------------------------------------------------------------- @@ -1011,11 +1573,16 @@ interface Props { isError?: boolean; /** Human-readable error text rendered alongside the red Mike icon. */ errorMessage?: string; - annotations?: MikeCitationAnnotation[]; - onCitationClick?: (citation: MikeCitationAnnotation) => void; + annotations?: CitationAnnotation[]; + citationStatus?: "started" | "partial" | "final"; + onCitationClick?: (citation: CitationAnnotation) => void; + onOpenCitationSource?: (citation: CitationAnnotation) => void; + onCaseClick?: ( + citation: Extract<AssistantEvent, { type: "case_citation" }>, + ) => void; minHeight?: string; onWorkflowClick?: (workflowId: string) => void; - onEditViewClick?: (ann: MikeEditAnnotation, filename: string) => void; + onEditViewClick?: (ann: EditAnnotation, filename: string) => void; /** * Opens the editor panel for a document without auto-highlighting any * specific edit. Used by the download card click — opening a doc to @@ -1074,7 +1641,10 @@ export function AssistantMessage({ isError = false, errorMessage, annotations = [], + citationStatus, onCitationClick, + onOpenCitationSource, + onCaseClick, minHeight = "0px", onWorkflowClick, onEditViewClick, @@ -1102,7 +1672,6 @@ export function AssistantMessage({ versionId: string | null; downloadUrl: string | null; }) => { - console.log("[AssistantMessage] handleEditResolved", args); if (args.downloadUrl) { setResolvedOverrides((prev) => ({ ...prev, @@ -1112,23 +1681,91 @@ export function AssistantMessage({ onEditResolved?.(args); }; - const status: StatusState = isError + const eventErrorMessages = (events ?? []) + .map(eventErrorMessage) + .filter((message): message is string => !!message); + const topLevelErrorMessage = + errorMessage ?? + ( + (events ?? []).find((event) => event.type === "error") as + | Extract<AssistantEvent, { type: "error" }> + | undefined + )?.message ?? + null; + const effectiveErrorMessage = + topLevelErrorMessage ?? eventErrorMessages[0] ?? null; + const hasError = isError || !!effectiveErrorMessage; + const status: StatusState = hasError ? "error" : isStreaming ? "active" : null; + const isRenderableEvent = (event: AssistantEvent) => + event.type !== "error" && + event.type !== "case_citation" && + event.type !== "case_opinions"; + + // Find the last content event so its raw text can be smoothed before + // citation preprocessing — slicing already-preprocessed text would risk + // chopping a `§N§` citation token in half. + const lastContentIdx = events + ? events.reduce( + (last, e, idx) => (e.type === "content" ? idx : last), + -1, + ) + : -1; + const lastContentEvent = + events && lastContentIdx >= 0 + ? (events[lastContentIdx] as Extract< + AssistantEvent, + { type: "content" } + >) + : null; + // Only smooth while the content event is still the visible tail. The + // moment the model emits a follow-up (tool call, reasoning, another + // content block), that content's text is frozen on the server — keeping + // it half-revealed below would make a tool-call wrapper appear under + // prose that still looks like it's typing. + const lastRenderableIdx = events + ? events.reduce( + (last, e, idx) => (isRenderableEvent(e) ? idx : last), + -1, + ) + : -1; + const contentIsTail = + lastContentEvent !== null && lastContentIdx === lastRenderableIdx; + const smoothedLastText = useSmoothedReveal( + lastContentEvent?.text ?? "", + isStreaming && contentIsTail, + ); + // Pre-process citations for all content events. Each [N] marker resolves // to exactly one annotation (models are instructed to use shared refs // only for cross-page continuations via the [[PAGE_BREAK]] sentinel). - const citationsList: MikeCitationAnnotation[] = []; + const citationsList: CitationAnnotation[] = []; + const caseCitations = new Map< + string, + Extract<AssistantEvent, { type: "case_citation" }> + >(); + const caseOpinions = new Map< + number, + Extract<AssistantEvent, { type: "case_opinions" }>["case"] + >(); const processedTexts: string[] = []; if (events) { - for (const event of events) { + for (let i = 0; i < events.length; i++) { + const event = events[i]; + if (event.type === "case_citation") { + const hrefKey = internalCaseHref(event.cluster_id); + if (hrefKey) caseCitations.set(hrefKey, event); + } else if (event.type === "case_opinions") { + caseOpinions.set(event.cluster_id, event.case); + } processedTexts.push( event.type === "content" ? preprocessCitations( - event.text, + i === lastContentIdx ? smoothedLastText : event.text, annotations, citationsList, ) @@ -1136,6 +1773,25 @@ export function AssistantMessage({ ); } } + const handleOpenCitationSource = (citation: CitationAnnotation) => { + if (onOpenCitationSource) { + onOpenCitationSource(citation); + return; + } + if (citation.kind === "case" || !onOpenDocument) return; + onOpenDocument({ + documentId: citation.document_id, + filename: citation.filename, + versionId: citation.version_id ?? null, + versionNumber: citation.version_number ?? null, + }); + }; + const canOpenCitationSource = (citation: CitationAnnotation) => + !!onOpenCitationSource || + (citation.kind !== "case" && !!onOpenDocument); + const citationBlockList = citationStatus ? annotations : citationsList; + const showCitationBlock = + !!citationStatus || (!isStreaming && citationsList.length > 0); const handleCopy = async () => { try { let html = ""; @@ -1144,9 +1800,19 @@ export function AssistantMessage({ const clone = contentDivRef.current.cloneNode( true, ) as HTMLElement; + clone.querySelectorAll("[data-citation-ref]").forEach((el) => { + const ref = el.getAttribute("data-citation-ref"); + if (!ref) return; + const sup = document.createElement("sup"); + sup.textContent = ref; + el.replaceWith(sup); + }); html = clone.innerHTML; plainText = clone.textContent || ""; } + const appendix = buildCitationAppendix(citationBlockList); + html += appendix.html; + plainText += appendix.text; const item = new ClipboardItem({ "text/html": new Blob([html], { type: "text/html" }), "text/plain": new Blob([plainText], { type: "text/plain" }), @@ -1159,13 +1825,6 @@ export function AssistantMessage({ } }; - const lastContentIdx = events - ? events.reduce( - (last, e, idx) => (e.type === "content" ? idx : last), - -1, - ) - : -1; - // Walk events in chronological order and group consecutive non-content // events into their own PreResponseWrapper. Content events render // between wrappers, so reasoning/tool chatter that arrives after the @@ -1182,6 +1841,7 @@ export function AssistantMessage({ if (events) { let current: Extract<EventGroup, { kind: "pre" }> | null = null; events.forEach((e, i) => { + if (!isRenderableEvent(e)) return; if (e.type === "content") { if (current) { groups.push(current); @@ -1224,7 +1884,10 @@ export function AssistantMessage({ <MarkdownContent text={processed} citationsList={citationsList} + caseCitations={caseCitations} + caseOpinions={caseOpinions} onCitationClick={onCitationClick} + onCaseClick={onCaseClick} divRef={isLastContent ? contentDivRef : undefined} /> </div> @@ -1271,7 +1934,9 @@ export function AssistantMessage({ ); } if (event.type === "doc_read") { - const ann = annotations.find((a) => a.filename === event.filename); + const ann = annotations.find( + (a) => a.kind !== "case" && a.filename === event.filename, + ); return ( <DocReadBlock key={globalIdx} @@ -1348,6 +2013,226 @@ export function AssistantMessage({ /> ); } + if (event.type === "courtlistener_search_case_law") { + const count = event.result_count ?? 0; + const detail = event.isStreaming + ? event.query + ? `for "${event.query}"` + : undefined + : event.error + ? event.error + : `${count} ${count === 1 ? "result" : "results"}${event.query ? ` for "${event.query}"` : ""}`; + return ( + <CourtListenerBlock + key={globalIdx} + label={ + event.isStreaming + ? "Searching case law" + : event.error + ? "Case law search failed" + : "Searched case law" + } + detail={detail} + isStreaming={!!event.isStreaming} + hasError={!!event.error} + showConnector={showConnector} + /> + ); + } + if (event.type === "courtlistener_get_cases") { + const caseCount = event.case_count ?? event.cluster_ids.length; + const displayLabel = `${caseCount} ${ + caseCount === 1 ? "case" : "cases" + }`; + const detail = event.error ? event.error : undefined; + const items: CourtListenerBlockItem[] = + event.cases?.map((caseItem) => ({ + caseName: caseItem.case_name, + citation: caseItem.citation, + url: caseItem.url ?? null, + })) ?? + event.cluster_ids.map((clusterId) => { + const citation = caseCitations.get(`us-case-${clusterId}`); + return { + caseName: citation?.case_name ?? null, + citation: citation?.citation ?? `Cluster ${clusterId}`, + url: citation?.url ?? null, + }; + }); + return ( + <CourtListenerBlock + key={globalIdx} + label={ + event.isStreaming + ? `Fetching ${displayLabel}` + : event.error + ? "Case fetch failed" + : `Fetched ${displayLabel}` + } + detail={detail} + isStreaming={!!event.isStreaming} + hasError={!!event.error} + showConnector={showConnector} + items={items.length > 0 ? items : undefined} + /> + ); + } + if (event.type === "courtlistener_find_in_case") { + const searches = event.searches ?? []; + if (searches.length > 0) { + const matches = + event.total_matches ?? + searches.reduce( + (sum, search) => sum + (search.total_matches ?? 0), + 0, + ); + const caseIds = new Set( + searches.map( + (search) => + search.cluster_id ?? + `${search.case_name ?? ""}|${search.citation ?? ""}`, + ), + ); + const caseCount = caseIds.size || searches.length; + const searchLabel = `${searches.length} ${ + searches.length === 1 ? "search" : "searches" + } in ${caseCount} ${caseCount === 1 ? "case" : "cases"}`; + const detail = event.isStreaming + ? undefined + : event.error + ? event.error + : `(${matches} ${matches === 1 ? "match" : "matches"})`; + const items: CourtListenerBlockItem[] = searches.map( + (search) => ({ + caseName: search.case_name ?? null, + citation: + search.citation ?? + (search.cluster_id + ? `Cluster ${search.cluster_id}` + : null), + url: null, + query: search.query, + totalMatches: search.total_matches ?? 0, + hasError: !!search.error, + }), + ); + return ( + <CourtListenerBlock + key={globalIdx} + label={ + event.isStreaming + ? `Running ${searchLabel}` + : event.error + ? "Case searches failed" + : `Ran ${searchLabel}` + } + detail={detail} + isStreaming={!!event.isStreaming} + hasError={!!event.error} + showConnector={showConnector} + items={items.length > 0 ? items : undefined} + /> + ); + } + const matches = event.total_matches ?? 0; + const caseLabel = + [event.case_name, event.citation].filter(Boolean).join(", ") || + (event.cluster_id ? `cluster ${event.cluster_id}` : "case"); + const detail = event.isStreaming + ? event.query + ? `for "${event.query}" in ${caseLabel}` + : caseLabel + : event.error + ? event.error + : `${matches} ${matches === 1 ? "match" : "matches"}${event.query ? ` for "${event.query}"` : ""} in ${caseLabel}`; + return ( + <CourtListenerBlock + key={globalIdx} + label={ + event.isStreaming + ? "Searching case" + : event.error + ? "Case search failed" + : "Searched case" + } + detail={detail} + isStreaming={!!event.isStreaming} + hasError={!!event.error} + showConnector={showConnector} + /> + ); + } + if (event.type === "courtlistener_read_case") { + const count = event.opinion_count ?? 0; + const caseLabel = + [event.case_name, event.citation].filter(Boolean).join(", ") || + "case"; + const detail = event.isStreaming + ? undefined + : event.error + ? event.error + : count > 0 + ? `(${count} ${count === 1 ? "opinion" : "opinions"})` + : undefined; + return ( + <CourtListenerBlock + key={globalIdx} + label={ + event.isStreaming + ? `Reading case ${caseLabel}` + : event.error + ? `Case read failed ${caseLabel}` + : `Read case ${caseLabel}` + } + detail={detail} + isStreaming={!!event.isStreaming} + hasError={!!event.error} + showConnector={showConnector} + /> + ); + } + if (event.type === "courtlistener_verify_citations") { + const citations = event.citation_count ?? 0; + const matches = event.match_count ?? 0; + const citationLabel = `${citations} ${citations === 1 ? "citation" : "citations"}`; + const detail = event.isStreaming + ? undefined + : event.error + ? event.error + : `(${matches} ${matches === 1 ? "match" : "matches"})`; + // Adjacent `case_citation` events are emitted between the start + // and final verify_citations events (one per matched citation) — + // collect them so the user can expand to see resolved cases. + const items: CourtListenerBlockItem[] = []; + if (events) { + for (let j = globalIdx + 1; j < events.length; j++) { + const e = events[j]; + if (e.type !== "case_citation") break; + items.push({ + caseName: e.case_name, + citation: e.citation, + url: e.url || null, + }); + } + } + return ( + <CourtListenerBlock + key={globalIdx} + label={ + event.isStreaming + ? `Verifying ${citationLabel}` + : event.error + ? "Citation verification failed" + : `Verified ${citationLabel}` + } + detail={detail} + isStreaming={!!event.isStreaming} + hasError={!!event.error} + showConnector={showConnector} + items={items.length > 0 ? items : undefined} + /> + ); + } return null; }; @@ -1366,7 +2251,10 @@ export function AssistantMessage({ <MarkdownContent text={processedTexts[g.index]} citationsList={citationsList} + caseCitations={caseCitations} + caseOpinions={caseOpinions} onCitationClick={onCitationClick} + onCaseClick={onCaseClick} divRef={ isLastContent ? contentDivRef @@ -1414,7 +2302,7 @@ export function AssistantMessage({ { type: "doc_edited" } >[]; const pending: { - annotation: MikeEditAnnotation; + annotation: EditAnnotation; filename: string; }[] = []; const filenameByDocId = new Map< @@ -1422,7 +2310,7 @@ export function AssistantMessage({ string >(); // Effective status = external override if any, else the annotation's DB status. - const statusOf = (ann: MikeEditAnnotation) => + const statusOf = (ann: EditAnnotation) => resolvedEditStatuses?.[ann.edit_id] ?? ann.status; for (const e of editedEvents) { @@ -1494,12 +2382,10 @@ export function AssistantMessage({ </div> ) : null} - {isError && ( - <div className="mt-2 flex items-start gap-2 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-serif text-red-700"> - <span className="leading-snug"> - {errorMessage ?? "Sorry, something went wrong."} - </span> - </div> + {topLevelErrorMessage && ( + <p className="mt-2 text-base font-serif leading-7 text-red-700"> + {topLevelErrorMessage} + </p> )} {/* Download card for each edited doc — only after streaming @@ -1613,6 +2499,20 @@ export function AssistantMessage({ </div> )} + {showCitationBlock && ( + <CitationsBlock + citationsList={citationBlockList} + onCitationClick={onCitationClick} + onOpenSource={handleOpenCitationSource} + canOpenSource={canOpenCitationSource} + showWhenEmpty={!!citationStatus} + isLoading={ + citationStatus === "started" || + citationStatus === "partial" + } + /> + )} + {/* Copy button */} <div className="flex items-center gap-2 pt-2 pb-4 md:pb-8 font-sans justify-start"> {!isStreaming && ( diff --git a/frontend/src/app/components/assistant/AssistantSidePanel.tsx b/frontend/src/app/components/assistant/AssistantSidePanel.tsx index a6aeb86..639ab09 100644 --- a/frontend/src/app/components/assistant/AssistantSidePanel.tsx +++ b/frontend/src/app/components/assistant/AssistantSidePanel.tsx @@ -1,12 +1,23 @@ "use client"; -import { useCallback, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useRef, + useState, + type CSSProperties, +} from "react"; import { X } from "lucide-react"; import { DocPanel, type DocPanelMode } from "../shared/DocPanel"; import type { - MikeCitationAnnotation, - MikeEditAnnotation, + CitationAnnotation, + EditAnnotation, } from "../shared/types"; +import { + CaseLawPanel, + type CaseTab, +} from "./CaseLawPanel"; +import { cn } from "@/lib/utils"; // --------------------------------------------------------------------------- // Tab data @@ -34,15 +45,19 @@ export type DocumentTab = CommonTab & { kind: "document" }; export type CitationTab = CommonTab & { kind: "citation"; - citation: MikeCitationAnnotation; + citation: CitationAnnotation; }; export type EditTab = CommonTab & { kind: "edit"; - edit: MikeEditAnnotation; + edit: EditAnnotation; }; -export type AssistantSidePanelTab = DocumentTab | CitationTab | EditTab; +export type AssistantSidePanelTab = + | DocumentTab + | CitationTab + | EditTab + | CaseTab; interface Props { tabs: AssistantSidePanelTab[]; @@ -86,6 +101,22 @@ interface Props { const MIN_WIDTH = 300; const MAX_WIDTH_OFFSET = 56; // sidebar width +const MIN_CHAT_WIDTH = 400; + +function maxPanelWidth() { + if (typeof window === "undefined") return 600; + return Math.max( + MIN_WIDTH, + window.innerWidth - MAX_WIDTH_OFFSET - MIN_CHAT_WIDTH, + ); +} + +function tabTitle(tab: AssistantSidePanelTab): string { + if (tab.kind === "case") { + return tab.caseName || tab.citation || "Case"; + } + return tab.filename; +} export function AssistantSidePanel({ tabs, @@ -104,7 +135,10 @@ export function AssistantSidePanel({ const panelRef = useRef<HTMLDivElement>(null); const [panelWidth, setPanelWidth] = useState(() => typeof window !== "undefined" - ? Math.round((window.innerWidth - MAX_WIDTH_OFFSET) / 2) + ? Math.min( + maxPanelWidth(), + Math.round((window.innerWidth - MAX_WIDTH_OFFSET) / 2), + ) : 600, ); @@ -120,10 +154,9 @@ export function AssistantSidePanel({ const onMouseMove = (ev: MouseEvent) => { const delta = dragStartX.current - ev.clientX; - const maxWidth = window.innerWidth - MAX_WIDTH_OFFSET - 200; setPanelWidth( Math.min( - maxWidth, + maxPanelWidth(), Math.max(MIN_WIDTH, dragStartWidth.current + delta), ), ); @@ -143,46 +176,73 @@ export function AssistantSidePanel({ [panelWidth], ); + useEffect(() => { + const onResize = () => { + setPanelWidth((width) => + Math.min(maxPanelWidth(), Math.max(MIN_WIDTH, width)), + ); + }; + window.addEventListener("resize", onResize); + onResize(); + return () => window.removeEventListener("resize", onResize); + }, []); + const active = tabs.find((t) => t.id === activeTabId) ?? tabs[0] ?? null; if (!active) return null; return ( <div ref={panelRef} - className="flex h-full shrink-0 flex-col bg-white relative border-l border-gray-200 shadow-[-4px_0_12px_rgba(0,0,0,0.02)]" - style={{ width: panelWidth }} + className={cn( + "relative flex h-full w-full shrink-0 flex-col md:my-3 md:mr-3 md:h-[calc(100%-1.5rem)] md:w-[var(--assistant-panel-width)]", + "rounded-2xl border border-white/70 bg-white shadow-[0_6px_18px_rgba(15,23,42,0.08),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={{ + "--assistant-panel-width": `${panelWidth}px`, + } as CSSProperties} > {/* Drag handle */} <div onMouseDown={onMouseDown} - className="absolute left-0 top-0 h-full w-1 cursor-col-resize hover:bg-blue-400 transition-colors z-10" + className={cn( + "absolute left-0 top-0 z-10 hidden h-full w-1 cursor-col-resize transition-colors md:block", + "hover:bg-blue-400/70", + )} style={{ marginLeft: -2 }} /> {/* Tab strip (Chrome-style) */} - <div className="flex items-end gap-1 pr-2 pt-2 bg-gray-100"> - <div className="flex-1 flex items-end gap-1 overflow-x-auto pl-2 pr-2"> + <div + className={cn( + "flex items-end gap-1 px-1 pt-2", + "bg-gray-200/80", + )} + > + <div className="flex-1 flex items-end gap-1 overflow-hidden px-2"> {tabs.map((tab) => { const isActive = tab.id === active.id; const showVersionBadge = + tab.kind !== "case" && typeof tab.versionNumber === "number" && Number.isFinite(tab.versionNumber) && tab.versionNumber > 1; + const title = tabTitle(tab); return ( <div key={tab.id} onClick={() => onActivateTab(tab.id)} - className={`group relative flex items-center gap-1.5 pl-3 pr-1.5 h-8 min-w-0 max-w-[220px] rounded-t-lg cursor-pointer select-none transition-colors ${ + className={cn( + "group relative flex items-center gap-1.5 pl-3 pr-1.5 h-8 min-w-0 max-w-[220px] rounded-t-lg cursor-pointer select-none transition-colors", isActive - ? "bg-white text-gray-800 before:content-[''] before:absolute before:bottom-0 before:-left-2 before:w-2 before:h-2 before:bg-[radial-gradient(circle_at_top_left,transparent_8px,white_9px)] after:content-[''] after:absolute after:bottom-0 after:-right-2 after:w-2 after:h-2 after:bg-[radial-gradient(circle_at_top_right,transparent_8px,white_9px)]" - : "bg-gray-200/70 text-gray-600 hover:bg-gray-200" - }`} + ? "z-20 bg-white text-gray-800 before:content-[''] before:absolute before:bottom-0 before:-left-2 before:z-20 before:h-2 before:w-2 before:rounded-br-lg before:shadow-[4px_4px_0_4px_white] before:transition-shadow after:content-[''] after:absolute after:bottom-0 after:-right-2 after:z-20 after:h-2 after:w-2 after:rounded-bl-lg after:shadow-[-4px_4px_0_4px_white] after:transition-shadow" + : "z-10 bg-gray-100 text-gray-600 hover:bg-gray-100 before:content-[''] before:absolute before:bottom-0 before:-left-2 before:h-2 before:w-2 before:rounded-br-lg before:shadow-[4px_4px_0_4px_#f3f4f6] before:transition-shadow after:content-[''] after:absolute after:bottom-0 after:-right-2 after:h-2 after:w-2 after:rounded-bl-lg after:shadow-[-4px_4px_0_4px_#f3f4f6] after:transition-shadow", + )} > <span className={`min-w-0 flex-1 truncate text-xs ${isActive ? "font-medium" : "font-normal"}`} - title={tab.filename} + title={title} > - {tab.filename} + {title} </span> {showVersionBadge && ( <span @@ -200,7 +260,7 @@ export function AssistantSidePanel({ e.stopPropagation(); onCloseTab(tab.id); }} - className="shrink-0 rounded-full p-0.5 text-gray-400 hover:bg-gray-300 hover:text-gray-700" + className="shrink-0 rounded-full p-0.5 text-gray-400 hover:text-gray-700" > <X className="h-3 w-3" /> </button> @@ -210,7 +270,7 @@ export function AssistantSidePanel({ </div> <button onClick={onCloseAll} - className="shrink-0 mb-1 ml-1 rounded-lg p-1.5 text-gray-400 hover:bg-gray-200 hover:text-gray-700" + className="shrink-0 mb-1 ml-1 rounded-lg p-1.5 text-gray-400 hover:text-gray-700" title="Close panel" > <X className="h-4 w-4" /> @@ -223,6 +283,20 @@ export function AssistantSidePanel({ <div className="flex-1 min-h-0 relative"> {tabs.map((tab) => { const isActive = tab.id === active.id; + if (tab.kind === "case") { + return ( + <div + key={tab.id} + className={`absolute inset-0 flex flex-col ${isActive ? "" : "invisible pointer-events-none"}`} + aria-hidden={!isActive} + > + <CaseLawPanel + tab={tab} + compactActions={panelWidth < 600} + /> + </div> + ); + } const mode: DocPanelMode = tab.kind === "citation" ? { diff --git a/frontend/src/app/components/assistant/AssistantWorkflowModal.tsx b/frontend/src/app/components/assistant/AssistantWorkflowModal.tsx index 1e412ad..10c712a 100644 --- a/frontend/src/app/components/assistant/AssistantWorkflowModal.tsx +++ b/frontend/src/app/components/assistant/AssistantWorkflowModal.tsx @@ -1,18 +1,18 @@ "use client"; import { useEffect, useState } from "react"; -import { createPortal } from "react-dom"; import { ChevronLeft, Search, X } from "lucide-react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; -import type { MikeWorkflow } from "../shared/types"; +import type { Workflow } from "../shared/types"; import { listWorkflows } from "@/app/lib/mikeApi"; import { BUILT_IN_WORKFLOWS } from "../workflows/builtinWorkflows"; +import { Modal } from "../shared/Modal"; interface Props { open: boolean; onClose: () => void; - onSelect: (workflow: MikeWorkflow) => void; + onSelect: (workflow: Workflow) => void; projectName?: string; projectCmNumber?: string | null; initialWorkflowId?: string; @@ -26,9 +26,9 @@ export function AssistantWorkflowModal({ projectCmNumber, initialWorkflowId, }: Props) { - const [workflows, setWorkflows] = useState<MikeWorkflow[]>([]); + const [workflows, setWorkflows] = useState<Workflow[]>([]); const [loading, setLoading] = useState(false); - const [selected, setSelected] = useState<MikeWorkflow | null>(null); + const [selected, setSelected] = useState<Workflow | null>(null); const [search, setSearch] = useState(""); const [rightVisible, setRightVisible] = useState(false); @@ -87,45 +87,28 @@ export function AssistantWorkflowModal({ onClose(); } - return createPortal( - <div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/10 backdrop-blur-xs"> - <div - className={`w-full rounded-2xl bg-white shadow-2xl flex flex-col h-[600px] ${selected ? "max-w-4xl" : "max-w-2xl"}`} - > - {/* Header */} - <div className="flex items-center justify-between px-4 py-4 shrink-0 border-b border-gray-100"> - <div className="flex items-center gap-1.5 text-xs text-gray-400"> - {projectName ? ( - <> - <span>Projects</span> - <span>›</span> - <span> - {projectName} - {projectCmNumber - ? ` (#${projectCmNumber})` - : ""} - </span> - <span>›</span> - <span>Assistant</span> - <span>›</span> - <span>Add workflow</span> - </> - ) : ( - <> - <span>Assistant</span> - <span>›</span> - <span>Add workflow</span> - </> - )} - </div> - <button - onClick={onClose} - className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 transition-colors" - > - <X className="h-4 w-4" /> - </button> - </div> + const breadcrumbs = projectName + ? [ + "Projects", + `${projectName}${projectCmNumber ? ` (#${projectCmNumber})` : ""}`, + "Assistant", + "Add workflow", + ] + : ["Assistant", "Add workflow"]; + return ( + <Modal + open={open} + onClose={onClose} + size={selected ? "xl" : "lg"} + breadcrumbs={breadcrumbs} + primaryAction={{ + label: "Use", + type: "button", + onClick: handleUse, + disabled: !selected, + }} + > {/* Content */} <div className="flex flex-row flex-1 min-h-0 overflow-hidden"> {/* Left panel — workflow list */} @@ -133,7 +116,7 @@ export function AssistantWorkflowModal({ className={`overflow-y-auto ${selected ? "w-80 shrink-0" : "flex-1"}`} > {/* Search */} - <div className="px-4 pt-3 pb-2 shrink-0"> + <div className="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 @@ -152,7 +135,7 @@ export function AssistantWorkflowModal({ </div> {loading ? ( - <div className="space-y-px px-4 pt-1"> + <div className="space-y-px pt-1"> {[60, 45, 75, 50, 65, 40, 55].map((w, i) => ( <div key={i} @@ -167,7 +150,7 @@ export function AssistantWorkflowModal({ ))} </div> ) : filteredWorkflows.length === 0 ? ( - <p className="px-4 py-8 text-sm text-center text-gray-400"> + <p className="py-8 text-sm text-center text-gray-400"> {search ? "No matches found" : "No assistant workflows found"} </p> ) : ( @@ -268,26 +251,6 @@ export function AssistantWorkflowModal({ )} </div> - {/* Footer */} - <div className="border-t border-gray-100 px-4 py-3 flex items-center justify-end gap-2 shrink-0"> - <button - type="button" - onClick={onClose} - className="rounded-lg px-3 py-1.5 text-sm text-gray-500 hover:bg-gray-100 transition-colors" - > - Cancel - </button> - <button - type="button" - onClick={handleUse} - disabled={!selected} - className="rounded-lg bg-gray-900 px-4 py-1.5 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-40 transition-colors" - > - Use - </button> - </div> - </div> - </div>, - document.body, + </Modal> ); } diff --git a/frontend/src/app/components/assistant/CaseLawPanel.tsx b/frontend/src/app/components/assistant/CaseLawPanel.tsx new file mode 100644 index 0000000..2583c36 --- /dev/null +++ b/frontend/src/app/components/assistant/CaseLawPanel.tsx @@ -0,0 +1,623 @@ +"use client"; + +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type RefObject, +} from "react"; +import DOMPurify from "dompurify"; +import { + Download, + ExternalLink, +} from "lucide-react"; +import { MikeIcon } from "@/components/chat/mike-icon"; +import type { CaseCitationQuote } from "../shared/types"; +import { + clearDocxQuoteHighlights, + highlightDocxQuote, +} from "../shared/highlightDocxQuote"; +import { + RelevantQuotes, + type RelevantQuoteItem, +} from "../shared/RelevantQuotes"; +import { + getCourtlistenerOpinions, + type CaseLawOpinion, +} from "@/app/lib/mikeApi"; +import { cn } from "@/lib/utils"; + +export type CaseTab = { + kind: "case"; + id: `case:${number}`; + chatId: string; + clusterId: number; + citationRef?: number; + caseName: string | null; + citation: string | null; + url: string | null; + dateFiled: string | null; + pdfUrl: string | null; + judges: string | null; + quotes?: CaseCitationQuote[]; + opinions?: CaseLawOpinion[]; +}; + +const courtlistenerOpinionsCache = new Map<number, CaseLawOpinion[]>(); +const caseOpinionsRequestCache = new Map< + string, + ReturnType<typeof getCourtlistenerOpinions> +>(); + +const CASE_OPINION_SANITIZER_CONFIG = { + ALLOWED_TAGS: [ + "a", + "blockquote", + "br", + "code", + "div", + "em", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "i", + "li", + "ol", + "p", + "pre", + "small", + "span", + "strong", + "sub", + "sup", + "table", + "tbody", + "td", + "th", + "thead", + "tr", + "u", + "ul", + ], + ALLOWED_ATTR: [ + "aria-label", + "class", + "colspan", + "href", + "id", + "rel", + "rowspan", + "target", + "title", + ], + ALLOW_DATA_ATTR: false, + ALLOW_ARIA_ATTR: true, + ALLOWED_URI_REGEXP: /^(?:https:\/\/www\.courtlistener\.com\/|#)/i, + FORBID_ATTR: ["style"], + FORBID_TAGS: [ + "embed", + "form", + "iframe", + "math", + "object", + "script", + "style", + "svg", + ], + RETURN_TRUSTED_TYPE: false, +}; + +function sanitizeCaseOpinionHtml(value: string): string { + const sanitized = DOMPurify.sanitize( + value, + CASE_OPINION_SANITIZER_CONFIG, + ); + if (typeof document === "undefined") return sanitized; + + const template = document.createElement("template"); + template.innerHTML = sanitized; + template.content.querySelectorAll("a[href]").forEach((anchor) => { + const href = anchor.getAttribute("href") ?? ""; + if (href.startsWith("#")) return; + anchor.setAttribute("target", "_blank"); + anchor.setAttribute("rel", "noopener noreferrer"); + }); + return template.innerHTML; +} + +function friendlyCaseError(message: string): string { + try { + const parsed = JSON.parse(message) as { detail?: unknown }; + if (typeof parsed.detail === "string") { + message = parsed.detail; + } + } catch { + /* keep original message */ + } + + if (message.includes("429") || /rate limit|throttled/i.test(message)) { + const waitMatch = message.match(/available in\s+(\d+)\s+seconds/i); + const wait = waitMatch?.[1]; + return wait + ? `CourtListener is rate limiting requests. Please try again in about ${wait} seconds.` + : "CourtListener is rate limiting requests. Please try again shortly."; + } + if (message.includes("401") || /credentials|token|auth/i.test(message)) { + return "CourtListener authentication is not configured correctly."; + } + return "Could not load this case from CourtListener. Please try again shortly."; +} + +function formatCaseDate(value: string | null | undefined): string | null { + if (!value) return null; + const date = new Date(`${value}T00:00:00`); + if (Number.isNaN(date.getTime())) return value; + return new Intl.DateTimeFormat("en-US", { + month: "long", + day: "numeric", + year: "numeric", + timeZone: "UTC", + }).format(date); +} + +function hashString(value: string): string { + let hash = 0; + for (let i = 0; i < value.length; i += 1) { + hash = (hash * 31 + value.charCodeAt(i)) | 0; + } + return Math.abs(hash).toString(36); +} + +function caseTabQuoteKey(tab: CaseTab): string { + const quoteKey = + tab.quotes + ?.map((quote) => quote.quote) + .filter(Boolean) + .join("\n---\n") ?? ""; + return [tab.clusterId, tab.citationRef ?? "source", hashString(quoteKey)].join(":"); +} + +function relevantQuoteKey(quote: CaseCitationQuote, index: number): string { + return `${quote.opinionId ?? "unknown"}:${index}:${hashString(quote.quote)}`; +} + +function caseCitationRequestKey(tab: CaseTab) { + return String(tab.clusterId); +} + +export function CaseLawPanel({ + tab, + compactActions = false, +}: { + tab: CaseTab; + compactActions?: boolean; +}) { + const cachedOpinions = courtlistenerOpinionsCache.get(tab.clusterId); + const [opinions, setOpinions] = useState<CaseLawOpinion[]>( + tab.opinions?.length ? tab.opinions : (cachedOpinions ?? []), + ); + const [error, setError] = useState<string | null>(null); + const [loading, setLoading] = useState(false); + const [activeOpinionId, setActiveOpinionId] = useState<number | null>(null); + const [relevantQuotes, setRelevantQuotes] = useState<CaseCitationQuote[]>( + tab.quotes ?? [], + ); + const [activeQuoteKey, setActiveQuoteKey] = useState<string | null>(null); + const [quoteIndexState, setQuoteIndexState] = useState({ + cacheKey: "", + index: 0, + }); + const opinionScrollRef = useRef<HTMLDivElement | null>(null); + const opinionContentRef = useRef<HTMLElement | null>(null); + + useEffect(() => { + if (tab.opinions?.length) { + setOpinions(tab.opinions); + setLoading(false); + setError(null); + return; + } + const cached = courtlistenerOpinionsCache.get(tab.clusterId); + if (cached?.length) { + setOpinions(cached); + setLoading(false); + setError(null); + return; + } + + let cancelled = false; + setLoading(true); + setError(null); + const requestKey = caseCitationRequestKey(tab); + let request = caseOpinionsRequestCache.get(requestKey); + if (!request) { + request = getCourtlistenerOpinions(tab.clusterId).finally(() => { + caseOpinionsRequestCache.delete(requestKey); + }); + caseOpinionsRequestCache.set(requestKey, request); + } + request + .then((nextOpinions) => { + if (!cancelled) { + setOpinions(nextOpinions); + courtlistenerOpinionsCache.set(tab.clusterId, nextOpinions); + } + }) + .catch((err: unknown) => { + if (!cancelled) { + setError( + err instanceof Error + ? friendlyCaseError(err.message) + : "Failed to load case", + ); + } + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [tab]); + + useEffect(() => { + const firstOpinionId = + orderOpinions(opinions).find( + ({ opinion }) => typeof opinion.opinionId === "number", + )?.opinion.opinionId ?? null; + setActiveOpinionId(firstOpinionId); + }, [opinions]); + + useEffect(() => { + setRelevantQuotes(tab.quotes ?? []); + }, [tab.quotes]); + + const title = tab.caseName; + const citation = tab.citation; + const courtlistenerUrl = tab.url; + const filedDate = formatCaseDate(tab.dateFiled); + const judges = tab.judges?.trim() || null; + const orderedOpinions = orderOpinions(opinions); + const activeOpinion = opinions.find( + (opinion) => opinion.opinionId === activeOpinionId, + ); + const quoteCacheKey = caseTabQuoteKey(tab); + const currentQuoteIndex = + quoteIndexState.cacheKey === quoteCacheKey + ? Math.min( + quoteIndexState.index, + Math.max(relevantQuotes.length - 1, 0), + ) + : 0; + const relevantQuoteItems: RelevantQuoteItem[] = relevantQuotes.map( + (quote, index) => ({ + id: relevantQuoteKey(quote, index), + quote: quote.quote, + eyebrow: + quote.author || quote.type + ? opinionTitle({ + opinionId: quote.opinionId, + type: quote.type, + author: quote.author, + url: null, + }) + : null, + }), + ); + + const selectRelevantQuote = useCallback( + (quote: CaseCitationQuote, index: number) => { + const key = relevantQuoteKey(quote, index); + setQuoteIndexState({ cacheKey: quoteCacheKey, index }); + setActiveQuoteKey((current) => (current === key ? null : key)); + if (typeof quote.opinionId === "number") { + setActiveOpinionId(quote.opinionId); + } + }, + [quoteCacheKey], + ); + + useEffect(() => { + setQuoteIndexState({ cacheKey: quoteCacheKey, index: 0 }); + const firstQuote = relevantQuotes[0]; + setActiveQuoteKey(firstQuote ? relevantQuoteKey(firstQuote, 0) : null); + if (typeof firstQuote?.opinionId === "number") { + setActiveOpinionId(firstQuote.opinionId); + } + }, [quoteCacheKey, relevantQuotes]); + + useEffect(() => { + const root = opinionContentRef.current; + if (!root) return; + clearDocxQuoteHighlights(root); + if (!activeQuoteKey) return; + + const activeEntry = relevantQuotes + .map((quote, index) => ({ quote, index })) + .find( + ({ quote, index }) => + relevantQuoteKey(quote, index) === activeQuoteKey, + ); + if (!activeEntry) return; + if ( + typeof activeEntry.quote.opinionId === "number" && + activeEntry.quote.opinionId !== activeOpinionId + ) { + return; + } + + const match = highlightDocxQuote(root, activeEntry.quote.quote); + if (!match) return; + window.setTimeout(() => { + match.scrollIntoView({ behavior: "smooth", block: "center" }); + }, 50); + }, [ + activeOpinionId, + activeOpinion?.html, + activeOpinion?.opinionId, + activeOpinion?.text, + activeQuoteKey, + relevantQuotes, + ]); + + const opinionSurfaceClassName = "bg-white/60 backdrop-blur-xl"; + + return ( + <div className="flex h-full flex-col"> + <div className="flex items-start gap-3 px-3 pt-4 pb-3"> + <div className="min-w-0 flex-1"> + <h2 className="font-serif text-xl text-gray-900"> + {title} + {citation && ( + <span className="text-gray-500">, {citation}</span> + )} + </h2> + {filedDate || judges ? ( + <p className="mt-1 font-serif text-sm text-gray-600"> + {filedDate && <>Date: {filedDate}</>} + {filedDate && judges && ( + <span className="mx-1.5 text-gray-300">|</span> + )} + {judges && <>Judges: {judges}</>} + </p> + ) : null} + </div> + <div className="flex min-w-0 shrink flex-wrap items-center justify-end gap-2"> + {tab.pdfUrl && ( + <a + href={tab.pdfUrl} + target="_blank" + rel="noopener noreferrer" + download + aria-label="Download PDF" + title="Download PDF" + className={`inline-flex min-w-0 shrink items-center justify-center rounded-lg border border-gray-200 text-xs text-gray-700 hover:bg-gray-50 ${ + compactActions + ? "h-8 w-8 p-0" + : "gap-1.5 px-2.5 py-1.5" + }`} + > + <span + className={ + compactActions ? "sr-only" : "truncate" + } + > + PDF + </span> + <Download className="h-3.5 w-3.5" /> + </a> + )} + {courtlistenerUrl && ( + <a + href={courtlistenerUrl} + target="_blank" + rel="noopener noreferrer" + aria-label="Open in CourtListener" + title="Open in CourtListener" + className={`inline-flex min-w-0 shrink items-center justify-center rounded-lg border border-gray-200 text-xs text-gray-700 hover:bg-gray-50 ${ + compactActions + ? "h-8 w-8 p-0" + : "gap-1.5 px-2.5 py-1.5" + }`} + > + <span + className={ + compactActions ? "sr-only" : "truncate" + } + > + CourtListener + </span> + <ExternalLink className="h-3.5 w-3.5" /> + </a> + )} + </div> + </div> + {relevantQuoteItems.length > 0 && ( + <RelevantQuotes + quotes={relevantQuoteItems} + activeQuoteId={activeQuoteKey} + currentIndex={currentQuoteIndex} + citationRef={tab.citationRef} + citationText={[title, citation].filter(Boolean).join(", ")} + onSelect={(_quote, index) => { + const quote = relevantQuotes[index]; + if (quote) selectRelevantQuote(quote, index); + }} + onIndexChange={(index) => { + const quote = relevantQuotes[index]; + if (quote) selectRelevantQuote(quote, index); + }} + /> + )} + {!loading && !error && opinions.length > 1 && ( + <div className="relative mt-2 px-1 shadow-[inset_0_-1px_0_rgb(229_231_235)]"> + <div className="relative z-10 flex items-end gap-1 overflow-hidden px-2 pt-1"> + {orderedOpinions.map(({ opinion, index }) => { + const opinionId = opinion.opinionId; + const isActive = + opinionId !== null && + opinionId === activeOpinionId; + return ( + <button + key={opinionId ?? index} + type="button" + disabled={opinionId === null} + onClick={() => { + if (opinionId === null) return; + setActiveOpinionId(opinionId); + setActiveQuoteKey(null); + }} + style={ + isActive + ? { + filter: "drop-shadow(0 -1px 0 #e5e7eb) drop-shadow(-1px 0 0 #e5e7eb) drop-shadow(1px 0 0 #e5e7eb)", + } + : undefined + } + className={`group relative flex h-8 max-w-[180px] shrink-0 items-center rounded-t-lg px-3 font-serif text-[13px] transition-colors ${ + isActive + ? "z-20 bg-white text-gray-800 before:content-[''] before:absolute before:bottom-0 before:-left-2 before:z-20 before:h-2 before:w-2 before:rounded-br-lg before:shadow-[4px_4px_0_4px_white] before:transition-shadow after:content-[''] after:absolute after:bottom-0 after:-right-2 after:z-20 after:h-2 after:w-2 after:rounded-bl-lg after:shadow-[-4px_4px_0_4px_white] after:transition-shadow" + : "z-10 bg-gray-100 text-gray-600 hover:bg-gray-100 before:content-[''] before:absolute before:bottom-0 before:-left-2 before:h-2 before:w-2 before:rounded-br-lg before:shadow-[4px_4px_0_4px_#f3f4f6] before:transition-shadow after:content-[''] after:absolute after:bottom-0 after:-right-2 after:h-2 after:w-2 after:rounded-bl-lg after:shadow-[-4px_4px_0_4px_#f3f4f6] after:transition-shadow" + } disabled:cursor-not-allowed disabled:opacity-50`} + > + <span className="truncate"> + {opinionTitle(opinion, index)} + </span> + </button> + ); + })} + </div> + </div> + )} + <div className="flex flex-1 min-h-0 flex-col px-3 py-3"> + {loading && ( + <div className={cn("h-full min-h-0 rounded-lg border border-gray-200", opinionSurfaceClassName)}> + <div className="flex h-full items-center justify-center p-5"> + <MikeIcon spin mike size={28} /> + </div> + </div> + )} + {error && ( + <p className={cn("rounded-md p-4 font-serif text-sm text-red-600", opinionSurfaceClassName)}> + {error} + </p> + )} + {!loading && !error && opinions.length === 0 && ( + <p className={cn("rounded-md p-4 font-serif text-sm text-gray-500", opinionSurfaceClassName)}> + No opinions were returned for this case. + </p> + )} + {!loading && !error && opinions.length > 0 && ( + <div className={cn("h-full min-h-0 border border-gray-200 rounded-lg overflow-hidden", opinionSurfaceClassName)}> + {activeOpinion && ( + <div + ref={opinionScrollRef} + className={cn("h-full overflow-y-auto p-5", opinionSurfaceClassName)} + > + <OpinionBlock + opinion={activeOpinion} + contentRef={opinionContentRef} + /> + </div> + )} + </div> + )} + </div> + </div> + ); +} + +function opinionTypeLabel(value: string | null): string { + if (!value) return "Opinion"; + const type = value.replace(/^\d+/, "").replace(/_/g, " ").trim(); + const compactType = type.toLowerCase().replace(/\s+/g, ""); + if (compactType === "lead") return "Lead Opinion"; + if ( + compactType === "concurrentinpart" || + compactType === "concurrenceinpart" || + compactType === "concurinpart" + ) { + return "Concurrence in part"; + } + if (compactType === "combined") return "Combined Opinion"; + return type.replace(/\b\w/g, (char) => char.toUpperCase()); +} + +function opinionOrderRank(value: string | null): number { + const type = value?.replace(/^\d+/, "").toLowerCase() ?? ""; + if ( + type.includes("lead") || + type.includes("majority") || + type.includes("unanimous") || + type.includes("plurality") + ) { + return 0; + } + if (type.includes("concurr")) return 1; + if (type.includes("dissent")) return 2; + if (type.includes("combined")) return 4; + return 3; +} + +function orderOpinions(opinions: CaseLawOpinion[]) { + return opinions + .map((opinion, index) => ({ opinion, index })) + .sort((a, b) => { + const rankDelta = + opinionOrderRank(a.opinion.type) - + opinionOrderRank(b.opinion.type); + return rankDelta || a.index - b.index; + }); +} + +function opinionTitle(opinion: CaseLawOpinion, index?: number): string { + const type = opinionTypeLabel(opinion.type); + const fallbackType = opinion.type ? type : `Opinion ${index ?? ""}`.trim(); + return opinion.author + ? `${fallbackType} by ${opinion.author}` + : fallbackType; +} + +function OpinionBlock({ + opinion, + contentRef, +}: { + opinion: CaseLawOpinion; + contentRef?: RefObject<HTMLElement | null>; +}) { + const sanitizedHtml = useMemo( + () => + opinion.html + ? sanitizeCaseOpinionHtml(opinion.html) + : "", + [opinion.html], + ); + + return ( + <article + ref={contentRef} + className="case-opinion-content border-b border-gray-100 pb-6 last:border-b-0" + > + <div className="mb-3"> + <h3 className="font-serif text-lg font-semibold text-gray-900"> + {opinionTitle(opinion)} + </h3> + </div> + {sanitizedHtml ? ( + <div + className="prose prose-sm max-w-none font-serif leading-7 text-gray-900 [&_*]:font-serif [&_.case-page-number]:mx-1 [&_.case-page-number]:text-xs [&_.case-page-number]:text-gray-400 [&_a]:text-blue-600 [&_a]:underline [&_a:hover]:text-blue-700 [&_p]:my-3" + dangerouslySetInnerHTML={{ __html: sanitizedHtml }} + /> + ) : ( + <div className="whitespace-pre-wrap font-serif text-sm leading-7 text-gray-900 [&_p]:my-3"> + {opinion.text || "No opinion text returned."} + </div> + )} + </article> + ); +} diff --git a/frontend/src/app/components/assistant/ChatInput.tsx b/frontend/src/app/components/assistant/ChatInput.tsx index b8a5c4d..e6ded8e 100644 --- a/frontend/src/app/components/assistant/ChatInput.tsx +++ b/frontend/src/app/components/assistant/ChatInput.tsx @@ -3,6 +3,7 @@ import { useState, useCallback, + useEffect, useRef, forwardRef, useImperativeHandle, @@ -29,14 +30,15 @@ import { isModelAvailable, type ModelProvider, } from "@/app/lib/modelAvailability"; -import type { MikeDocument, MikeMessage } from "../shared/types"; +import type { Document, Message } from "../shared/types"; +import { cn } from "@/lib/utils"; export interface ChatInputHandle { - addDoc: (doc: MikeDocument) => void; + addDoc: (doc: Document) => void; } interface Props { - onSubmit: (message: MikeMessage) => void; + onSubmit: (message: Message) => void; onCancel: () => void; isLoading: boolean; hideAddDocButton?: boolean; @@ -60,7 +62,7 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput( ref, ) { const [value, setValue] = useState(""); - const [attachedDocs, setAttachedDocs] = useState<MikeDocument[]>([]); + const [attachedDocs, setAttachedDocs] = useState<Document[]>([]); const [selectedWorkflow, setSelectedWorkflow] = useState<{ id: string; title: string; @@ -69,13 +71,15 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput( const { profile } = useUserProfile(); const apiKeys = profile?.apiKeys; const textareaRef = useRef<HTMLTextAreaElement>(null); + const controlsRef = useRef<HTMLDivElement>(null); + const [compactControls, setCompactControls] = useState(false); const [docSelectorOpen, setDocSelectorOpen] = useState(false); const [workflowModalOpen, setWorkflowModalOpen] = useState(false); const [apiKeyModalProvider, setApiKeyModalProvider] = useState<ModelProvider | null>(null); useImperativeHandle(ref, () => ({ - addDoc: (doc: MikeDocument) => { + addDoc: (doc: Document) => { setAttachedDocs((prev) => { if (prev.some((d) => d.id === doc.id)) return prev; return [...prev, doc]; @@ -83,7 +87,17 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput( }, })); - const handleAddDocFromProject = useCallback((doc: MikeDocument) => { + useEffect(() => { + const el = controlsRef.current; + if (!el) return; + const update = () => setCompactControls(el.offsetWidth < 430); + update(); + const observer = new ResizeObserver(update); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + const handleAddDocFromProject = useCallback((doc: Document) => { setAttachedDocs((prev) => { if (prev.some((d) => d.id === doc.id)) return prev; return [...prev, doc]; @@ -91,7 +105,7 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput( }, []); const handleAddDocsFromSelector = useCallback( - (selectedDocs: MikeDocument[]) => { + (selectedDocs: Document[]) => { setAttachedDocs((prev) => { const existing = new Set(prev.map((d) => d.id)); return [ @@ -157,7 +171,7 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput( return ( <> <div className="w-full"> - <div className="border border-gray-300 rounded-[16px] md:rounded-[20px] bg-white"> + <div className="rounded-[18px] border border-white/65 bg-white/60 shadow-[0_4px_10px_rgba(15,23,42,0.12),inset_0_1px_0_rgba(255,255,255,0.85),inset_0_-6px_14px_rgba(255,255,255,0.18)] backdrop-blur-2xl md:rounded-[22px]"> {/* Attached chips */} {(selectedWorkflow || attachedDocs.length > 0) && ( <div className="flex flex-wrap gap-1.5 px-2 pt-2"> @@ -184,12 +198,12 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput( return ( <div key={doc.id} - className="inline-flex items-center gap-1 pl-2 pr-1 py-0.5 rounded-full text-xs text-white shadow border border-white/20 bg-black backdrop-blur-sm" + className="inline-flex items-center gap-1 rounded-[10px] border border-white/70 bg-white py-0.5 pl-2 pr-1 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" /> + <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-400" /> + <File className="h-2.5 w-2.5 shrink-0 text-blue-500" /> )} <span className="max-w-[140px] truncate"> {doc.filename} @@ -203,7 +217,7 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput( ), ) } - className="rounded-full p-0.5 ml-0.5 text-white/60 hover:text-white hover:bg-white/20 transition-colors" + className="ml-0.5 rounded-full p-0.5 text-gray-400 transition-colors hover:bg-gray-900/5 hover:text-gray-700" > <X className="h-2.5 w-2.5" /> </button> @@ -227,7 +241,10 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput( </div> {/* Controls */} - <div className="flex items-center justify-between md:p-2.5 p-2"> + <div + ref={controlsRef} + className="flex items-center justify-between md:p-2.5 p-2" + > <div className="flex items-center gap-1"> {!hideAddDocButton && ( <AddDocButton @@ -236,6 +253,7 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput( selectedDocIds={attachedDocs.map( (d) => d.id, )} + hideLabel={compactControls} /> )} {!hideWorkflowButton && ( @@ -243,14 +261,25 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput( type="button" onClick={() => setWorkflowModalOpen(true)} aria-label="Open workflows" - className={`flex items-center gap-1.5 rounded-lg px-2 h-8 text-sm transition-colors ${selectedWorkflow ? "text-blue-600 hover:bg-blue-50" : "text-gray-400 hover:bg-gray-100 hover:text-gray-700"}`} + className={cn( + "flex items-center gap-1.5 rounded-lg px-2 h-8 text-sm transition-colors", + selectedWorkflow + ? "text-blue-600 hover:bg-white/55" + : "text-gray-400 hover:bg-white/55 hover:text-gray-700", + )} > {selectedWorkflow ? ( <Check className="h-3.5 w-3.5" /> ) : ( <Library className="h-3.5 w-3.5" /> )} - <span className="hidden sm:inline"> + <span + className={ + compactControls + ? "hidden" + : "hidden sm:inline" + } + > Workflows </span> </button> @@ -260,7 +289,10 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput( type="button" onClick={onProjectsClick} aria-label="Open projects" - className="flex items-center gap-1.5 rounded-lg px-2 h-8 text-sm text-gray-400 hover:bg-gray-100 hover:text-gray-700 transition-colors" + className={cn( + "flex items-center gap-1.5 rounded-lg px-2 h-8 text-sm text-gray-400 hover:text-gray-700 transition-colors", + "hover:bg-white/55", + )} > <FolderOpen className="h-3.5 w-3.5" /> <span className="hidden sm:inline"> @@ -278,7 +310,10 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput( /> <button type="button" - className="relative bg-gradient-to-b from-neutral-700 to-black text-white rounded-[10px] h-8 w-8 flex items-center justify-center cursor-pointer disabled:cursor-default disabled:from-neutral-600 disabled:to-black backdrop-blur-xl border border-white/30 active:enabled:scale-95 transition-all duration-150" + className={cn( + "relative bg-gradient-to-b from-neutral-700 to-black text-white rounded-[10px] h-8 w-8 flex items-center justify-center cursor-pointer disabled:cursor-default disabled:from-neutral-600 disabled:to-black backdrop-blur-xl border border-white/30 active:enabled:scale-95 transition-all duration-150", + "shadow-[0_5px_14px_rgba(15,23,42,0.18),inset_0_1px_0_rgba(255,255,255,0.24)]", + )} onClick={handleActionClick} disabled={!isLoading && !value.trim()} > diff --git a/frontend/src/app/components/assistant/ChatView.tsx b/frontend/src/app/components/assistant/ChatView.tsx index 9f59685..3c61ac8 100644 --- a/frontend/src/app/components/assistant/ChatView.tsx +++ b/frontend/src/app/components/assistant/ChatView.tsx @@ -1,6 +1,7 @@ "use client"; import { useCallback, useState, useRef, useEffect } from "react"; +import { flushSync } from "react-dom"; import { ArrowDown } from "lucide-react"; import { UserMessage } from "./UserMessage"; import { AssistantMessage } from "./AssistantMessage"; @@ -11,21 +12,35 @@ import { } from "./AssistantSidePanel"; import { AssistantWorkflowModal } from "./AssistantWorkflowModal"; import type { - MikeCitationAnnotation, - MikeEditAnnotation, - MikeMessage, + AssistantEvent, + CitationAnnotation, + EditAnnotation, + Message, } from "../shared/types"; import { useSidebar } from "@/app/contexts/SidebarContext"; import { invalidateDocxBytes } from "@/app/hooks/useFetchDocxBytes"; +import { cn } from "@/lib/utils"; interface Props { - messages: MikeMessage[]; + chatId?: string | null; + messages: Message[]; isResponseLoading: boolean; - handleChat: (message: MikeMessage) => Promise<string | null>; + handleChat: (message: Message) => Promise<string | null>; cancel: () => void; } +const ASSISTANT_PANEL_TRANSITION_MS = 500; +const MOBILE_BREAKPOINT_PX = 768; + +function isSmallScreen() { + return ( + typeof window !== "undefined" && + window.innerWidth < MOBILE_BREAKPOINT_PX + ); +} + export function ChatView({ + chatId, messages, isResponseLoading, handleChat, @@ -49,38 +64,86 @@ export function ChatView({ () => new Set(), ); const { setSidebarOpen } = useSidebar(); - + const panelCloseTimerRef = useRef<number | null>(null); const showPanel = useCallback(() => { + if (panelCloseTimerRef.current !== null) { + window.clearTimeout(panelCloseTimerRef.current); + panelCloseTimerRef.current = null; + } + flushSync(() => { + setSidebarOpen(false); + }); + + if (panelMounted) { + setPanelVisible(true); + return; + } + + setPanelVisible(false); setPanelMounted(true); - setSidebarOpen(false); requestAnimationFrame(() => requestAnimationFrame(() => setPanelVisible(true)), ); + }, [panelMounted, setSidebarOpen]); + + const restoreSidebarAfterPanelClose = useCallback(() => { + if (!isSmallScreen()) setSidebarOpen(true); }, [setSidebarOpen]); - const closeAllTabs = useCallback(() => { - setPanelVisible(false); - setTimeout(() => { - setTabs([]); - setActiveTabId(null); + useEffect( + () => () => { + if (panelCloseTimerRef.current !== null) { + window.clearTimeout(panelCloseTimerRef.current); + } + }, + [], + ); + + const hidePanel = useCallback( + (afterHidden: () => void) => { + if (panelCloseTimerRef.current !== null) { + window.clearTimeout(panelCloseTimerRef.current); + } + setPanelVisible(false); + panelCloseTimerRef.current = window.setTimeout(() => { + panelCloseTimerRef.current = null; + afterHidden(); + }, ASSISTANT_PANEL_TRANSITION_MS); + }, + [], + ); + + const unmountPanel = useCallback( + (afterUnmount?: () => void) => { setPanelMounted(false); - setSidebarOpen(true); - }, 300); - }, [setSidebarOpen]); + restoreSidebarAfterPanelClose(); + afterUnmount?.(); + }, + [restoreSidebarAfterPanelClose], + ); + + const closeAllTabs = useCallback(() => { + hidePanel(() => + unmountPanel(() => { + setTabs([]); + setActiveTabId(null); + }), + ); + }, [hidePanel, unmountPanel]); const closeTab = useCallback( (id: string) => { setTabs((prev) => { const next = prev.filter((t) => t.id !== id); if (next.length === 0) { - setPanelVisible(false); - setTimeout(() => { - setActiveTabId(null); - setPanelMounted(false); - setSidebarOpen(true); - }, 300); - return next; + hidePanel(() => + unmountPanel(() => { + setActiveTabId(null); + setTabs([]); + }), + ); + return prev; } if (activeTabId === id) { const idx = prev.findIndex((t) => t.id === id); @@ -90,7 +153,7 @@ export function ChatView({ return next; }); }, - [activeTabId, setSidebarOpen], + [activeTabId, hidePanel, unmountPanel], ); /** @@ -104,18 +167,23 @@ export function ChatView({ const upsertTab = useCallback( (tab: AssistantSidePanelTab) => { setTabs((prev) => { - const idx = prev.findIndex( - (t) => t.documentId === tab.documentId, + const idx = prev.findIndex((t) => + tab.kind === "case" + ? t.kind === "case" && t.id === tab.id + : t.kind !== "case" && t.documentId === tab.documentId, ); if (idx >= 0) { const existing = prev[idx]; const copy = prev.slice(); - copy[idx] = { - ...tab, - id: existing.id, - warning: existing.warning, - initialScrollTop: existing.initialScrollTop, - }; + copy[idx] = + tab.kind === "case" || existing.kind === "case" + ? tab + : { + ...tab, + id: existing.id, + warning: existing.warning, + initialScrollTop: existing.initialScrollTop, + }; return copy; } return [...prev, tab]; @@ -131,7 +199,38 @@ export function ChatView({ * AssistantMessage when the user clicks a numbered citation pill. */ const openCitation = useCallback( - (citation: MikeCitationAnnotation) => { + (citation: CitationAnnotation, options?: { showQuotes?: boolean }) => { + const showQuotes = options?.showQuotes ?? true; + if (citation.kind === "case") { + if (!chatId) return; + upsertTab({ + kind: "case", + id: `case:${citation.cluster_id}`, + chatId, + clusterId: citation.cluster_id, + citationRef: citation.ref, + caseName: citation.case_name ?? null, + citation: citation.citation ?? null, + url: citation.url ?? null, + dateFiled: citation.dateFiled ?? null, + pdfUrl: citation.pdfUrl ?? null, + judges: citation.judges ?? null, + quotes: showQuotes ? citation.quotes : undefined, + opinions: undefined, + }); + return; + } + if (!showQuotes) { + upsertTab({ + kind: "document", + id: citation.document_id, + documentId: citation.document_id, + filename: citation.filename, + versionId: citation.version_id ?? null, + versionNumber: citation.version_number ?? null, + }); + return; + } upsertTab({ kind: "citation", id: citation.document_id, @@ -142,7 +241,30 @@ export function ChatView({ citation, }); }, - [upsertTab], + [chatId, upsertTab], + ); + + const openCase = useCallback( + (citation: Extract<AssistantEvent, { type: "case_citation" }>) => { + if (!citation.cluster_id) return; + if (!chatId) return; + upsertTab({ + kind: "case", + id: `case:${citation.cluster_id}`, + chatId, + clusterId: citation.cluster_id, + citationRef: undefined, + caseName: citation.case_name, + citation: citation.citation, + url: citation.url, + dateFiled: citation.dateFiled ?? null, + pdfUrl: citation.pdfUrl ?? null, + judges: citation.judges ?? null, + quotes: undefined, + opinions: citation.case?.opinions, + }); + }, + [chatId, upsertTab], ); /** @@ -150,7 +272,7 @@ export function ChatView({ * AssistantMessage when the user clicks an EditCard's View button. */ const openEditor = useCallback( - (ann: MikeEditAnnotation, filename: string) => { + (ann: EditAnnotation, filename: string) => { upsertTab({ kind: "edit", id: ann.document_id, @@ -260,15 +382,18 @@ export function ChatView({ [], ); - const patchTab = useCallback( ( tabId: string, - patch: Partial<Pick<AssistantSidePanelTab, "warning" | "initialScrollTop">>, + patch: { + warning?: string | null; + initialScrollTop?: number | null; + }, ) => { setTabs((prev) => { const idx = prev.findIndex((t) => t.id === tabId); if (idx < 0) return prev; + if (prev[idx].kind === "case") return prev; const copy = prev.slice(); copy[idx] = { ...copy[idx], ...patch }; return copy; @@ -287,7 +412,7 @@ export function ChatView({ // Surface the warning on every tab tied to this document. setTabs((prev) => prev.map((t) => - t.documentId === args.documentId + t.kind !== "case" && t.documentId === args.documentId ? { ...t, warning: args.message } : t, ), @@ -328,8 +453,15 @@ export function ChatView({ const messagesEndRef = useRef<HTMLDivElement>(null); const latestUserMessageRef = useRef<HTMLDivElement>(null); const chatInputRef = useRef<HTMLDivElement>(null); - const hasScrolledRef = useRef(false); - const [messagesVisible, setMessagesVisible] = useState(false); + // Seed "already in place" when messages exist at mount (a freshly created + // chat arrives with its first message in hand). Otherwise the skeleton + + // opacity-0 gate would flash the message out and fade it back in on every + // remount. Existing chats mount with messages === [] and fetch async, so + // they still start hidden and reveal once loaded. + const hasScrolledRef = useRef(messages.length > 0); + const [messagesVisible, setMessagesVisible] = useState( + () => messages.length > 0, + ); const [showScrollButton, setShowScrollButton] = useState(false); const [inputHeight, setInputHeight] = useState(0); const [minHeight, setMinHeight] = useState("0px"); @@ -446,7 +578,7 @@ export function ChatView({ return ( <div className="h-full w-full flex relative"> {/* Chat column */} - <div className="flex flex-col h-full flex-1 relative"> + <div className="flex min-w-0 flex-col h-full flex-1 relative"> {/* Scrollable messages */} <div ref={messagesContainerRef} @@ -507,13 +639,28 @@ export function ChatView({ } isError={!!(msg as any).error} errorMessage={ - typeof (msg as any).error === - "string" + typeof (msg as any) + .error === "string" ? (msg as any).error : undefined } annotations={msg.annotations} - onCitationClick={openCitation} + citationStatus={ + msg.citationStatus + } + onCitationClick={(citation) => + openCitation(citation) + } + onOpenCitationSource={( + citation, + ) => + openCitation(citation, { + showQuotes: false, + }) + } + onCaseClick={(citation) => + openCase(citation) + } minHeight={ i === lastAssistantIndex ? minHeight @@ -561,7 +708,10 @@ export function ChatView({ > <button onClick={scrollToBottom} - className="p-2 rounded-full bg-white/70 backdrop-blur-xs shadow-lg cursor-pointer border border-gray-300" + className={cn( + "rounded-full p-2 cursor-pointer transition-all", + "bg-white/30 shadow-[0_5px_16px_rgba(15,23,42,0.13),inset_0_1px_0_rgba(255,255,255,0.75),inset_0_-8px_18px_rgba(255,255,255,0.26)] backdrop-blur-xl hover:bg-white/45 hover:shadow-[0_7px_20px_rgba(15,23,42,0.16),inset_0_1px_0_rgba(255,255,255,0.85),inset_0_-8px_18px_rgba(255,255,255,0.32)]", + )} > <ArrowDown className="h-6 w-6 text-gray-500" /> </button> @@ -573,8 +723,19 @@ export function ChatView({ ref={chatInputRef} className="absolute bottom-0 left-0 right-0 w-full z-30" > - <div className="w-full max-w-4xl mx-auto px-4 md:px-6"> - <div className="w-full rounded-t-[20px] bg-white"> + <div + className={cn( + "pointer-events-none absolute bottom-0 left-0 z-0", + "right-4 h-28 bg-gradient-to-t from-white/50 via-white/25 to-transparent backdrop-blur-[1px]", + )} + /> + <div className="relative z-20 w-full max-w-4xl mx-auto px-4 md:px-6"> + <div + className={cn( + "w-full rounded-t-[20px]", + "bg-transparent", + )} + > <ChatInput onSubmit={handleChat} onCancel={cancel} @@ -600,7 +761,7 @@ export function ChatView({ {panelMounted && ( <div - className={`fixed md:relative inset-0 md:inset-auto md:h-full md:flex-shrink-0 z-40 md:z-auto transition-transform duration-300 ease-in-out ${panelVisible ? "translate-x-0" : "translate-x-full"}`} + className={`fixed inset-0 z-40 flex justify-center p-3 transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] md:relative md:inset-auto md:z-auto md:block md:h-full md:min-w-0 md:flex-shrink-0 md:p-0 ${panelVisible ? "translate-x-0" : "translate-x-full"}`} > <AssistantSidePanel tabs={tabs} diff --git a/frontend/src/app/components/assistant/EditCard.tsx b/frontend/src/app/components/assistant/EditCard.tsx index ba2ea61..ddee3ab 100644 --- a/frontend/src/app/components/assistant/EditCard.tsx +++ b/frontend/src/app/components/assistant/EditCard.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { supabase } from "@/lib/supabase"; -import type { MikeEditAnnotation } from "../shared/types"; +import type { EditAnnotation } from "../shared/types"; function normalizeText(s: string) { return s.replace(/\s+/g, " ").trim(); @@ -19,13 +19,6 @@ function findMatch( const byId = container.querySelector( `${tag}[data-w-id="${opts.w_id}"]`, ) as HTMLElement | null; - console.log("[EditCard] findMatch by w_id", { - tag, - w_id: opts.w_id, - found: !!byId, - totalTagged: container.querySelectorAll(`${tag}[data-w-id]`).length, - totalAny: container.querySelectorAll(tag).length, - }); if (byId) return byId; } const text = opts.text ?? ""; @@ -42,12 +35,6 @@ function findMatch( normalizeText(el.textContent ?? "").includes(target), ) ?? null; - console.log("[EditCard] findMatch by text", { - tag, - target, - found: !!byText, - candidateCount: candidates.length, - }); return byText; } @@ -63,7 +50,7 @@ function findMatch( * so if the backend call later fails we can restore the original look. */ export function applyOptimisticResolution( - annotation: MikeEditAnnotation, + annotation: EditAnnotation, verb: "accept" | "reject", ): () => void { const reverts: (() => void)[] = []; @@ -117,13 +104,6 @@ export function applyOptimisticResolution( const scrolls = document.querySelectorAll( `[data-document-id="${CSS.escape(annotation.document_id)}"]`, ); - console.log("[EditCard] optimistic scrolls found:", scrolls.length, { - document_id: annotation.document_id, - ins_w_id: annotation.ins_w_id, - del_w_id: annotation.del_w_id, - inserted_text: annotation.inserted_text?.slice(0, 40), - deleted_text: annotation.deleted_text?.slice(0, 40), - }); scrolls.forEach((scroll) => { const container = scroll.querySelector(".docx-view-container"); if (!container) return; @@ -150,7 +130,7 @@ export function applyOptimisticResolution( } interface Props { - annotation: MikeEditAnnotation; + annotation: EditAnnotation; /** * External override for this edit's status. When set, takes * precedence over the annotation's DB status and the card's own @@ -164,7 +144,7 @@ interface Props { * Accept/Reject buttons disable so the user can't race resolutions. */ isReloading?: boolean; - onViewClick?: (ann: MikeEditAnnotation) => void; + onViewClick?: (ann: EditAnnotation) => void; /** * Fires immediately when the user clicks Accept or Reject, before the * backend round-trip. Parents use this to show an in-progress spinner diff --git a/frontend/src/app/components/assistant/InitialView.tsx b/frontend/src/app/components/assistant/InitialView.tsx index fe7ea40..93ca60f 100644 --- a/frontend/src/app/components/assistant/InitialView.tsx +++ b/frontend/src/app/components/assistant/InitialView.tsx @@ -6,14 +6,14 @@ import { useUserProfile } from "@/contexts/UserProfileContext"; import { MikeIcon } from "@/components/chat/mike-icon"; import { ChatInput } from "./ChatInput"; import { SelectAssistantProjectModal } from "./SelectAssistantProjectModal"; -import type { MikeMessage } from "../shared/types"; +import type { Message } from "../shared/types"; interface InitialViewProps { - onSubmit: (message: MikeMessage) => void; + onSubmit: (message: Message) => void; } -const ICON_SIZE = 35; -const GAP = 16; // gap-4 = 1rem = 16px +const ICON_SIZE = 30; +const GAP = 12; // gap-4 = 1rem = 16px export function InitialView({ onSubmit }: InitialViewProps) { const { user } = useAuth(); @@ -46,7 +46,7 @@ export function InitialView({ onSubmit }: InitialViewProps) { <div className="flex-col items-center w-full max-w-4xl relative px-0 xl:px-8"> <div className="mb-10 relative flex items-center justify-center"> <div - className="absolute h-[35px]" + className="absolute h-[30px] w-[30px] top-[-14px]" style={{ left: "50%", transform: loaded diff --git a/frontend/src/app/components/assistant/ModelToggle.tsx b/frontend/src/app/components/assistant/ModelToggle.tsx index f1710d8..09c98cd 100644 --- a/frontend/src/app/components/assistant/ModelToggle.tsx +++ b/frontend/src/app/components/assistant/ModelToggle.tsx @@ -25,7 +25,18 @@ export const MODELS: ModelOption[] = [ { 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" }, - { id: "gpt-5.4-mini", label: "GPT-5.4 Mini", group: "OpenAI" }, + { id: "gpt-5.4", label: "GPT-5.4", group: "OpenAI" }, +]; + +export const SETTINGS_MODELS: ModelOption[] = [ + ...MODELS, + { id: "claude-haiku-4-5", label: "Claude Haiku 4.5", group: "Anthropic" }, + { + id: "gemini-3.1-flash-lite-preview", + label: "Gemini 3.1 Flash Lite", + group: "Google", + }, + { id: "gpt-5.4-lite", label: "GPT-5.4 Lite", group: "OpenAI" }, ]; export const DEFAULT_MODEL_ID = "gemini-3-flash-preview"; @@ -69,7 +80,7 @@ export function ModelToggle({ value, onChange, apiKeys }: Props) { /> </button> </DropdownMenuTrigger> - <DropdownMenuContent className="w-56 z-50" side="top" align="start"> + <DropdownMenuContent className="w-56 z-50" side="top" align="end"> {GROUP_ORDER.map((group, gi) => { const items = MODELS.filter((m) => m.group === group); if (items.length === 0) return null; diff --git a/frontend/src/app/components/assistant/SelectAssistantProjectModal.tsx b/frontend/src/app/components/assistant/SelectAssistantProjectModal.tsx index 826ad4a..0c8bd94 100644 --- a/frontend/src/app/components/assistant/SelectAssistantProjectModal.tsx +++ b/frontend/src/app/components/assistant/SelectAssistantProjectModal.tsx @@ -1,12 +1,11 @@ "use client"; import { useEffect, useState } from "react"; -import { createPortal } from "react-dom"; -import { X, Loader2 } from "lucide-react"; import { useRouter } from "next/navigation"; import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext"; import { useDirectoryData } from "../shared/useDirectoryData"; import { ProjectPicker } from "../shared/ProjectPicker"; +import { Modal } from "../shared/Modal"; interface Props { open: boolean; @@ -40,53 +39,23 @@ export function SelectAssistantProjectModal({ open, onClose }: Props) { } } - return createPortal( - <div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/10 backdrop-blur-xs"> - <div className="w-full max-w-2xl rounded-2xl bg-white shadow-2xl flex flex-col h-[600px]"> - {/* Header */} - <div className="flex items-center justify-between px-5 py-4"> - <div className="flex items-center gap-1.5 text-xs text-gray-400"> - <span>Assistant</span> - <span>›</span> - <span>Start Chat in a Project</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> - - <ProjectPicker - projects={projects} - loading={loading} - selectedId={selectedId} - onSelect={setSelectedId} - /> - - {/* Footer */} - <div className="border-t border-gray-100 px-4 py-3 flex items-center justify-end gap-2"> - <button - onClick={onClose} - className="rounded-lg px-3 py-1.5 text-sm text-gray-500 hover:bg-gray-100" - > - Cancel - </button> - <button - onClick={handleContinue} - disabled={!selectedId || creating} - className="rounded-lg bg-gray-900 px-4 py-1.5 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-40" - > - {creating ? ( - <Loader2 className="h-3.5 w-3.5 animate-spin" /> - ) : ( - "Continue" - )} - </button> - </div> - </div> - </div>, - document.body, + return ( + <Modal + open={open} + onClose={onClose} + breadcrumbs={["Assistant", "Start Chat in a Project"]} + primaryAction={{ + label: creating ? "Creating…" : "Continue", + onClick: handleContinue, + disabled: !selectedId || creating, + }} + > + <ProjectPicker + projects={projects} + loading={loading} + selectedId={selectedId} + onSelect={setSelectedId} + /> + </Modal> ); } diff --git a/frontend/src/app/components/projects/DocumentSidePanel.tsx b/frontend/src/app/components/projects/DocumentSidePanel.tsx new file mode 100644 index 0000000..584567e --- /dev/null +++ b/frontend/src/app/components/projects/DocumentSidePanel.tsx @@ -0,0 +1,769 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { + AlertCircle, + Check, + Download, + Loader2, + Pencil, + Trash2, + Upload, + X, +} from "lucide-react"; +import { ConfirmPopup } from "@/app/components/shared/ConfirmPopup"; +import { DocView } from "@/app/components/shared/DocView"; +import { DocFileIcon } from "@/app/components/shared/FileDirectory"; +import { WarningPopup } from "@/app/components/shared/WarningPopup"; +import type { Document } from "@/app/components/shared/types"; +import type { DocumentVersion } from "@/app/lib/mikeApi"; +import { cn } from "@/lib/utils"; +import { formatBytes, formatDate } from "./ProjectPageParts"; + +const MIN_DOC_COLUMN_WIDTH = 420; +const DEFAULT_DOC_COLUMN_WIDTH = 620; +const MIN_DATA_COLUMN_WIDTH = 280; +const DEFAULT_DATA_COLUMN_WIDTH = 340; +const RESIZER_WIDTH = 6; +const MAX_PANEL_WIDTH = 1180; + +interface DocumentSidePanelProps { + doc: Document | null; + versionId?: string | null; + currentVersionId?: string | null; + versions: DocumentVersion[]; + versionsLoading: boolean; + onClose: () => void; + onLoadVersions: (docId: string) => Promise<void> | void; + onSelectVersion: (versionId: string, label: string) => void; + onDownloadDocument: (docId: string) => Promise<void> | void; + onDownloadVersion: ( + docId: string, + versionId: string, + filename: string, + ) => Promise<void> | void; + onRenameVersion: ( + docId: string, + versionId: string, + filename: string, + ) => Promise<void> | void; + onDeleteVersion: ( + docId: string, + versionId: string, + ) => Promise<void> | void; + onUploadNewVersion: ( + doc: Document, + file: File, + filename: string, + ) => Promise<void>; + onDelete: (doc: Document) => Promise<void> | void; +} + +export function DocumentSidePanel({ + doc, + versionId, + currentVersionId, + versions, + versionsLoading, + onClose, + onLoadVersions, + onSelectVersion, + onDownloadDocument, + onDownloadVersion, + onRenameVersion, + onDeleteVersion, + onUploadNewVersion, + onDelete, +}: DocumentSidePanelProps) { + const [mounted, setMounted] = useState(false); + const [uploading, setUploading] = useState(false); + const [uploadError, setUploadError] = useState<string | null>(null); + const [editingName, setEditingName] = useState(false); + const [nameDraft, setNameDraft] = useState(""); + const [savingName, setSavingName] = useState(false); + const [nameError, setNameError] = useState<string | null>(null); + const [extensionWarningOpen, setExtensionWarningOpen] = useState(false); + const [deletingVersion, setDeletingVersion] = useState(false); + const [deletingDocument, setDeletingDocument] = useState(false); + const [confirmDeleteDocumentOpen, setConfirmDeleteDocumentOpen] = + useState(false); + const [deleteDocumentStatus, setDeleteDocumentStatus] = useState< + "idle" | "deleting" | "deleted" + >("idle"); + const [dataColumnWidth, setDataColumnWidth] = useState( + DEFAULT_DATA_COLUMN_WIDTH, + ); + const [panelWidth, setPanelWidth] = useState( + DEFAULT_DOC_COLUMN_WIDTH + RESIZER_WIDTH + DEFAULT_DATA_COLUMN_WIDTH, + ); + const panelRef = useRef<HTMLDivElement>(null); + const fileInputRef = useRef<HTMLInputElement>(null); + const dragStartX = useRef(0); + const dragStartDataWidth = useRef(DEFAULT_DATA_COLUMN_WIDTH); + const dragStartPanelWidth = useRef( + DEFAULT_DOC_COLUMN_WIDTH + RESIZER_WIDTH + DEFAULT_DATA_COLUMN_WIDTH, + ); + + useEffect(() => setMounted(true), []); + + useEffect(() => { + if (!mounted) return; + function handleWindowResize() { + setPanelWidth((width) => clampPanelWidth(width, dataColumnWidth)); + } + handleWindowResize(); + window.addEventListener("resize", handleWindowResize); + return () => window.removeEventListener("resize", handleWindowResize); + }, [dataColumnWidth, mounted]); + + useEffect(() => { + if (!doc) return; + setUploadError(null); + void onLoadVersions(doc.id); + }, [doc?.id]); + + useEffect(() => { + setEditingName(false); + setNameDraft(""); + setNameError(null); + setExtensionWarningOpen(false); + }, [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 orderedVersions = [...versions].reverse(); + const selectedVersion = + versions.find((version) => version.id === versionId) ?? + versions.find((version) => version.id === currentVersionId) ?? + orderedVersions[0] ?? + null; + const selectedVersionId = selectedVersion?.id ?? versionId ?? null; + const selectedFilename = + selectedVersion?.filename?.trim() || doc.filename; + const selectedFileType = + selectedVersion != null + ? fileTypeForVersion(selectedVersion, doc.file_type) + : doc.file_type; + const selectedSizeBytes = + selectedVersion?.size_bytes === undefined + ? doc.size_bytes + : selectedVersion.size_bytes; + const selectedPageCount = + selectedVersion?.page_count === undefined + ? doc.page_count + : selectedVersion.page_count; + const selectedVersionNumber = + selectedVersion?.version_number ?? doc.active_version_number ?? null; + const selectedUploadedAt = selectedVersion?.created_at ?? doc.created_at; + const selectedExtension = filenameExtension(selectedFilename); + + async function handleSaveName() { + if (!selectedVersionId) return; + const trimmed = nameDraft.trim(); + if (!trimmed) { + setNameError("Name is required."); + return; + } + if (hasExtensionChange(selectedFilename, trimmed)) { + setExtensionWarningOpen(true); + return; + } + if (trimmed === selectedFilename) { + setEditingName(false); + setNameError(null); + return; + } + + setSavingName(true); + setNameError(null); + try { + await onRenameVersion(documentId, selectedVersionId, trimmed); + setEditingName(false); + } catch (err) { + console.error("rename version failed", err); + setNameError("Could not save name."); + } finally { + setSavingName(false); + } + } + + async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) { + const file = e.target.files?.[0] ?? null; + if (fileInputRef.current) fileInputRef.current.value = ""; + if (!file || !doc) return; + setUploadError(null); + setUploading(true); + try { + await onUploadNewVersion(doc, file, file.name); + } catch (err) { + console.error("upload new version failed", err); + setUploadError("Could not upload the new version."); + } finally { + setUploading(false); + } + } + + async function handleDeleteSelectedVersion() { + if (!selectedVersionId) return; + setDeletingVersion(true); + try { + await onDeleteVersion(documentId, selectedVersionId); + } catch (err) { + console.error("delete version failed", err); + } finally { + setDeletingVersion(false); + } + } + + async function handleDeleteDocument() { + if (deleteDocumentStatus === "deleting") return; + setDeleteDocumentStatus("deleting"); + setDeletingDocument(true); + try { + await onDelete(activeDoc); + setDeleteDocumentStatus("deleted"); + window.setTimeout(() => { + setConfirmDeleteDocumentOpen(false); + setDeleteDocumentStatus("idle"); + onClose(); + }, 650); + } catch (err) { + console.error("delete document failed", err); + setDeleteDocumentStatus("idle"); + } finally { + setDeletingDocument(false); + } + } + + function requestDeleteDocument() { + if (versions.length > 1) { + setDeleteDocumentStatus("idle"); + setConfirmDeleteDocumentOpen(true); + return; + } + void handleDeleteDocument(); + } + + function handleResizeMouseDown(e: React.MouseEvent<HTMLDivElement>) { + e.preventDefault(); + dragStartX.current = e.clientX; + dragStartDataWidth.current = dataColumnWidth; + + const handleMouseMove = (event: MouseEvent) => { + const panelWidth = + panelRef.current?.clientWidth ?? window.innerWidth; + const maxDataWidth = Math.max( + MIN_DATA_COLUMN_WIDTH, + panelWidth - MIN_DOC_COLUMN_WIDTH - RESIZER_WIDTH, + ); + const nextWidth = + dragStartDataWidth.current + (dragStartX.current - event.clientX); + setDataColumnWidth( + Math.min( + maxDataWidth, + Math.max(MIN_DATA_COLUMN_WIDTH, nextWidth), + ), + ); + }; + + const handleMouseUp = () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + } + + function handlePanelResizeMouseDown(e: React.MouseEvent<HTMLDivElement>) { + e.preventDefault(); + dragStartX.current = e.clientX; + dragStartPanelWidth.current = panelWidth; + + const handleMouseMove = (event: MouseEvent) => { + const nextWidth = + dragStartPanelWidth.current + (dragStartX.current - event.clientX); + setPanelWidth(clampPanelWidth(nextWidth, dataColumnWidth)); + }; + + const handleMouseUp = () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + } + + return createPortal( + <div + 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", + )} + style={{ 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" + 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"> + {selectedFilename} + </div> + </div> + <div className="flex items-center gap-1"> + <button + type="button" + onClick={onClose} + className="flex h-7 w-7 items-center justify-center text-gray-500 transition-colors hover:text-gray-900" + title="Close" + > + <X className="h-4 w-4" /> + </button> + </div> + </div> + + <div + className="grid min-h-0 flex-1" + style={{ + gridTemplateColumns: `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", + )} + > + <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", + )} + > + <DocView + key={selectedVersionId ?? "current"} + doc={{ + document_id: doc.id, + version_id: selectedVersionId, + }} + /> + </div> + </section> + + <div + onMouseDown={handleResizeMouseDown} + className={cn( + "relative cursor-col-resize transition-colors", + "bg-white/25 hover:bg-blue-400/60", + )} + title="Resize document panel" + /> + + <aside + className={cn( + "flex min-h-0 flex-col", + "bg-white/25", + )} + > + <div + className={cn( + "shrink-0 px-4 pb-3 pt-0", + "border-b border-white/60", + )} + > + <div className="mb-4"> + <div className="mb-3 text-xs font-medium text-gray-900"> + Name + </div> + {editingName ? ( + <div className="space-y-1.5"> + <div className="flex min-h-6 items-center gap-2"> + <input + value={nameDraft} + onChange={(e) => { + setNameDraft(e.target.value); + setNameError(null); + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void handleSaveName(); + } + if (e.key === "Escape") { + setEditingName(false); + setNameError(null); + } + }} + className="h-6 min-w-0 flex-1 border-0 border-b border-gray-300 bg-transparent px-0 text-xs leading-6 text-gray-900 outline-none transition-colors focus:border-gray-500" + autoFocus + /> + <button + type="button" + onClick={() => void handleSaveName()} + disabled={savingName} + className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-gray-500 transition-colors hover:bg-white/65 hover:text-gray-900 disabled:cursor-not-allowed disabled:opacity-40" + title="Save name" + > + {savingName ? ( + <Loader2 className="h-3.5 w-3.5 animate-spin" /> + ) : ( + <Check className="h-3.5 w-3.5" /> + )} + </button> + </div> + {nameError && ( + <div className="text-xs text-red-600"> + {nameError} + </div> + )} + </div> + ) : ( + <div className="flex min-h-6 items-center gap-2"> + <div className="min-w-0 flex-1 truncate text-xs leading-6 text-gray-800"> + {selectedFilename} + </div> + {selectedVersionId && ( + <button + type="button" + onClick={() => { + setNameDraft(selectedFilename); + setEditingName(true); + setNameError(null); + }} + className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-gray-500 transition-colors hover:bg-white/65 hover:text-gray-900" + title="Edit name" + > + <Pencil className="h-3.5 w-3.5" /> + </button> + )} + </div> + )} + </div> + + <div className="mb-3 text-xs font-medium text-gray-900"> + Document Data + </div> + <div className="space-y-1.5"> + <DataRow label="Type" value={selectedFileType ?? "—"} /> + <DataRow + label="Size" + value={ + selectedSizeBytes != null + ? formatBytes(selectedSizeBytes) + : "—" + } + /> + <DataRow + label="Version" + value={ + selectedVersionNumber != null + ? String(selectedVersionNumber) + : "—" + } + /> + <DataRow + label="Uploaded" + value={ + selectedUploadedAt + ? formatDate(selectedUploadedAt) + : "—" + } + /> + {selectedPageCount != null && ( + <DataRow + label="Pages" + value={String(selectedPageCount)} + /> + )} + </div> + <div className="mt-4 flex items-center justify-between gap-2"> + <button + type="button" + onClick={() => + void handleDeleteSelectedVersion() + } + disabled={ + !selectedVersionId || + versions.length <= 1 || + deletingVersion + } + className={cn( + "inline-flex items-center gap-1.5 rounded-lg border border-gray-300/80 bg-white/65 px-3 py-2 text-xs font-medium text-red-600 transition-colors hover:border-red-200 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-40", + )} + > + {deletingVersion ? ( + <Loader2 className="h-3.5 w-3.5 animate-spin" /> + ) : ( + <Trash2 className="h-3.5 w-3.5" /> + )} + Delete version + </button> + <button + type="button" + onClick={() => + selectedVersionId + ? void onDownloadVersion( + doc.id, + selectedVersionId, + selectedFilename, + ) + : void onDownloadDocument(doc.id) + } + className="inline-flex items-center gap-1.5 rounded-lg border border-gray-300/80 bg-white/65 px-3 py-2 text-xs font-medium text-gray-700 transition-colors hover:border-gray-400 hover:bg-white hover:text-gray-900" + > + <Download className="h-3.5 w-3.5" /> + Download + </button> + </div> + </div> + + <div className="flex min-h-0 flex-1 flex-col px-4 pb-3 pt-0"> + <div + className={cn( + "flex min-h-0 flex-1 flex-col overflow-visible rounded-xl", + "border border-white/60 bg-white/35 shadow-[inset_0_1px_0_rgba(255,255,255,0.8)]", + )} + > + <div + className={cn( + "shrink-0 py-2 text-xs font-medium text-gray-900", + "border-b border-white/60", + )} + > + Versions + </div> + <div className="-mx-2 min-h-0 flex-1 overflow-y-auto px-2 py-2"> + {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> + ) : orderedVersions.length === 0 ? ( + <div className="py-2 text-xs text-gray-400"> + No version history. + </div> + ) : ( + <div className="space-y-1"> + {orderedVersions.map((version) => { + const title = + versionTitleFor(version); + const filename = + versionFilenameFor(version); + const selected = + selectedVersionId === version.id; + const fileType = + fileTypeForVersion( + version, + doc.file_type, + ); + return ( + <button + key={version.id} + type="button" + onClick={() => + onSelectVersion( + version.id, + filename, + ) + } + className={cn( + "group -mx-2 flex w-[calc(100%+1rem)] items-center gap-2 rounded-lg px-2 py-2 text-left transition-colors", + selected + ? "bg-gray-100" + : "hover:bg-white/55", + )} + > + <div className="min-w-0 flex-1"> + <div className="flex min-w-0 items-center gap-2"> + <DocFileIcon + fileType={ + fileType + } + /> + <div className="min-w-0 flex-1 truncate text-xs font-medium text-gray-800"> + {title} + </div> + </div> + <div className="truncate pl-[22px] text-[11px] text-gray-400"> + {filename} + </div> + <div className="truncate pl-[22px] text-[11px] text-gray-400"> + {version.created_at + ? new Date( + version.created_at, + ).toLocaleString() + : "—"} + </div> + </div> + </button> + ); + })} + </div> + )} + </div> + </div> + </div> + + {uploadError && ( + <div className="mx-4 mb-2 flex items-center gap-2 rounded-lg bg-red-50 px-3 py-2 text-xs text-gray-900"> + <AlertCircle className="h-3.5 w-3.5 shrink-0 text-red-600" /> + <span>{uploadError}</span> + </div> + )} + + <div + className={cn( + "flex shrink-0 items-center justify-between px-4 py-3", + "border-t border-white/60 bg-white/25", + )} + > + <input + ref={fileInputRef} + type="file" + accept={accept} + className="hidden" + onChange={handleUpload} + /> + <button + type="button" + onClick={requestDeleteDocument} + disabled={deletingDocument} + className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-gray-300/80 bg-white/35 px-3 text-xs font-medium text-red-600 transition-colors hover:border-red-200 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-40" + > + {deletingDocument ? ( + <Loader2 className="h-3.5 w-3.5 animate-spin" /> + ) : ( + <Trash2 className="h-3.5 w-3.5" /> + )} + Delete + </button> + <button + type="button" + onClick={() => fileInputRef.current?.click()} + disabled={uploading} + className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-gray-300/80 bg-white/35 px-3 text-xs font-medium text-gray-800 transition-colors hover:border-gray-400 hover:bg-white/60 disabled:cursor-not-allowed disabled:opacity-40" + > + {uploading ? ( + <Loader2 className="h-3.5 w-3.5 animate-spin" /> + ) : ( + <Upload className="h-3.5 w-3.5" /> + )} + Upload new version + </button> + </div> + </aside> + </div> + <WarningPopup + open={extensionWarningOpen} + onClose={() => setExtensionWarningOpen(false)} + message={ + selectedExtension + ? `File extensions cannot be changed here. Keep ${selectedExtension} at the end of the name.` + : "File extensions cannot be changed here." + } + /> + <ConfirmPopup + open={confirmDeleteDocumentOpen} + title="Delete document?" + message={`${selectedFilename} has ${versions.length} versions. Deleting this document will delete all of its versions.`} + confirmLabel="Delete" + confirmStatus={ + deleteDocumentStatus === "deleting" + ? "loading" + : deleteDocumentStatus === "deleted" + ? "complete" + : "idle" + } + cancelLabel="Cancel" + onCancel={() => { + if (deleteDocumentStatus === "deleting") return; + setConfirmDeleteDocumentOpen(false); + setDeleteDocumentStatus("idle"); + }} + onConfirm={() => void handleDeleteDocument()} + /> + </div>, + document.body, + ); +} + +function DataRow({ label, value }: { label: string; value: string }) { + return ( + <div className="grid grid-cols-[112px_minmax(0,1fr)] gap-2 text-xs"> + <span className="text-gray-400">{label}</span> + <span className="truncate text-gray-800">{value}</span> + </div> + ); +} + +function clampPanelWidth(width: number, dataColumnWidth: number) { + const minWidth = MIN_DOC_COLUMN_WIDTH + RESIZER_WIDTH + dataColumnWidth; + const maxWidth = + typeof window === "undefined" + ? MAX_PANEL_WIDTH + : Math.min(MAX_PANEL_WIDTH, window.innerWidth - 16); + return Math.min(maxWidth, Math.max(minWidth, width)); +} + +function versionTitleFor(version: DocumentVersion) { + if ( + typeof version.version_number === "number" && + version.version_number >= 1 + ) { + return `Version ${version.version_number}`; + } + return "Version"; +} + +function versionFilenameFor(version: DocumentVersion) { + if (version.filename?.trim()) return version.filename.trim(); + return version.source === "upload" ? "Original" : "—"; +} + +function fileTypeForVersion( + version: DocumentVersion, + fallback: string | null, +) { + const name = version.filename?.trim().toLowerCase() ?? ""; + if (name.endsWith(".pdf")) return "pdf"; + if (name.endsWith(".doc") || name.endsWith(".docx")) return "docx"; + return fallback; +} + +function filenameExtension(filename: string) { + const trimmed = filename.trim(); + const dotIndex = trimmed.lastIndexOf("."); + if (dotIndex <= 0 || dotIndex === trimmed.length - 1) return null; + return trimmed.slice(dotIndex); +} + +function hasExtensionChange(previous: string, next: string) { + const previousExtension = filenameExtension(previous); + if (previousExtension == null) return false; + return ( + filenameExtension(next)?.toLowerCase() !== + previousExtension.toLowerCase() + ); +} diff --git a/frontend/src/app/components/projects/NewProjectModal.tsx b/frontend/src/app/components/projects/NewProjectModal.tsx index 1c3b39d..cc2a76e 100644 --- a/frontend/src/app/components/projects/NewProjectModal.tsx +++ b/frontend/src/app/components/projects/NewProjectModal.tsx @@ -1,7 +1,7 @@ "use client"; import { useRef, useState } from "react"; -import { X, Users, Upload } from "lucide-react"; +import { Users, Upload } from "lucide-react"; import { addDocumentToProject, createProject, @@ -10,13 +10,14 @@ import { import { useDirectoryData } from "../shared/useDirectoryData"; import { FileDirectory } from "../shared/FileDirectory"; import { EmailPillInput } from "../shared/EmailPillInput"; -import type { MikeProject } from "../shared/types"; +import type { Project } from "../shared/types"; import { useAuth } from "@/contexts/AuthContext"; +import { Modal } from "../shared/Modal"; interface Props { open: boolean; onClose: () => void; - onCreated: (project: MikeProject) => void; + onCreated: (project: Project) => void; } export function NewProjectModal({ open, onClose, onCreated }: Props) { @@ -31,6 +32,7 @@ export function NewProjectModal({ open, onClose, onCreated }: Props) { const fileInputRef = useRef<HTMLInputElement>(null); const { user } = useAuth(); const ownEmail = user?.email?.trim().toLowerCase() ?? null; + const formId = "new-project-modal-form"; const { loading: dirLoading, standaloneDocuments, projects: dirProjects } = useDirectoryData(open); @@ -86,129 +88,93 @@ export function NewProjectModal({ open, onClose, onCreated }: Props) { } return ( - <div className="fixed inset-0 z-101 flex items-center justify-center bg-black/20 backdrop-blur-xs"> - <div className="w-full max-w-2xl rounded-2xl bg-white shadow-2xl flex flex-col h-[600px]"> - {/* Header */} - <div className="flex items-center justify-between px-6 pt-5 pb-2"> - <div className="flex items-center gap-1.5 text-xs text-gray-400"> - <span>Projects</span> - <span>›</span> - <span>New project</span> - </div> + <Modal + open={open} + onClose={handleClose} + breadcrumbs={["Projects", "New project"]} + secondaryAction={{ + label: `Upload files${pendingFiles.length > 0 ? ` (${pendingFiles.length})` : ""}`, + icon: <Upload className="h-3.5 w-3.5" />, + onClick: () => fileInputRef.current?.click(), + }} + primaryAction={{ + label: loading ? "Creating…" : "Create project", + type: "submit", + form: formId, + disabled: !name.trim() || loading, + }} + > + <input + ref={fileInputRef} + type="file" + multiple + className="hidden" + onChange={handleFileChange} + /> + <form + id={formId} + onSubmit={handleSubmit} + className="flex flex-col flex-1 min-h-0" + > + <input + type="text" + value={name} + onChange={(e) => setName(e.target.value)} + placeholder="Project name" + className="w-full text-2xl font-serif text-gray-800 placeholder-gray-300 focus:outline-none bg-transparent" + autoFocus + /> + + <input + type="text" + value={cmNumber} + onChange={(e) => setCmNumber(e.target.value)} + placeholder="Add a CM number..." + className="mt-1.5 w-full text-sm text-gray-500 placeholder-gray-300 focus:outline-none bg-transparent" + /> + + <div className="mt-4 flex flex-wrap items-center gap-2"> <button - onClick={handleClose} - className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 transition-colors" + type="button" + onClick={() => setShowMembers((v) => !v)} + className="flex items-center gap-1.5 rounded-full border border-gray-200 px-3 py-1 text-xs text-gray-600 hover:bg-gray-50 transition-colors" > - <X className="h-4 w-4" /> + <Users className="h-3 w-3 text-gray-400" /> + Members{sharedEmails.length > 0 ? ` (${sharedEmails.length})` : ""} </button> </div> - <form onSubmit={handleSubmit} className="flex flex-col flex-1 min-h-0"> - <div className="px-6 pt-3 pb-5 flex-1 overflow-y-auto"> - {/* Title */} - <input - type="text" - value={name} - onChange={(e) => setName(e.target.value)} - placeholder="Project name" - className="w-full text-2xl font-serif text-gray-800 placeholder-gray-300 focus:outline-none bg-transparent" - autoFocus + {showMembers && ( + <div className="mt-3"> + <EmailPillInput + emails={sharedEmails} + onChange={setSharedEmails} + validate={async (email) => + ownEmail && email === ownEmail + ? "You cannot share a project with yourself." + : null + } + placeholder="Add colleagues by email…" /> - - {/* CM Number */} - <input - type="text" - value={cmNumber} - onChange={(e) => setCmNumber(e.target.value)} - placeholder="Add a CM number..." - className="mt-1.5 w-full text-sm text-gray-500 placeholder-gray-300 focus:outline-none bg-transparent" - /> - - {/* Attribute pills */} - <div className="mt-4 flex flex-wrap items-center gap-2"> - <button - type="button" - onClick={() => setShowMembers((v) => !v)} - className="flex items-center gap-1.5 rounded-full border border-gray-200 px-3 py-1 text-xs text-gray-600 hover:bg-gray-50 transition-colors" - > - <Users className="h-3 w-3 text-gray-400" /> - Members{sharedEmails.length > 0 ? ` (${sharedEmails.length})` : ""} - </button> - </div> - - {/* Members panel */} - {showMembers && ( - <div className="mt-3"> - <EmailPillInput - emails={sharedEmails} - onChange={setSharedEmails} - validate={async (email) => - ownEmail && email === ownEmail - ? "You cannot share a project with yourself." - : null - } - placeholder="Add colleagues by email…" - /> - </div> - )} - - {/* Documents */} - <div className="mt-4 space-y-2"> - <p className="text-xs font-medium text-gray-700">Select documents</p> - <FileDirectory - standaloneDocs={standaloneDocuments} - directoryProjects={dirProjects} - loading={dirLoading} - selectedIds={selectedDocIds} - onChange={setSelectedDocIds} - emptyMessage="No existing documents" - /> - - </div> - - {error && ( - <p className="mt-3 text-sm text-red-500">{error}</p> - )} </div> + )} - {/* Footer */} - <div className="flex items-center justify-between border-t border-gray-100 px-6 py-4 shrink-0"> - <div className="flex items-center gap-2"> - <input - ref={fileInputRef} - type="file" - multiple - className="hidden" - onChange={handleFileChange} - /> - <button - type="button" - onClick={() => fileInputRef.current?.click()} - className="flex items-center gap-1.5 rounded-lg border border-gray-200 px-3 py-1.5 text-xs text-gray-500 hover:bg-gray-50 transition-colors" - > - <Upload className="h-3.5 w-3.5" /> - Upload files{pendingFiles.length > 0 ? ` (${pendingFiles.length})` : ""} - </button> - </div> - <div className="flex items-center gap-2"> - <button - type="button" - onClick={handleClose} - className="rounded-lg px-4 py-2 text-sm text-gray-500 hover:bg-gray-100 transition-colors" - > - Cancel - </button> - <button - type="submit" - disabled={!name.trim() || loading} - className="rounded-lg bg-gray-900 px-5 py-2 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-40 transition-colors" - > - {loading ? "Creating…" : "Create project"} - </button> - </div> - </div> - </form> - </div> - </div> + <div className="mt-4 space-y-2"> + <p className="text-xs font-medium text-gray-700">Select documents</p> + <FileDirectory + standaloneDocs={standaloneDocuments} + directoryProjects={dirProjects} + loading={dirLoading} + selectedIds={selectedDocIds} + onChange={setSelectedDocIds} + emptyMessage="No existing documents" + /> + </div> + + {error && ( + <p className="mt-3 text-sm text-red-500">{error}</p> + )} + </form> + </Modal> ); } diff --git a/frontend/src/app/components/projects/ProjectAssistantTab.tsx b/frontend/src/app/components/projects/ProjectAssistantTab.tsx index 8cf230c..b2690b7 100644 --- a/frontend/src/app/components/projects/ProjectAssistantTab.tsx +++ b/frontend/src/app/components/projects/ProjectAssistantTab.tsx @@ -3,8 +3,8 @@ import { type Dispatch, type SetStateAction } from "react"; import { MessageSquare } from "lucide-react"; import { RowActions } from "@/app/components/shared/RowActions"; -import type { MikeChat } from "@/app/components/shared/types"; -import { CHECK_W, formatDate, NAME_COL_W } from "./ProjectPageParts"; +import type { Chat } from "@/app/components/shared/types"; +import { formatDate, NAME_COL_W } from "./ProjectPageParts"; export function ProjectAssistantTab({ chats, @@ -24,8 +24,8 @@ export function ProjectAssistantTab({ setRenamingChatId, setRenameChatValue, }: { - chats: MikeChat[]; - filteredChats: MikeChat[]; + chats: Chat[]; + filteredChats: Chat[]; selectedChatIds: string[]; allChatsSelected: boolean; someChatsSelected: boolean; @@ -34,19 +34,19 @@ export function ProjectAssistantTab({ currentUserId?: string | null; onCreateChat: () => void; onOpenChat: (chatId: string) => void; - onDeleteChat: (chat: MikeChat) => Promise<void> | void; + onDeleteChat: (chat: Chat) => Promise<void> | void; onOwnerOnlyAction: (action: string) => void; submitChatRename: (chatId: string) => Promise<void> | void; setSelectedChatIds: Dispatch<SetStateAction<string[]>>; setRenamingChatId: Dispatch<SetStateAction<string | null>>; setRenameChatValue: Dispatch<SetStateAction<string>>; }) { + const stickyCellBg = "bg-[#fcfcfd]"; + 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] ${CHECK_W} relative bg-white flex items-center justify-center self-stretch before:absolute before:inset-x-0 before:bottom-0 before:h-px before:bg-white`} - > + <div className={`sticky left-0 z-[60] ${NAME_COL_W} ${stickyCellBg} flex items-center gap-4 self-stretch pl-4 pr-2 text-left`}> <input type="checkbox" checked={allChatsSelected} @@ -59,11 +59,7 @@ export function ProjectAssistantTab({ }} className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black" /> - </div> - <div - className={`sticky left-8 z-[60] ${NAME_COL_W} bg-white pl-2 text-left`} - > - Chats + <span>Chats</span> </div> <div className="ml-auto w-32 shrink-0 text-left">Created</div> <div className="w-8 shrink-0" /> @@ -94,54 +90,48 @@ export function ProjectAssistantTab({ if (renamingChatId === chat.id) return; onOpenChat(chat.id); }} - className="group flex items-center h-10 pr-8 border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors" + className="group flex items-center h-10 pr-8 border-b border-gray-50 hover:bg-gray-100 cursor-pointer transition-colors" > <div - className={`sticky left-0 z-[60] ${CHECK_W} p-2 flex items-center justify-center ${ - selectedChatIds.includes(chat.id) - ? "bg-gray-50" - : "bg-white" - } group-hover:bg-gray-50`} - onClick={(e) => e.stopPropagation()} + className={`sticky left-0 z-[60] ${NAME_COL_W} ${selectedChatIds.includes(chat.id) ? "bg-gray-50" : stickyCellBg} py-2 pl-4 pr-2 transition-colors group-hover:bg-gray-100`} > - <input - type="checkbox" - checked={selectedChatIds.includes(chat.id)} - onChange={() => - setSelectedChatIds((prev) => - prev.includes(chat.id) - ? prev.filter((x) => x !== chat.id) - : [...prev, chat.id], - ) - } - className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black" - /> - </div> - <div - className={`sticky left-8 z-[60] ${NAME_COL_W} bg-white p-2 group-hover:bg-gray-50`} - > - {renamingChatId === chat.id ? ( + <div className="flex min-w-0 items-center gap-4"> <input - autoFocus - value={renameChatValue} - onChange={(e) => - setRenameChatValue(e.target.value) + type="checkbox" + checked={selectedChatIds.includes(chat.id)} + onChange={() => + setSelectedChatIds((prev) => + prev.includes(chat.id) + ? prev.filter((x) => x !== chat.id) + : [...prev, chat.id], + ) } - onKeyDown={(e) => { - if (e.key === "Enter") - void submitChatRename(chat.id); - if (e.key === "Escape") - setRenamingChatId(null); - }} - onBlur={() => void submitChatRename(chat.id)} onClick={(e) => e.stopPropagation()} - className="w-full text-sm text-gray-800 bg-transparent outline-none" + className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-pointer accent-black" /> - ) : ( - <span className="text-sm text-gray-800 truncate block"> - {chat.title ?? "Untitled Chat"} - </span> - )} + {renamingChatId === chat.id ? ( + <input + autoFocus + value={renameChatValue} + onChange={(e) => + setRenameChatValue(e.target.value) + } + onKeyDown={(e) => { + if (e.key === "Enter") + void submitChatRename(chat.id); + if (e.key === "Escape") + setRenamingChatId(null); + }} + onBlur={() => void submitChatRename(chat.id)} + onClick={(e) => e.stopPropagation()} + className="min-w-0 flex-1 text-sm text-gray-800 bg-transparent outline-none" + /> + ) : ( + <span className="min-w-0 flex-1 truncate text-sm text-gray-800"> + {chat.title ?? "Untitled Chat"} + </span> + )} + </div> </div> <div className="ml-auto w-32 shrink-0 text-sm text-gray-500 truncate"> {formatDate(chat.created_at)} diff --git a/frontend/src/app/components/projects/ProjectExplorer.tsx b/frontend/src/app/components/projects/ProjectExplorer.tsx index 85bd21b..1f2d389 100644 --- a/frontend/src/app/components/projects/ProjectExplorer.tsx +++ b/frontend/src/app/components/projects/ProjectExplorer.tsx @@ -11,15 +11,18 @@ import { FolderPlus, Trash2, } from "lucide-react"; -import type { MikeDocument, MikeFolder } from "@/app/components/shared/types"; +import type { + Document, + Folder as ProjectFolder, +} from "@/app/components/shared/types"; import { VersionChip } from "@/app/components/shared/VersionChip"; interface Props { projectName?: string | null; - documents: MikeDocument[]; - folders?: MikeFolder[]; + documents: Document[]; + folders?: ProjectFolder[]; selectedDocId?: string | null; - onDocClick: (doc: MikeDocument) => void; + onDocClick: (doc: Document) => void; onCreateFolder?: (parentFolderId: string | null, name: string) => Promise<void>; onRenameFolder?: (folderId: string, name: string) => Promise<void>; onDeleteFolder?: (folderId: string) => Promise<void>; @@ -131,7 +134,7 @@ export function ProjectExplorer({ } function wouldCreateCycle(movingId: string, targetId: string): boolean { - let cur: MikeFolder | undefined = folders.find((f) => f.id === targetId); + let cur: ProjectFolder | undefined = folders.find((f) => f.id === targetId); while (cur) { if (cur.id === movingId) return true; if (!cur.parent_folder_id) break; @@ -299,8 +302,15 @@ export function ProjectExplorer({ style={{ paddingLeft: basePadding }} > <DocIcon fileType={doc.file_type} /> - <span className="text-xs truncate">{doc.filename}</span> - <VersionChip n={doc.latest_version_number} /> + <span className="text-xs truncate"> + {doc.filename} + </span> + <VersionChip + n={ + doc.active_version_number ?? + doc.latest_version_number + } + /> </li> ); })} diff --git a/frontend/src/app/components/projects/ProjectPage.tsx b/frontend/src/app/components/projects/ProjectPage.tsx index 46df498..daff891 100644 --- a/frontend/src/app/components/projects/ProjectPage.tsx +++ b/frontend/src/app/components/projects/ProjectPage.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { type DragEvent, useEffect, useRef, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { Upload, @@ -33,16 +33,18 @@ import { renameProjectDocument, listDocumentVersions, uploadDocumentVersion, + copyDocumentVersionFromDocument, + deleteDocumentVersion, uploadProjectDocument, renameDocumentVersion, getProjectPeople, - type MikeDocumentVersion, + type DocumentVersion, } from "@/app/lib/mikeApi"; import type { - MikeDocument, - MikeFolder, - MikeProject, - MikeChat, + Document, + Folder as ProjectFolder, + Project, + Chat, TabularReview, } from "@/app/components/shared/types"; import { ToolbarTabs } from "@/app/components/shared/ToolbarTabs"; @@ -58,12 +60,15 @@ import { import { PeopleModal } from "@/app/components/shared/PeopleModal"; import { OwnerOnlyModal } from "@/app/components/shared/OwnerOnlyModal"; import { useAuth } from "@/contexts/AuthContext"; -import { UploadNewVersionModal } from "@/app/components/shared/UploadNewVersionModal"; -import { DocViewModal } from "@/app/components/shared/DocViewModal"; import { AddNewTRModal } from "@/app/components/tabular/AddNewTRModal"; +import { WarningPopup } from "@/app/components/shared/WarningPopup"; +import { ConfirmPopup } from "@/app/components/shared/ConfirmPopup"; import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext"; import { - CHECK_W, + formatUnsupportedDocumentWarning, + partitionSupportedDocumentFiles, +} from "@/app/lib/documentUploadValidation"; +import { DOC_NAME_COL_W, DocIcon, DocVersionHistory, @@ -71,11 +76,11 @@ import { formatDate, ProjectPageHeader, ProjectPageSkeleton, - treeControlCellStyle, treeNameCellStyle, type ProjectContextMenu, type ProjectTab, } from "./ProjectPageParts"; +import { DocumentSidePanel } from "./DocumentSidePanel"; import { ProjectAssistantTab } from "./ProjectAssistantTab"; import { ProjectReviewsTab } from "./ProjectReviewsTab"; @@ -85,9 +90,9 @@ interface Props { } export function ProjectPage({ projectId, initialTab = "documents" }: Props) { - const [project, setProject] = useState<MikeProject | null>(null); - const [folders, setFolders] = useState<MikeFolder[]>([]); - const [chats, setChats] = useState<MikeChat[]>([]); + const [project, setProject] = useState<Project | null>(null); + const [folders, setFolders] = useState<ProjectFolder[]>([]); + const [chats, setChats] = useState<Chat[]>([]); const [projectReviews, setProjectReviews] = useState<TabularReview[]>([]); const [loading, setLoading] = useState(true); const searchParams = useSearchParams(); @@ -100,9 +105,8 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { const [peopleModalOpen, setPeopleModalOpen] = useState(false); const [ownerOnlyAction, setOwnerOnlyAction] = useState<string | null>(null); const { user } = useAuth(); - const [uploadVersionDoc, setUploadVersionDoc] = - useState<MikeDocument | null>(null); - const [viewingDoc, setViewingDoc] = useState<MikeDocument | null>(null); + const stickyCellBg = "bg-[#fcfcfd]"; + const [viewingDoc, setViewingDoc] = useState<Document | null>(null); const [viewingDocVersion, setViewingDocVersion] = useState<{ id: string; label: string; @@ -124,31 +128,32 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { Set<string> >(() => new Set()); const [versionsByDocId, setVersionsByDocId] = useState< - Map<string, MikeDocumentVersion[]> + Map< + string, + { currentVersionId: string | null; versions: DocumentVersion[] } + > >(() => new Map()); const [loadingVersionDocIds, setLoadingVersionDocIds] = useState< Set<string> >(() => new Set()); - const toggleVersions = async (docId: string) => { - const already = expandedVersionDocIds.has(docId); - if (already) { - setExpandedVersionDocIds((prev) => { - const next = new Set(prev); - next.delete(docId); - return next; - }); - return; + const loadDocumentVersions = async ( + docId: string, + options: { expand?: boolean; force?: boolean } = {}, + ) => { + if (options.expand) { + setExpandedVersionDocIds((prev) => new Set([...prev, docId])); } - // Opening — expand immediately so the user sees a loading state. - setExpandedVersionDocIds((prev) => new Set([...prev, docId])); - if (versionsByDocId.has(docId)) return; + if (!options.force && versionsByDocId.has(docId)) return; setLoadingVersionDocIds((prev) => new Set([...prev, docId])); try { const res = await listDocumentVersions(docId); setVersionsByDocId((prev) => { const next = new Map(prev); - next.set(docId, res.versions); + next.set(docId, { + currentVersionId: res.current_version_id, + versions: res.versions, + }); return next; }); } catch (e) { @@ -162,6 +167,20 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { } }; + const toggleVersions = async (docId: string) => { + const already = expandedVersionDocIds.has(docId); + if (already) { + setExpandedVersionDocIds((prev) => { + const next = new Set(prev); + next.delete(docId); + return next; + }); + return; + } + // Opening — expand immediately so the user sees a loading state. + await loadDocumentVersions(docId, { expand: true }); + }; + async function downloadDocVersion( docId: string, versionId: string, @@ -172,7 +191,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { const a = document.createElement("a"); a.href = resolved.url; // Prefer the backend's resolved filename (which honours the - // version's display_name). Fall back to the passed filename + // version filename). Fall back to the passed filename // if for some reason it's missing. a.download = resolved.filename || filename; a.click(); @@ -181,67 +200,94 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { } } - /** - * Trigger a file picker and upload the chosen file as a new version of - * the given document. On success, refresh the project (for the doc's - * latest_version_number) and re-fetch the version list so the history - * panel shows the new row. - */ - function handleUploadNewVersion(doc: MikeDocument) { - setUploadVersionDoc(doc); + function handleUploadNewVersion(doc: Document) { + setVersionUploadTargetDoc(doc); + window.setTimeout(() => versionUploadInputRef.current?.click(), 0); + } + + async function handleVersionUploadInputChange( + e: React.ChangeEvent<HTMLInputElement>, + ) { + const file = e.target.files?.[0] ?? null; + e.target.value = ""; + const doc = versionUploadTargetDoc; + setVersionUploadTargetDoc(null); + if (!file || !doc) return; + await handleDropDocumentVersions(doc, [file]); } async function submitNewVersion( - doc: MikeDocument, + doc: Document, file: File, - displayName: string, + filename: string, ) { try { - await uploadDocumentVersion(doc.id, file, displayName); - // Refresh project so doc.latest_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(doc.id); - return next; - }); - // Ensure the history panel is expanded so the user sees it. - setExpandedVersionDocIds((prev) => new Set([...prev, doc.id])); - const res = await listDocumentVersions(doc.id); - setVersionsByDocId((prev) => { - const next = new Map(prev); - next.set(doc.id, res.versions); - return next; - }); + await uploadDocumentVersion(doc.id, file, filename); + await refreshDocumentVersionState(doc.id); } catch (e) { console.error("uploadDocumentVersion failed", e); } } + 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; + }); + const res = await listDocumentVersions(docId); + setVersionsByDocId((prev) => { + const next = new Map(prev); + next.set(docId, { + currentVersionId: res.current_version_id, + versions: res.versions, + }); + return next; + }); + return res; + } + /** - * Patch a version's display_name and update the local cache in place. + * Patch a version filename and update the local cache in place. */ async function handleRenameVersion( docId: string, versionId: string, - displayName: string | null, + filename: string | null, ) { + const previousFilename = versionsByDocId + .get(docId) + ?.versions.find((version) => version.id === versionId) + ?.filename?.trim(); + if ( + previousFilename && + (filename == null || + hasFilenameExtensionChange(previousFilename, filename)) + ) { + setDocumentRenameWarning(extensionChangeWarning(previousFilename)); + return; + } + try { const updated = await renameDocumentVersion( docId, versionId, - displayName, + filename, ); setVersionsByDocId((prev) => { - const list = prev.get(docId); - if (!list) return prev; + const cached = prev.get(docId); + if (!cached) return prev; const next = new Map(prev); - next.set( - docId, - list.map((v) => (v.id === versionId ? updated : v)), - ); + next.set(docId, { + ...cached, + versions: cached.versions.map((v) => + v.id === versionId ? updated : v, + ), + }); return next; }); } catch (e) { @@ -249,6 +295,28 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { } } + async function handleDeleteVersion(docId: string, versionId: string) { + try { + await deleteDocumentVersion(docId, versionId); + const res = await refreshDocumentVersionState(docId); + const nextVersion = + res.versions.find( + (version) => version.id === res.current_version_id, + ) ?? res.versions[res.versions.length - 1] ?? null; + setViewingDocVersion( + nextVersion + ? { + id: nextVersion.id, + label: nextVersion.filename?.trim() || "Version", + } + : null, + ); + } catch (e) { + console.error("deleteDocumentVersion failed", e); + setDocumentRenameWarning("Could not delete this version."); + } + } + // Inline rename for chats and reviews const [renamingChatId, setRenamingChatId] = useState<string | null>(null); const [renameChatValue, setRenameChatValue] = useState(""); @@ -268,12 +336,37 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { useState<ProjectContextMenu | null>(null); const contextMenuRef = useRef<HTMLDivElement>(null); const newFolderInputRef = useRef<HTMLDivElement | null>(null); + const versionUploadInputRef = useRef<HTMLInputElement>(null); const [dragOverFolderId, setDragOverFolderId] = useState<string | null>(null); const [dragOverRoot, setDragOverRoot] = useState(false); const [dragOverFileRoot, setDragOverFileRoot] = useState(false); + const [dragOverVersionDocId, setDragOverVersionDocId] = useState< + string | null + >(null); + const [uploadingVersionDocIds, setUploadingVersionDocIds] = useState< + Set<string> + >(() => new Set()); + const [versionUploadTargetDoc, setVersionUploadTargetDoc] = + useState<Document | null>(null); const [uploadingDroppedFilenames, setUploadingDroppedFilenames] = useState< string[] >([]); + const [documentUploadWarning, setDocumentUploadWarning] = useState< + string | null + >(null); + const [documentRenameWarning, setDocumentRenameWarning] = useState< + string | null + >(null); + const [pendingVersionDrop, setPendingVersionDrop] = useState<{ + targetDoc: Document; + sourceDoc: Document; + } | null>(null); + const [pendingDeleteDoc, setPendingDeleteDoc] = useState<Document | null>( + null, + ); + const [pendingDeleteStatus, setPendingDeleteStatus] = useState< + "idle" | "deleting" | "deleted" + >("idle"); // Actions dropdown const [actionsOpen, setActionsOpen] = useState(false); @@ -292,7 +385,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { useEffect(() => { Promise.all([ getProject(projectId), - listProjectChats(projectId).catch(() => [] as MikeChat[]), + listProjectChats(projectId).catch(() => [] as Chat[]), listTabularReviews(projectId).catch(() => []), ]) .then(([proj, projectChats, projectReviews]) => { @@ -371,7 +464,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { // Immediately hide the input and show an optimistic folder row setCreatingFolderIn(undefined); const tempId = `temp-${Date.now()}`; - const optimistic: MikeFolder = { + const optimistic: ProjectFolder = { id: tempId, project_id: projectId, user_id: "", @@ -426,7 +519,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { // ── Doc/chat/review handlers ────────────────────────────────────────────── - function handleDocsSelected(newDocs: MikeDocument[]) { + function handleDocsSelected(newDocs: Document[]) { setProject((prev) => prev ? { ...prev, @@ -438,6 +531,100 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { ); } + function removeDocumentFromLocalState(docId: string) { + setProject((prev) => + prev + ? { + ...prev, + documents: + prev.documents?.filter((doc) => doc.id !== docId) ?? + [], + } + : prev, + ); + setSelectedDocIds((prev) => prev.filter((id) => id !== docId)); + setExpandedVersionDocIds((prev) => { + const next = new Set(prev); + next.delete(docId); + return next; + }); + setVersionsByDocId((prev) => { + const next = new Map(prev); + next.delete(docId); + return next; + }); + setLoadingVersionDocIds((prev) => { + const next = new Set(prev); + next.delete(docId); + return next; + }); + setUploadingVersionDocIds((prev) => { + const next = new Set(prev); + next.delete(docId); + return next; + }); + setViewingDoc((prev) => (prev?.id === docId ? null : prev)); + if (renamingDocumentId === docId) setRenamingDocumentId(null); + if (contextMenu?.docId === docId) setContextMenu(null); + } + + function restoreDocumentToLocalState( + doc: Document, + snapshot: { + index: number; + selected: boolean; + versionsOpen: boolean; + versions?: DocumentVersion[]; + currentVersionId?: string | null; + loadingVersions: boolean; + uploadingVersion: boolean; + viewing: boolean; + viewingVersion: typeof viewingDocVersion; + }, + ) { + setProject((prev) => { + if (!prev) return prev; + const documents = prev.documents ?? []; + if (documents.some((d) => d.id === doc.id)) return prev; + const nextDocs = [...documents]; + nextDocs.splice( + Math.max(0, Math.min(snapshot.index, nextDocs.length)), + 0, + doc, + ); + return { ...prev, documents: nextDocs }; + }); + if (snapshot.selected) { + setSelectedDocIds((prev) => + prev.includes(doc.id) ? prev : [...prev, doc.id], + ); + } + if (snapshot.versionsOpen) { + setExpandedVersionDocIds((prev) => new Set([...prev, doc.id])); + } + const versions = snapshot.versions; + if (versions) { + setVersionsByDocId((prev) => { + const next = new Map(prev); + next.set(doc.id, { + currentVersionId: snapshot.currentVersionId ?? null, + versions, + }); + return next; + }); + } + if (snapshot.loadingVersions) { + setLoadingVersionDocIds((prev) => new Set([...prev, doc.id])); + } + if (snapshot.uploadingVersion) { + setUploadingVersionDocIds((prev) => new Set([...prev, doc.id])); + } + if (snapshot.viewing) { + setViewingDoc(doc); + setViewingDocVersion(snapshot.viewingVersion); + } + } + async function handleRemoveDocFromFolder(docId: string) { setProject((prev) => prev ? { ...prev, @@ -450,10 +637,21 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { async function submitDocumentRename(docId: string) { const trimmed = renameDocumentValue.trim(); - setRenamingDocumentId(null); - if (!trimmed) return; + if (!trimmed) { + setRenamingDocumentId(null); + return; + } const previous = project?.documents?.find((d) => d.id === docId); - if (!previous || trimmed === previous.filename) return; + if (!previous || trimmed === previous.filename) { + setRenamingDocumentId(null); + return; + } + if (hasFilenameExtensionChange(previous.filename, trimmed)) { + setDocumentRenameWarning(extensionChangeWarning(previous.filename)); + return; + } + + setRenamingDocumentId(null); setProject((prev) => prev @@ -512,6 +710,32 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { ); } + function requestRemoveDoc(doc: Document) { + if ((currentVersionNumber(doc) ?? 1) > 1) { + setPendingDeleteStatus("idle"); + setPendingDeleteDoc(doc); + return; + } + void handleRemoveDoc(doc.id); + } + + async function confirmRemovePendingDoc() { + const pending = pendingDeleteDoc; + if (!pending || pendingDeleteStatus === "deleting") return; + setPendingDeleteStatus("deleting"); + try { + await handleRemoveDoc(pending.id); + setPendingDeleteStatus("deleted"); + window.setTimeout(() => { + setPendingDeleteDoc(null); + setPendingDeleteStatus("idle"); + }, 650); + } catch (err) { + console.error("delete document failed", err); + setPendingDeleteStatus("idle"); + } + } + async function handleNewChat() { setCreatingChat(true); try { @@ -677,7 +901,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { } } - async function handleDeleteChatRow(chat: MikeChat) { + async function handleDeleteChatRow(chat: Chat) { if (user?.id && chat.user_id !== user.id) { setOwnerOnlyAction("delete this chat"); return; @@ -699,7 +923,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { function wouldCreateCycle(movingId: string, targetId: string): boolean { // Returns true if targetId is movingId or a descendant of it - let cur: MikeFolder | undefined = folders.find((f) => f.id === targetId); + let cur: ProjectFolder | undefined = folders.find((f) => f.id === targetId); while (cur) { if (cur.id === movingId) return true; if (!cur.parent_folder_id) break; @@ -720,12 +944,23 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { return Array.from(dt.types).includes("Files"); } + function hasDocumentPayload(dt: DataTransfer): boolean { + return Array.from(dt.types).includes("application/mike-doc"); + } + + function currentVersionNumber(doc: Document): number | null { + return doc.active_version_number ?? doc.latest_version_number ?? null; + } + async function handleDropProjectFiles(files: File[]) { if (files.length === 0) return; - setUploadingDroppedFilenames(files.map((file) => file.name)); + const { supported, unsupported } = partitionSupportedDocumentFiles(files); + setDocumentUploadWarning(formatUnsupportedDocumentWarning(unsupported)); + if (supported.length === 0) return; + setUploadingDroppedFilenames(supported.map((file) => file.name)); try { const uploaded = await Promise.all( - files.map((file) => uploadProjectDocument(projectId, file)), + supported.map((file) => uploadProjectDocument(projectId, file)), ); invalidateDirectoryCache(); handleDocsSelected(uploaded); @@ -736,6 +971,137 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { } } + async function handleDropDocumentVersions(doc: Document, files: File[]) { + if (files.length === 0) return; + const { supported, unsupported } = partitionSupportedDocumentFiles(files); + setDocumentUploadWarning(formatUnsupportedDocumentWarning(unsupported)); + if (supported.length === 0) return; + + setUploadingVersionDocIds((prev) => new Set([...prev, doc.id])); + try { + for (const file of supported) { + await uploadDocumentVersion(doc.id, file, file.name); + } + await refreshDocumentVersionState(doc.id); + } catch (err) { + console.error("Document version drop upload failed", err); + } finally { + setUploadingVersionDocIds((prev) => { + const next = new Set(prev); + next.delete(doc.id); + return next; + }); + } + } + + async function saveExistingDocumentAsNewVersion( + targetDoc: Document, + sourceDoc: Document, + ) { + const sourceIndex = + project?.documents?.findIndex((doc) => doc.id === sourceDoc.id) ?? + -1; + const sourceSnapshot = { + index: sourceIndex >= 0 ? sourceIndex : 0, + selected: selectedDocIds.includes(sourceDoc.id), + versionsOpen: expandedVersionDocIds.has(sourceDoc.id), + versions: versionsByDocId.get(sourceDoc.id)?.versions, + currentVersionId: versionsByDocId.get(sourceDoc.id)?.currentVersionId, + loadingVersions: loadingVersionDocIds.has(sourceDoc.id), + uploadingVersion: uploadingVersionDocIds.has(sourceDoc.id), + viewing: viewingDoc?.id === sourceDoc.id, + viewingVersion: viewingDoc?.id === sourceDoc.id + ? viewingDocVersion + : null, + }; + + setUploadingVersionDocIds((prev) => new Set([...prev, targetDoc.id])); + removeDocumentFromLocalState(sourceDoc.id); + try { + await copyDocumentVersionFromDocument( + targetDoc.id, + sourceDoc.id, + sourceDoc.filename, + ); + invalidateDirectoryCache(); + await refreshDocumentVersionState(targetDoc.id); + } catch (err) { + console.error("Existing document version drop failed", err); + restoreDocumentToLocalState(sourceDoc, sourceSnapshot); + } finally { + setUploadingVersionDocIds((prev) => { + const next = new Set(prev); + next.delete(targetDoc.id); + return next; + }); + } + } + + function handleDropExistingDocumentVersion( + targetDoc: Document, + sourceDocId: string, + ) { + if (!sourceDocId || sourceDocId === targetDoc.id) return; + const sourceDoc = (project?.documents ?? []).find( + (doc) => doc.id === sourceDocId, + ); + if (!sourceDoc) return; + setPendingVersionDrop({ targetDoc, sourceDoc }); + } + + function handleDocumentVersionDragOver( + e: DragEvent<HTMLDivElement>, + docId: string, + ) { + if ( + !hasFilePayload(e.dataTransfer) && + !hasDocumentPayload(e.dataTransfer) + ) { + return; + } + e.preventDefault(); + e.stopPropagation(); + e.dataTransfer.dropEffect = "copy"; + setDragOverVersionDocId(docId); + setDragOverFileRoot(false); + setDragOverRoot(false); + } + + function handleDocumentVersionDragLeave(e: DragEvent<HTMLDivElement>) { + if (!e.currentTarget.contains(e.relatedTarget as Node)) { + setDragOverVersionDocId(null); + } + } + + function handleDocumentVersionDrop( + e: DragEvent<HTMLDivElement>, + doc: Document, + ) { + if ( + !hasFilePayload(e.dataTransfer) && + !hasDocumentPayload(e.dataTransfer) + ) { + return; + } + e.preventDefault(); + e.stopPropagation(); + setDragOverVersionDocId(null); + setDragOverFileRoot(false); + setDragOverRoot(false); + setDragOverFolderId(null); + if (hasFilePayload(e.dataTransfer)) { + void handleDropDocumentVersions( + doc, + Array.from(e.dataTransfer.files), + ); + return; + } + void handleDropExistingDocumentVersion( + doc, + e.dataTransfer.getData("application/mike-doc"), + ); + } + async function handleDropOnFolder(targetFolderId: string | null, dt: DataTransfer) { if (!hasMovePayload(dt)) return; const docId = dt.getData("application/mike-doc"); @@ -772,16 +1138,13 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { key={`new-folder-${parentId ?? "root"}`} > <div - className={`sticky left-0 z-[60] ${CHECK_W} bg-white p-2 flex items-center justify-center self-stretch`} - style={treeControlCellStyle(depth)} - > - <ChevronRight className="h-3.5 w-3.5 text-gray-300 shrink-0" /> - </div> - <div - className={`sticky left-8 z-[60] ${DOC_NAME_COL_W} bg-white p-2`} + className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} ${stickyCellBg} py-2 pl-4 pr-2`} style={treeNameCellStyle(depth)} > - <div className="flex items-center gap-1.5"> + <div className="flex items-center gap-4"> + <span className="flex h-2.5 w-2.5 shrink-0 items-center justify-center"> + <ChevronRight className="h-3.5 w-3.5 text-gray-300" /> + </span> <FolderPlus className="h-4 w-4 text-amber-400 shrink-0" /> <input autoFocus @@ -814,20 +1177,15 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { className="group flex items-center h-10 pr-8 border-b border-gray-50" > <div - className={`sticky left-0 z-[60] ${CHECK_W} bg-white p-2 flex items-center justify-center self-stretch`} - style={treeControlCellStyle(depth)} - > - <input - type="checkbox" - disabled - className="h-2.5 w-2.5 rounded border-gray-200 cursor-default accent-black disabled:opacity-100" - /> - </div> - <div - className={`sticky left-8 z-[60] ${DOC_NAME_COL_W} bg-white p-2`} + className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} ${stickyCellBg} py-2 pl-4 pr-2`} style={treeNameCellStyle(depth)} > - <div className="flex items-center gap-2"> + <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" /> <span className="text-sm text-gray-400 truncate"> {filename} @@ -859,12 +1217,16 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { {parentId === null && renderUploadingDocumentRows(depth)} {/* Files first */} {childDocs.map((doc) => { + const docName = doc.filename; const isProcessing = doc.status === "pending" || doc.status === "processing"; const isError = doc.status === "error"; const isVersionsOpen = expandedVersionDocIds.has(doc.id); + const versionNumber = currentVersionNumber(doc); const hasVersions = - typeof doc.latest_version_number === "number" && - doc.latest_version_number >= 1; + typeof versionNumber === "number" && + versionNumber > 1; + const isVersionDragOver = dragOverVersionDocId === doc.id; + const isUploadingVersion = uploadingVersionDocIds.has(doc.id); return ( <div key={`doc-${doc.id}`}> <div @@ -875,8 +1237,20 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { return; } e.dataTransfer.setData("application/mike-doc", doc.id); - e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.effectAllowed = "copyMove"; }} + onDragEnd={() => { + setDragOverRoot(false); + setDragOverFolderId(null); + setDragOverVersionDocId(null); + }} + onDragOver={(e) => + handleDocumentVersionDragOver(e, doc.id) + } + onDragLeave={handleDocumentVersionDragLeave} + onDrop={(e) => + handleDocumentVersionDrop(e, doc) + } onClick={() => { setViewingDocVersion(null); setViewingDoc(doc); @@ -893,19 +1267,18 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { showFolderActions: false, }); }} - className="group flex items-center h-10 pr-8 border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors" + className={`group flex items-center h-10 pr-8 border-b border-gray-50 hover:bg-gray-100 cursor-pointer transition-colors ${isVersionDragOver ? "bg-blue-50 ring-1 ring-inset ring-blue-200" : ""}`} > {(() => { - const rowBg = selectedDocIds.includes(doc.id) - ? "bg-gray-50" - : "bg-white"; + const rowBg = isVersionDragOver + ? "bg-blue-50" + : selectedDocIds.includes(doc.id) + ? "bg-gray-50" + : stickyCellBg; return ( <> - <div - className={`sticky left-0 z-[60] ${CHECK_W} p-2 flex items-center justify-center ${rowBg} group-hover:bg-gray-50`} - style={treeControlCellStyle(depth)} - onClick={(e) => e.stopPropagation()} - > + <div className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} ${rowBg} py-2 pl-4 pr-2 transition-colors ${isVersionDragOver ? "" : "group-hover:bg-gray-100"}`} style={treeNameCellStyle(depth)}> + <div className="flex items-center gap-4"> <input type="checkbox" checked={selectedDocIds.includes(doc.id)} @@ -916,12 +1289,10 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { : [...prev, doc.id], ) } - className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black" + onClick={(e) => e.stopPropagation()} + className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-pointer accent-black" /> - </div> - <div className={`sticky left-8 z-[60] ${DOC_NAME_COL_W} bg-white p-2 group-hover:bg-gray-50`} style={treeNameCellStyle(depth)}> - <div className="flex items-center gap-2"> - {isProcessing ? ( + {isProcessing || isUploadingVersion ? ( <Loader2 className="h-4 w-4 animate-spin text-gray-400 shrink-0" /> ) : isError ? ( <AlertCircle className="h-4 w-4 text-red-500 shrink-0" /> @@ -960,7 +1331,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { } /> ) : ( - <span className="text-sm text-gray-800 truncate">{doc.filename}</span> + <span className="text-sm text-gray-800 truncate">{docName}</span> )} </div> </div> @@ -979,7 +1350,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { onClick={() => void toggleVersions(doc.id)} className="flex items-center gap-1 rounded px-1 py-0.5 hover:bg-gray-100 transition-colors" > - <span>{doc.latest_version_number}</span> + <span>{versionNumber}</span> {isVersionsOpen ? ( <ChevronDown className="h-3 w-3 text-gray-400" /> ) : ( @@ -1000,7 +1371,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { {!isProcessing && ( <RowActions onRename={() => { - setRenameDocumentValue(doc.filename); + setRenameDocumentValue(docName); setRenamingDocumentId(doc.id); }} renameLabel="Rename document" @@ -1014,7 +1385,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { void handleUploadNewVersion(doc) } onRemoveFromFolder={doc.folder_id ? () => handleRemoveDocFromFolder(doc.id) : undefined} - onDelete={() => handleRemoveDoc(doc.id)} + onDelete={() => requestRemoveDoc(doc)} /> )} </div> @@ -1025,17 +1396,27 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { {isVersionsOpen && ( <DocVersionHistory docId={doc.id} - filename={doc.filename} + filename={docName} + fileType={doc.file_type} + activeVersionNumber={versionNumber} loading={loadingVersionDocIds.has(doc.id)} - versions={versionsByDocId.get(doc.id) ?? []} + versions={versionsByDocId.get(doc.id)?.versions ?? []} + currentVersionId={ + versionsByDocId.get(doc.id)?.currentVersionId ?? null + } depth={depth} onDownloadVersion={downloadDocVersion} onOpenVersion={(versionId, label) => { setViewingDocVersion({ id: versionId, label }); setViewingDoc(doc); }} - onRenameVersion={(versionId, displayName) => - handleRenameVersion(doc.id, versionId, displayName) + onRenameVersion={(versionId, filename) => + handleRenameVersion(doc.id, versionId, filename) + } + onExtensionChangeBlocked={(filename) => + setDocumentRenameWarning( + extensionChangeWarning(filename), + ) } /> )} @@ -1065,6 +1446,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { e.preventDefault(); e.stopPropagation(); setDragOverFolderId(folder.id); + setDragOverVersionDocId(null); }} onDragLeave={(e) => { e.stopPropagation(); setDragOverFolderId(null); }} onDrop={async (e) => { @@ -1073,6 +1455,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { e.stopPropagation(); setDragOverFolderId(null); setDragOverRoot(false); + setDragOverVersionDocId(null); await handleDropOnFolder(folder.id, e.dataTransfer); }} onClick={() => toggleFolder(folder.id)} @@ -1082,16 +1465,16 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { closeRowActionMenus(); setContextMenu({ x: e.clientX, y: e.clientY, folderId: folder.id, showFolderActions: true }); }} - className={`group flex items-center h-10 pr-8 border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors ${isRenaming ? "" : "select-none"} ${dragOverFolderId === folder.id ? "bg-blue-50 ring-1 ring-inset ring-blue-200" : ""}`} + className={`group flex items-center h-10 pr-8 border-b border-gray-50 hover:bg-gray-100 cursor-pointer transition-colors ${isRenaming ? "" : "select-none"} ${dragOverFolderId === folder.id ? "bg-blue-50 ring-1 ring-inset ring-blue-200" : ""}`} > - <div className={`sticky left-0 z-[60] ${CHECK_W} p-2 flex items-center justify-center ${dragOverFolderId === folder.id ? "bg-blue-50" : "bg-white"} group-hover:bg-gray-50 self-stretch`} style={treeControlCellStyle(depth)}> - {isExpanded - ? <ChevronDown className="h-3.5 w-3.5 text-gray-400 shrink-0" /> - : <ChevronRight className="h-3.5 w-3.5 text-gray-400 shrink-0" /> - } - </div> - <div className={`sticky left-8 z-[60] ${DOC_NAME_COL_W} p-2 ${dragOverFolderId === folder.id ? "bg-blue-50" : "bg-white"} group-hover:bg-gray-50`} style={treeNameCellStyle(depth)}> - <div className="flex items-center gap-1.5"> + <div className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} py-2 pl-4 pr-2 ${dragOverFolderId === folder.id ? "bg-blue-50" : stickyCellBg} transition-colors ${dragOverFolderId === folder.id ? "" : "group-hover:bg-gray-100"}`} 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"> + {isExpanded + ? <ChevronDown className="h-3.5 w-3.5 text-gray-400" /> + : <ChevronRight className="h-3.5 w-3.5 text-gray-400" /> + } + </span> {isExpanded ? <FolderOpen className="h-4 w-4 text-amber-500 shrink-0" /> : <Folder className="h-4 w-4 text-amber-500 shrink-0" /> @@ -1160,8 +1543,15 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { } 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 q = search.toLowerCase(); - const filteredDocs = q ? docs.filter((d) => d.filename.toLowerCase().includes(q)) : docs; + const filteredDocs = q + ? docs.filter((d) => d.filename.toLowerCase().includes(q)) + : docs; const filteredChats = q ? chats.filter((c) => (c.title ?? "").toLowerCase().includes(q)) : chats; const filteredReviews = q ? projectReviews.filter((r) => (r.title ?? "").toLowerCase().includes(q)) : projectReviews; @@ -1230,22 +1620,112 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { className="flex items-center gap-1 text-xs font-medium text-gray-500 hover:text-gray-700 transition-colors" > <FolderPlus className="h-3.5 w-3.5" /> - Add Subfolder + <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" > <Upload className="h-3.5 w-3.5" /> - Add Documents + <span className="hidden sm:inline">Add Documents</span> </button> </> )} </div> ); + const pendingVersionDropMessage = pendingVersionDrop ? ( + <div className="space-y-2"> + <p> + You are about to save{" "} + <span className="font-medium text-gray-950"> + {pendingVersionDrop.sourceDoc.filename} + </span>{" "} + as a new version of{" "} + <span className="font-medium text-gray-950"> + {pendingVersionDrop.targetDoc.filename} + </span> + . + </p> + <p> + <span className="font-medium text-gray-950"> + {pendingVersionDrop.sourceDoc.filename} + </span>{" "} + will no longer exist as a separate document + {(currentVersionNumber(pendingVersionDrop.sourceDoc) ?? 1) > 1 + ? " and its older versions will be deleted" + : ""} + . + </p> + </div> + ) : undefined; + const pendingDeleteDocMessage = pendingDeleteDoc ? ( + <div className="space-y-2"> + <p> + <span className="font-medium text-gray-950"> + {pendingDeleteDoc.filename} + </span>{" "} + has {currentVersionNumber(pendingDeleteDoc)} versions. Deleting + this document will delete all of its versions. + </p> + </div> + ) : undefined; return ( - <div className="flex-1 overflow-y-auto bg-white flex flex-col h-full"> + <div className="relative flex-1 overflow-y-auto flex flex-col h-full"> + <input + ref={versionUploadInputRef} + type="file" + accept={versionUploadAccept} + className="hidden" + onChange={handleVersionUploadInputChange} + /> + <WarningPopup + open={!!documentUploadWarning} + onClose={() => setDocumentUploadWarning(null)} + message={documentUploadWarning} + /> + <WarningPopup + open={!!documentRenameWarning} + onClose={() => setDocumentRenameWarning(null)} + message={documentRenameWarning} + /> + <ConfirmPopup + open={!!pendingVersionDrop} + title="Save as new version?" + message={pendingVersionDropMessage} + confirmLabel="Confirm" + cancelLabel="Cancel" + onCancel={() => setPendingVersionDrop(null)} + onConfirm={() => { + const pending = pendingVersionDrop; + if (!pending) return; + setPendingVersionDrop(null); + void saveExistingDocumentAsNewVersion( + pending.targetDoc, + pending.sourceDoc, + ); + }} + /> + <ConfirmPopup + open={!!pendingDeleteDoc} + title="Delete document?" + message={pendingDeleteDocMessage} + confirmLabel="Delete" + confirmStatus={ + pendingDeleteStatus === "deleting" + ? "loading" + : pendingDeleteStatus === "deleted" + ? "complete" + : "idle" + } + cancelLabel="Cancel" + onCancel={() => { + if (pendingDeleteStatus === "deleting") return; + setPendingDeleteDoc(null); + setPendingDeleteStatus("idle"); + }} + onConfirm={() => void confirmRemovePendingDoc()} + /> <ProjectPageHeader project={project} tab={tab} @@ -1254,7 +1734,6 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { creatingReview={creatingReview} docsCount={docs.length} onBackToProjects={() => router.push("/projects")} - onOpenDocuments={() => router.push(`/projects/${projectId}`)} onTitleCommit={handleTitleCommit} onSearchChange={setSearch} onOpenPeople={() => setPeopleModalOpen(true)} @@ -1265,7 +1744,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { <ToolbarTabs tabs={[ { id: "documents", label: "Documents" }, - { id: "assistant", label: "Assistant" }, + { id: "assistant", label: "Assistant Chats" }, { id: "reviews", label: "Tabular Reviews" }, ]} active={tab} @@ -1286,7 +1765,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { <div className="flex-1 flex flex-col min-h-0"> {/* Table header */} <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] ${CHECK_W} relative bg-white flex items-center justify-center self-stretch before:absolute before:inset-x-0 before:bottom-0 before:h-px before:bg-white`}> + <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`}> <input type="checkbox" checked={allDocsSelected} @@ -1297,9 +1776,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { }} className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black" /> - </div> - <div className={`sticky left-8 z-[60] ${DOC_NAME_COL_W} bg-white pl-2 text-left`}> - Name + <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> @@ -1317,6 +1794,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { e.preventDefault(); e.dataTransfer.dropEffect = "copy"; setDragOverFileRoot(true); + setDragOverVersionDocId(null); }} onDragLeave={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node)) { @@ -1330,6 +1808,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { setDragOverFileRoot(false); setDragOverRoot(false); setDragOverFolderId(null); + setDragOverVersionDocId(null); void handleDropProjectFiles( Array.from(e.dataTransfer.files), ); @@ -1351,7 +1830,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { className="flex-1 flex cursor-pointer flex-col items-center justify-center py-24 text-center" > <Upload className="h-8 w-8 text-gray-200 mb-3" /> - <p className="text-sm text-gray-400">Drop PDF or DOCX files here</p> + <p className="text-sm text-gray-400">Drop PDF, DOCX, or DOC files here</p> </div> ) : ( <div @@ -1366,6 +1845,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { if (!hasMovePayload(e.dataTransfer)) return; e.preventDefault(); setDragOverRoot(true); + setDragOverVersionDocId(null); }} onDragLeave={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node)) { @@ -1377,6 +1857,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { e.preventDefault(); setDragOverRoot(false); setDragOverFolderId(null); + setDragOverVersionDocId(null); await handleDropOnFolder(null, e.dataTransfer); }} > @@ -1385,15 +1866,69 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { <> {renderUploadingDocumentRows(0)} {filteredDocs.map((doc) => { + const docName = + doc.filename; const isProcessing = doc.status === "pending" || doc.status === "processing"; const isError = doc.status === "error"; const isVersionsOpen = expandedVersionDocIds.has(doc.id); + const versionNumber = + currentVersionNumber(doc); const hasVersions = - typeof doc.latest_version_number === "number" && - doc.latest_version_number >= 1; + typeof versionNumber === + "number" && + versionNumber > 1; + const isVersionDragOver = + dragOverVersionDocId === doc.id; + const isUploadingVersion = + uploadingVersionDocIds.has( + doc.id, + ); return ( <div key={doc.id}> <div + draggable={ + renamingDocumentId !== + doc.id + } + onDragStart={(e) => { + if ( + renamingDocumentId === + doc.id + ) { + e.preventDefault(); + return; + } + e.dataTransfer.setData( + "application/mike-doc", + doc.id, + ); + e.dataTransfer.effectAllowed = + "copyMove"; + }} + onDragEnd={() => { + setDragOverRoot(false); + setDragOverFolderId( + null, + ); + setDragOverVersionDocId( + null, + ); + }} + onDragOver={(e) => + handleDocumentVersionDragOver( + e, + doc.id, + ) + } + onDragLeave={ + handleDocumentVersionDragLeave + } + onDrop={(e) => + handleDocumentVersionDrop( + e, + doc, + ) + } onClick={() => { setViewingDocVersion(null); setViewingDoc(doc); @@ -1410,19 +1945,18 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { showFolderActions: false, }); }} - className="group flex items-center h-10 pr-8 border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors" + className={`group flex items-center h-10 pr-8 border-b border-gray-50 hover:bg-gray-100 cursor-pointer transition-colors ${isVersionDragOver ? "bg-blue-50 ring-1 ring-inset ring-blue-200" : ""}`} > - <div className={`sticky left-0 z-[60] ${CHECK_W} p-2 flex items-center justify-center ${selectedDocIds.includes(doc.id) ? "bg-gray-50" : "bg-white"} group-hover:bg-gray-50`} onClick={(e) => e.stopPropagation()}> + <div 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])} - className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black" + onClick={(e) => e.stopPropagation()} + className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-pointer accent-black" /> - </div> - <div className={`sticky left-8 z-[60] ${DOC_NAME_COL_W} bg-white p-2 group-hover:bg-gray-50`}> - <div className="flex items-center gap-2"> - {isProcessing ? <Loader2 className="h-4 w-4 animate-spin text-gray-400 shrink-0" /> : isError ? <AlertCircle className="h-4 w-4 text-red-500 shrink-0" /> : <DocIcon fileType={doc.file_type} />} + {isProcessing || isUploadingVersion ? <Loader2 className="h-4 w-4 animate-spin text-gray-400 shrink-0" /> : isError ? <AlertCircle className="h-4 w-4 text-red-500 shrink-0" /> : <DocIcon fileType={doc.file_type} />} {renamingDocumentId === doc.id ? ( <input autoFocus @@ -1455,7 +1989,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { } /> ) : ( - <span className="text-sm text-gray-800 truncate">{doc.filename}</span> + <span className="text-sm text-gray-800 truncate">{docName}</span> )} </div> </div> @@ -1470,7 +2004,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { onClick={() => void toggleVersions(doc.id)} className="flex items-center gap-1 rounded px-1 py-0.5 hover:bg-gray-100 transition-colors" > - <span>{doc.latest_version_number}</span> + <span>{versionNumber}</span> {isVersionsOpen ? ( <ChevronDown className="h-3 w-3 text-gray-400" /> ) : ( @@ -1491,7 +2025,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { {!isProcessing && ( <RowActions onRename={() => { - setRenameDocumentValue(doc.filename); + setRenameDocumentValue(docName); setRenamingDocumentId(doc.id); }} renameLabel="Rename document" @@ -1504,7 +2038,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { onUploadNewVersion={() => void handleUploadNewVersion(doc) } - onDelete={() => handleRemoveDoc(doc.id)} + onDelete={() => requestRemoveDoc(doc)} /> )} </div> @@ -1512,16 +2046,26 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { {isVersionsOpen && ( <DocVersionHistory docId={doc.id} - filename={doc.filename} + filename={docName} + fileType={doc.file_type} + activeVersionNumber={versionNumber} loading={loadingVersionDocIds.has(doc.id)} - versions={versionsByDocId.get(doc.id) ?? []} + versions={versionsByDocId.get(doc.id)?.versions ?? []} + currentVersionId={ + versionsByDocId.get(doc.id)?.currentVersionId ?? null + } onDownloadVersion={downloadDocVersion} onOpenVersion={(versionId, label) => { setViewingDocVersion({ id: versionId, label }); setViewingDoc(doc); }} - onRenameVersion={(versionId, displayName) => - handleRenameVersion(doc.id, versionId, displayName) + onRenameVersion={(versionId, filename) => + handleRenameVersion(doc.id, versionId, filename) + } + onExtensionChangeBlocked={(filename) => + setDocumentRenameWarning( + extensionChangeWarning(filename), + ) } /> )} @@ -1543,9 +2087,12 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { const menuDoc = contextMenu.docId ? docs.find((doc) => doc.id === contextMenu.docId) : null; + const menuDocVersionNumber = menuDoc + ? currentVersionNumber(menuDoc) + : null; const menuDocHasVersions = - typeof menuDoc?.latest_version_number === "number" && - menuDoc.latest_version_number >= 1; + typeof menuDocVersionNumber === "number" && + menuDocVersionNumber > 1; const menuDocVersionsOpen = menuDoc ? expandedVersionDocIds.has(menuDoc.id) : false; @@ -1587,7 +2134,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { : undefined } onDelete={() => - void handleRemoveDoc(menuDoc.id) + requestRemoveDoc(menuDoc) } /> ) : ( @@ -1717,24 +2264,41 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { projectId={projectId} /> - <UploadNewVersionModal - open={!!uploadVersionDoc} - doc={uploadVersionDoc} - onClose={() => setUploadVersionDoc(null)} - onSubmit={(file, displayName) => - submitNewVersion(uploadVersionDoc!, file, displayName) - } - /> - - <DocViewModal - doc={viewingDoc} + <DocumentSidePanel + doc={sidePanelDoc} versionId={viewingDocVersion?.id ?? null} - versionLabel={viewingDocVersion?.label ?? null} + currentVersionId={ + sidePanelDoc + ? versionsByDocId.get(sidePanelDoc.id) + ?.currentVersionId ?? null + : null + } + versions={ + sidePanelDoc + ? versionsByDocId.get(sidePanelDoc.id)?.versions ?? [] + : [] + } + versionsLoading={ + sidePanelDoc + ? loadingVersionDocIds.has(sidePanelDoc.id) + : false + } onClose={() => { setViewingDoc(null); setViewingDocVersion(null); }} - onDelete={(doc) => handleRemoveDoc(doc.id)} + onLoadVersions={(docId) => loadDocumentVersions(docId)} + onSelectVersion={(versionId, label) => + setViewingDocVersion({ id: versionId, label }) + } + onDownloadDocument={downloadDoc} + onDownloadVersion={downloadDocVersion} + onRenameVersion={handleRenameVersion} + onDeleteVersion={handleDeleteVersion} + onUploadNewVersion={submitNewVersion} + onDelete={async (doc) => { + await handleRemoveDoc(doc.id); + }} /> <AddNewTRModal @@ -1790,3 +2354,26 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { </div> ); } + +function filenameExtension(filename: string) { + const trimmed = filename.trim(); + const dotIndex = trimmed.lastIndexOf("."); + if (dotIndex <= 0 || dotIndex === trimmed.length - 1) return null; + return trimmed.slice(dotIndex); +} + +function hasFilenameExtensionChange(previous: string, next: string) { + const previousExtension = filenameExtension(previous); + if (previousExtension == null) return false; + return ( + filenameExtension(next)?.toLowerCase() !== + previousExtension.toLowerCase() + ); +} + +function extensionChangeWarning(filename: string) { + const extension = filenameExtension(filename); + return extension + ? `File extensions cannot be changed here. Keep ${extension} at the end of the name.` + : "File extensions cannot be changed here."; +} diff --git a/frontend/src/app/components/projects/ProjectPageParts.tsx b/frontend/src/app/components/projects/ProjectPageParts.tsx index 2c30a38..6fd48a5 100644 --- a/frontend/src/app/components/projects/ProjectPageParts.tsx +++ b/frontend/src/app/components/projects/ProjectPageParts.tsx @@ -2,18 +2,20 @@ import { type CSSProperties, useState } from "react"; import { - Download, + CornerDownRight, File, FileText, Loader2, - Pencil, - Plus, + MessageSquare, + Search, + Table2, Users, } from "lucide-react"; -import { HeaderSearchBtn } from "@/app/components/shared/HeaderSearchBtn"; +import { PageHeader } from "@/app/components/shared/PageHeader"; import { RenameableTitle } from "@/app/components/shared/RenameableTitle"; -import type { MikeProject } from "@/app/components/shared/types"; -import type { MikeDocumentVersion } from "@/app/lib/mikeApi"; +import type { Project } from "@/app/components/shared/types"; +import type { DocumentVersion } from "@/app/lib/mikeApi"; +import { RowActions } from "@/app/components/shared/RowActions"; export type ProjectTab = "documents" | "assistant" | "reviews"; @@ -25,32 +27,18 @@ export type ProjectContextMenu = { showFolderActions: boolean; }; -export const CHECK_W = "w-8 shrink-0"; -export const NAME_COL_W = "w-[300px] shrink-0"; +export const NAME_COL_W = "w-[332px] shrink-0"; export const DOC_NAME_COL_W = - "w-[260px] sm:w-[300px] md:w-[360px] lg:w-[420px] xl:w-[500px] 2xl:w-[560px] shrink-0"; + "w-[292px] sm:w-[332px] md:w-[392px] lg:w-[452px] xl:w-[532px] 2xl:w-[592px] shrink-0"; const TREE_CONTROL_WIDTH_PX = 32; -const TREE_NAME_PADDING_PX = 8; - -function treeControlWidth(depth: number) { - return TREE_CONTROL_WIDTH_PX * (Math.max(0, depth) + 1); -} - -export function treeControlCellStyle(depth: number): CSSProperties | undefined { - if (depth <= 0) return undefined; - const width = treeControlWidth(depth); - return { - justifyContent: "flex-start", - minWidth: width, - paddingLeft: TREE_NAME_PADDING_PX + depth * TREE_CONTROL_WIDTH_PX, - width, - }; -} +const TREE_NAME_PADDING_PX = 16; export function treeNameCellStyle(depth: number): CSSProperties | undefined { if (depth <= 0) return undefined; - return { left: treeControlWidth(depth) }; + return { + paddingLeft: TREE_NAME_PADDING_PX + depth * TREE_CONTROL_WIDTH_PX, + }; } export function formatBytes(bytes: number): string { @@ -78,17 +66,24 @@ export function DocIcon({ fileType }: { fileType: string | null }) { export function DocVersionHistory({ docId, filename, + fileType, + activeVersionNumber, + currentVersionId, loading, versions, depth = 0, onDownloadVersion, onOpenVersion, onRenameVersion, + onExtensionChangeBlocked, }: { docId: string; filename: string; + fileType: string | null; + activeVersionNumber: number | null; + currentVersionId: string | null; loading: boolean; - versions: MikeDocumentVersion[]; + versions: DocumentVersion[]; depth?: number; onDownloadVersion: ( docId: string, @@ -98,8 +93,9 @@ export function DocVersionHistory({ onOpenVersion?: (versionId: string, versionLabel: string) => void; onRenameVersion?: ( versionId: string, - displayName: string | null, + filename: string | null, ) => Promise<void> | void; + onExtensionChangeBlocked?: (filename: string) => void; }) { const [editingVersionId, setEditingVersionId] = useState<string | null>( null, @@ -108,40 +104,69 @@ export function DocVersionHistory({ const commit = async (versionId: string) => { const trimmed = editingValue.trim(); + const previousFilename = versions + .find((version) => version.id === versionId) + ?.filename?.trim(); + if ( + previousFilename && + (trimmed.length === 0 || + hasFilenameExtensionChange(previousFilename, trimmed)) + ) { + onExtensionChangeBlocked?.(previousFilename); + return; + } + setEditingVersionId(null); const next = trimmed.length > 0 ? trimmed : null; await onRenameVersion?.(versionId, next); }; if (loading && versions.length === 0) { + const skeletonCount = Math.max(0, (activeVersionNumber ?? 1) - 1); return ( - <div className="flex items-center h-9 border-b border-gray-50 text-xs text-gray-500 bg-gray-50/60"> - <div - className={`sticky left-0 z-[60] ${CHECK_W} bg-gray-50/60 self-stretch`} - style={treeControlCellStyle(depth)} - /> - <div - className={`sticky left-8 z-[60] ${DOC_NAME_COL_W} bg-gray-50/60 p-2`} - style={treeNameCellStyle(depth)} - > - <div className="flex items-center gap-2"> - <Loader2 className="h-3 w-3 animate-spin text-gray-400" /> - <span>Loading versions…</span> + <> + {Array.from({ length: skeletonCount }).map((_, index) => ( + <div + key={`ver-skeleton-${docId}-${index}`} + className="flex h-10 items-center pr-8 bg-gray-100" + > + <div + className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} bg-gray-100 py-2 pl-4 pr-2`} + style={treeNameCellStyle(depth)} + > + <div className="flex items-center gap-4"> + <div className="h-2.5 w-2.5 shrink-0 rounded bg-gray-200 animate-pulse" /> + <div className="h-4 w-4 shrink-0 rounded bg-gray-200 animate-pulse" /> + <div className="h-3 w-32 rounded bg-gray-200 animate-pulse" /> + </div> + </div> + <div className="ml-auto w-20 shrink-0"> + <div className="h-3 w-8 rounded bg-gray-200 animate-pulse" /> + </div> + <div className="w-24 shrink-0"> + <div className="h-3 w-10 rounded bg-gray-200 animate-pulse" /> + </div> + <div className="w-20 shrink-0 pl-1"> + <div className="h-3 w-5 rounded bg-gray-200 animate-pulse" /> + </div> + <div className="w-32 shrink-0"> + <div className="h-3 w-16 rounded bg-gray-200 animate-pulse" /> + </div> + <div className="w-32 shrink-0"> + <div className="h-3 w-10 rounded bg-gray-200 animate-pulse" /> + </div> + <div className="w-8 shrink-0" /> </div> - </div> - </div> + ))} + </> ); } if (versions.length === 0) { return ( - <div className="flex items-center h-9 border-b border-gray-50 text-xs text-gray-400 bg-gray-50/60"> + <div className="flex items-center h-9 border-b border-gray-50 text-xs text-gray-400 bg-gray-50/80"> <div - className={`sticky left-0 z-[60] ${CHECK_W} bg-gray-50/60 self-stretch`} - style={treeControlCellStyle(depth)} - /> - <div - className={`sticky left-8 z-[60] ${DOC_NAME_COL_W} bg-gray-50/60 p-2`} + className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} bg-gray-50/80 py-2 pl-4 pr-2`} style={treeNameCellStyle(depth)} > <div>No version history.</div> @@ -150,7 +175,10 @@ export function DocVersionHistory({ ); } - const ordered = [...versions].reverse(); + const olderVersions = versions.filter((v) => v.id !== currentVersionId); + if (olderVersions.length === 0) return null; + + const ordered = [...olderVersions].reverse(); return ( <> {ordered.map((v) => { @@ -161,7 +189,7 @@ export function DocVersionHistory({ : v.source === "upload" ? "Original" : "—"; - const displayLabel = v.display_name?.trim() || numberLabel; + const displayLabel = v.filename?.trim() || numberLabel; const dt = new Date(v.created_at); const dateLabel = Number.isNaN(dt.valueOf()) ? "" @@ -173,7 +201,7 @@ export function DocVersionHistory({ minute: "2-digit", }); const isEditing = editingVersionId === v.id; - + const rowBg = "bg-gray-100"; return ( <div key={`ver-${docId}-${v.id}`} @@ -181,20 +209,20 @@ export function DocVersionHistory({ if (isEditing) return; onOpenVersion?.(v.id, displayLabel); }} - className="group flex items-center h-9 pr-3 md:pr-10 border-b border-gray-50 bg-gray-50/60 text-xs text-gray-600 cursor-pointer hover:bg-gray-100/80 transition-colors" + className={`group flex h-10 cursor-pointer items-center pr-8 text-sm text-gray-500 transition-colors hover:bg-gray-200 ${rowBg}`} > <div - className={`sticky left-0 z-[60] ${CHECK_W} bg-gray-50/60 group-hover:bg-gray-100/80 self-stretch`} - style={treeControlCellStyle(depth)} - /> - <div - className={`sticky left-8 z-[60] ${DOC_NAME_COL_W} bg-gray-50/60 group-hover:bg-gray-100/80 p-2`} + className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} ${rowBg} py-2 pl-4 pr-2 transition-colors group-hover:bg-gray-200`} style={treeNameCellStyle(depth)} > - <div className="flex items-center gap-2"> - <span className="shrink-0 text-gray-400"> - ↳ + <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" + aria-hidden="true" + /> </span> + <DocIcon fileType={fileType} /> {isEditing ? ( <input autoFocus @@ -212,53 +240,48 @@ export function DocVersionHistory({ } }} onBlur={() => void commit(v.id)} - className="min-w-0 flex-1 max-w-[240px] border-b border-gray-300 bg-transparent text-xs text-gray-800 outline-none focus:border-gray-500" + 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="font-medium text-gray-700 truncate"> + <span className="truncate text-sm text-gray-700"> {displayLabel} </span> )} - {!isEditing && onRenameVersion && ( - <button - onClick={(e) => { - e.stopPropagation(); - setEditingVersionId(v.id); - setEditingValue( - v.display_name ?? "", - ); - }} - title="Rename version" - className="shrink-0 rounded p-0.5 text-gray-400 opacity-0 group-hover:opacity-100 hover:text-gray-700 hover:bg-gray-200 transition" - > - <Pencil className="h-3 w-3" /> - </button> - )} - <span className="text-gray-400 truncate"> - {dateLabel} - </span> - <span className="text-gray-300 shrink-0"> - · - </span> - <span className="text-gray-400 truncate"> - {v.source} - </span> </div> </div> - <div className="ml-auto w-20 shrink-0" /> - <div className="w-24 shrink-0" /> - <div className="ml-auto w-20 shrink-0" /> - <div className="w-8 shrink-0 flex justify-end"> - <button - onClick={(e) => { - e.stopPropagation(); - onDownloadVersion(docId, v.id, filename); - }} - title="Download this version" - className="flex items-center justify-center w-6 h-6 rounded text-gray-500 hover:text-gray-800 hover:bg-gray-100 transition-colors" - > - <Download className="h-3.5 w-3.5" /> - </button> + <div className="ml-auto w-20 shrink-0 truncate text-xs uppercase text-gray-500"> + {fileType ?? <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"> + {numberLabel} + </div> + <div className="w-32 shrink-0 truncate text-sm 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"> + — + </div> + <div + 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) + } + /> </div> </div> ); @@ -269,20 +292,43 @@ export function DocVersionHistory({ export function ProjectPageSkeleton() { return ( - <div className="flex-1 overflow-y-auto bg-white"> - <div className="mb-1 flex items-start justify-between px-4 py-3 md:px-10"> - <div className="flex items-center gap-1.5 text-2xl font-medium font-serif"> - <span className="text-gray-400">Projects</span> - <span className="text-gray-300">›</span> - <div className="h-6 w-40 rounded bg-gray-100 animate-pulse" /> - </div> - <div className="flex items-center gap-4"> - <div className="h-8 w-8 rounded bg-gray-100 animate-pulse" /> - <div className="h-8 w-8 rounded bg-gray-100 animate-pulse" /> - <div className="h-8 w-11 rounded bg-gray-100 animate-pulse" /> - <div className="h-8 w-28 rounded bg-gray-100 animate-pulse" /> - </div> - </div> + <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" /> @@ -293,8 +339,8 @@ export function ProjectPageSkeleton() { </div> </div> <div className="flex items-center h-8 pr-3 md:pr-10 border-b border-gray-200"> - <div className="w-8 shrink-0" /> - <div className="flex-1 min-w-0 pl-3 pr-4"> + <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"> @@ -310,8 +356,8 @@ export function ProjectPageSkeleton() { key={i} className="flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50" > - <div className="w-8 shrink-0" /> - <div className="flex-1 min-w-0 pl-3 pr-4"> + <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"> @@ -335,21 +381,19 @@ export function ProjectPageHeader({ creatingReview, docsCount, onBackToProjects, - onOpenDocuments, onTitleCommit, onSearchChange, onOpenPeople, onNewChat, onNewReview, }: { - project: MikeProject; + project: Project; tab: ProjectTab; search: string; creatingChat: boolean; creatingReview: boolean; docsCount: number; onBackToProjects: () => void; - onOpenDocuments: () => void; onTitleCommit: (newName: string) => void | Promise<void>; onSearchChange: (search: string) => void; onOpenPeople: () => void; @@ -357,109 +401,88 @@ export function ProjectPageHeader({ onNewReview: () => void; }) { return ( - <div className="mb-1 flex items-start justify-between px-4 py-3 md:px-10"> - <div> - <div className="flex items-center gap-1.5 text-2xl font-medium font-serif"> - <button - onClick={onBackToProjects} - className="text-gray-400 hover:text-gray-600 transition-colors" - > - Projects - </button> - <span className="text-gray-300">›</span> - {tab !== "documents" ? ( - <button - onClick={onOpenDocuments} - className="text-gray-500 hover:text-gray-700 transition-colors" - > - {project.name} - {project.cm_number ? ( - <span className="ml-1 text-gray-400"> - (#{project.cm_number}) - </span> - ) : null} - </button> - ) : ( + <PageHeader + breadcrumbs={[ + { + label: "Projects", + onClick: onBackToProjects, + 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 - } /> - )} - {tab !== "documents" && ( - <> - <span className="text-gray-300">›</span> - <span className="text-gray-900"> - {tab === "assistant" - ? "Assistant" - : "Tabular Reviews"} + ), + suffix: project.cm_number ? ( + <span className="ml-1 text-gray-400"> + (#{project.cm_number}) + </span> + ) : null, + }, + ]} + align="start" + actionGap="lg" + actionGroups={[ + [ + { + type: "search", + value: search, + onChange: onSearchChange, + placeholder: "Search…", + }, + { + onClick: onOpenPeople, + iconOnly: true, + title: "People with access", + icon: <Users className="h-4 w-4" />, + }, + ], + [ + { + 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 ? ( + <Loader2 className="h-4 w-4 animate-spin" /> + ) : ( + <Table2 className="h-4 w-4" /> + ), + label: ( + <span className="hidden sm:inline"> + New Review </span> - </> - )} - </div> - </div> - <div className="flex items-center gap-4"> - <HeaderSearchBtn - value={search} - onChange={onSearchChange} - placeholder="Search…" - /> - <button - onClick={onOpenPeople} - className="flex h-8 w-8 items-center justify-center text-sm text-gray-500 transition-colors hover:text-gray-900 cursor-pointer" - title="People with access" - aria-label="People with access" - > - <Users className="h-4 w-4" /> - </button> - <div className="relative group"> - <button - onClick={() => !creatingChat && onNewChat()} - className={`flex h-8 items-center justify-center gap-1.5 text-sm transition-colors ${ - !creatingChat - ? "text-gray-500 hover:text-gray-900 cursor-pointer" - : "text-gray-300 cursor-default" - }`} - > - {creatingChat ? ( - <Loader2 className="h-4 w-4 animate-spin" /> - ) : ( - <Plus className="h-4 w-4" /> - )} - Chat - </button> - </div> - <div className="relative group"> - <button - onClick={() => - docsCount > 0 && !creatingReview && onNewReview() - } - className={`flex h-8 items-center justify-center gap-1.5 text-sm transition-colors ${ - docsCount > 0 - ? "text-gray-500 hover:text-gray-900 cursor-pointer" - : "text-gray-300 cursor-default" - }`} - > - {creatingReview ? ( - <Loader2 className="h-4 w-4 animate-spin" /> - ) : ( - <Plus className="h-4 w-4" /> - )} - Tabular Review - </button> - {docsCount === 0 && ( - <div className="pointer-events-none absolute right-0 top-full mt-1.5 z-10 hidden group-hover:flex items-center whitespace-nowrap rounded-lg bg-gray-900 px-2.5 py-1.5 text-xs text-white shadow-lg"> - Upload a document first - </div> - )} - </div> - </div> - </div> + ), + tooltip: docsCount === 0 ? "Upload a document first" : null, + }, + ], + ]} + /> + ); +} + +function filenameExtension(filename: string) { + const trimmed = filename.trim(); + const dotIndex = trimmed.lastIndexOf("."); + if (dotIndex <= 0 || dotIndex === trimmed.length - 1) return null; + return trimmed.slice(dotIndex); +} + +function hasFilenameExtensionChange(previous: string, next: string) { + const previousExtension = filenameExtension(previous); + if (previousExtension == null) return false; + return ( + filenameExtension(next)?.toLowerCase() !== + previousExtension.toLowerCase() ); } diff --git a/frontend/src/app/components/projects/ProjectReviewsTab.tsx b/frontend/src/app/components/projects/ProjectReviewsTab.tsx index 8587901..14cf49c 100644 --- a/frontend/src/app/components/projects/ProjectReviewsTab.tsx +++ b/frontend/src/app/components/projects/ProjectReviewsTab.tsx @@ -3,8 +3,8 @@ import { type Dispatch, type SetStateAction } from "react"; import { Table2 } from "lucide-react"; import { RowActions } from "@/app/components/shared/RowActions"; -import type { MikeDocument, TabularReview } from "@/app/components/shared/types"; -import { CHECK_W, formatDate, NAME_COL_W } from "./ProjectPageParts"; +import type { Document, TabularReview } from "@/app/components/shared/types"; +import { formatDate, NAME_COL_W } from "./ProjectPageParts"; export function ProjectReviewsTab({ docs, @@ -26,7 +26,7 @@ export function ProjectReviewsTab({ setRenamingReviewId, setRenameReviewValue, }: { - docs: MikeDocument[]; + docs: Document[]; reviews: TabularReview[]; filteredReviews: TabularReview[]; selectedReviewIds: string[]; @@ -45,12 +45,12 @@ export function ProjectReviewsTab({ setRenamingReviewId: Dispatch<SetStateAction<string | null>>; setRenameReviewValue: Dispatch<SetStateAction<string>>; }) { + const stickyCellBg = "bg-[#fcfcfd]"; + 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] ${CHECK_W} relative bg-white flex items-center justify-center self-stretch before:absolute before:inset-x-0 before:bottom-0 before:h-px before:bg-white`} - > + <div className={`sticky left-0 z-[60] ${NAME_COL_W} ${stickyCellBg} flex items-center gap-4 self-stretch pl-4 pr-2 text-left`}> <input type="checkbox" checked={allReviewsSelected} @@ -66,11 +66,7 @@ export function ProjectReviewsTab({ }} className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black" /> - </div> - <div - className={`sticky left-8 z-[60] ${NAME_COL_W} bg-white pl-2 text-left`} - > - Name + <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> @@ -103,58 +99,52 @@ export function ProjectReviewsTab({ if (renamingReviewId === review.id) return; onOpenReview(review.id); }} - className="group flex items-center h-10 pr-8 border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors" + className="group flex items-center h-10 pr-8 border-b border-gray-50 hover:bg-gray-100 cursor-pointer transition-colors" > <div - className={`sticky left-0 z-[60] ${CHECK_W} p-2 flex items-center justify-center ${ - selectedReviewIds.includes(review.id) - ? "bg-gray-50" - : "bg-white" - } group-hover:bg-gray-50`} - onClick={(e) => e.stopPropagation()} + className={`sticky left-0 z-[60] ${NAME_COL_W} ${selectedReviewIds.includes(review.id) ? "bg-gray-50" : stickyCellBg} py-2 pl-4 pr-2 transition-colors group-hover:bg-gray-100`} > - <input - type="checkbox" - checked={selectedReviewIds.includes(review.id)} - onChange={() => - setSelectedReviewIds((prev) => - prev.includes(review.id) - ? prev.filter( - (x) => x !== review.id, - ) - : [...prev, review.id], - ) - } - className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black" - /> - </div> - <div - className={`sticky left-8 z-[60] ${NAME_COL_W} bg-white p-2 group-hover:bg-gray-50`} - > - {renamingReviewId === review.id ? ( + <div className="flex min-w-0 items-center gap-4"> <input - autoFocus - value={renameReviewValue} - onChange={(e) => - setRenameReviewValue(e.target.value) - } - onKeyDown={(e) => { - if (e.key === "Enter") - void submitReviewRename(review.id); - if (e.key === "Escape") - setRenamingReviewId(null); - }} - onBlur={() => - void submitReviewRename(review.id) + type="checkbox" + checked={selectedReviewIds.includes(review.id)} + onChange={() => + setSelectedReviewIds((prev) => + prev.includes(review.id) + ? prev.filter( + (x) => x !== review.id, + ) + : [...prev, review.id], + ) } onClick={(e) => e.stopPropagation()} - className="w-full text-sm text-gray-800 bg-transparent outline-none" + className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-pointer accent-black" /> - ) : ( - <span className="text-sm text-gray-800 truncate block"> - {review.title ?? "Untitled Review"} - </span> - )} + {renamingReviewId === review.id ? ( + <input + autoFocus + value={renameReviewValue} + onChange={(e) => + setRenameReviewValue(e.target.value) + } + onKeyDown={(e) => { + if (e.key === "Enter") + void submitReviewRename(review.id); + if (e.key === "Escape") + setRenamingReviewId(null); + }} + onBlur={() => + void submitReviewRename(review.id) + } + onClick={(e) => e.stopPropagation()} + className="min-w-0 flex-1 text-sm text-gray-800 bg-transparent outline-none" + /> + ) : ( + <span className="min-w-0 flex-1 truncate text-sm text-gray-800"> + {review.title ?? "Untitled Review"} + </span> + )} + </div> </div> <div className="ml-auto w-24 shrink-0 text-sm text-gray-500 truncate"> {review.columns_config?.length ?? 0} diff --git a/frontend/src/app/components/projects/ProjectsOverview.tsx b/frontend/src/app/components/projects/ProjectsOverview.tsx index 55497ce..b1c6194 100644 --- a/frontend/src/app/components/projects/ProjectsOverview.tsx +++ b/frontend/src/app/components/projects/ProjectsOverview.tsx @@ -2,15 +2,15 @@ import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; -import { Plus, FolderOpen, ChevronDown } from "lucide-react"; -import { HeaderSearchBtn } from "@/app/components/shared/HeaderSearchBtn"; +import { FolderOpen, ChevronDown } from "lucide-react"; import { listProjects, updateProject, deleteProject } from "@/app/lib/mikeApi"; import { OwnerOnlyModal } from "@/app/components/shared/OwnerOnlyModal"; import { useAuth } from "@/contexts/AuthContext"; -import type { MikeProject } from "@/app/components/shared/types"; +import type { Project } from "@/app/components/shared/types"; import { NewProjectModal } from "./NewProjectModal"; import { ToolbarTabs } from "@/app/components/shared/ToolbarTabs"; import { RowActions } from "@/app/components/shared/RowActions"; +import { PageHeader } from "@/app/components/shared/PageHeader"; function formatDate(iso: string) { return new Date(iso).toLocaleDateString(undefined, { @@ -22,11 +22,10 @@ function formatDate(iso: string) { type Tab = "all" | "mine" | "shared-with-me"; -const CHECK_W = "w-8 shrink-0"; -const NAME_COL_W = "w-[300px] shrink-0"; +const NAME_COL_W = "w-[332px] shrink-0"; export function ProjectsOverview() { - const [projects, setProjects] = useState<MikeProject[]>([]); + const [projects, setProjects] = useState<Project[]>([]); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState<string | null>(null); const [modalOpen, setModalOpen] = useState(false); @@ -42,6 +41,7 @@ export function ProjectsOverview() { const actionsRef = useRef<HTMLDivElement>(null); const router = useRouter(); const { user, isAuthenticated, authLoading } = useAuth(); + const stickyCellBg = "bg-[#fcfcfd]"; useEffect(() => { if (authLoading) { @@ -203,26 +203,27 @@ export function ProjectsOverview() { ); return ( - <div className="flex-1 overflow-y-auto bg-white"> + <div className="flex-1 overflow-y-auto"> {/* Page header */} - <div className="mb-1 flex items-center justify-between px-4 py-3 md:px-10"> + <PageHeader + actions={[ + { + type: "search", + value: search, + onChange: setSearch, + placeholder: "Search projects…", + }, + { + type: "new", + onClick: () => setModalOpen(true), + title: "New project", + }, + ]} + > <h1 className="text-2xl font-medium font-serif text-gray-900"> Projects </h1> - <div className="flex items-center gap-2"> - <HeaderSearchBtn - value={search} - onChange={setSearch} - placeholder="Search projects…" - /> - <button - onClick={() => setModalOpen(true)} - className="flex items-center justify-center p-1.5 text-gray-500 hover:text-gray-900 transition-colors" - > - <Plus className="h-4 w-4" /> - </button> - </div> - </div> + </PageHeader> <ToolbarTabs tabs={tabs} @@ -236,8 +237,10 @@ export function ProjectsOverview() { <div className="min-w-max"> {/* Column headers */} <div className="flex items-center h-8 pr-3 md:pr-10 border-b border-gray-200 text-xs text-gray-500 font-medium select-none"> - <div className={`sticky left-0 z-[60] ${CHECK_W} relative bg-white flex items-center justify-center self-stretch before:absolute before:inset-x-0 before:bottom-0 before:h-px before:bg-white`}> - {!loading && ( + <div className={`sticky left-0 z-[60] ${NAME_COL_W} ${stickyCellBg} flex items-center gap-4 self-stretch pl-4 pr-2 text-left`}> + {loading ? ( + <div className="h-2.5 w-2.5 shrink-0 rounded bg-gray-100 animate-pulse" /> + ) : ( <input type="checkbox" checked={allSelected} @@ -248,9 +251,7 @@ export function ProjectsOverview() { className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black" /> )} - </div> - <div className={`sticky left-8 z-[60] ${NAME_COL_W} bg-white pl-2 text-left`}> - Name + <span>Name</span> </div> <div className="ml-auto w-32 shrink-0 text-left">CM</div> <div className="w-24 shrink-0 text-left">Files</div> @@ -269,8 +270,8 @@ export function ProjectsOverview() { key={i} className="flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50" > - <div className="w-8 shrink-0" /> - <div className="flex-1 min-w-0 pl-3 pr-4"> + <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> <div className="w-32 shrink-0"> @@ -333,7 +334,7 @@ export function ProjectsOverview() { {filtered.map((project) => { const rowBg = selectedIds.includes(project.id) ? "bg-gray-50" - : "bg-white"; + : stickyCellBg; return ( <div key={project.id} @@ -341,50 +342,47 @@ export function ProjectsOverview() { if (renamingId === project.id) return; router.push(`/projects/${project.id}`); }} - className="group flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors" + className="group flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50 hover:bg-gray-100 cursor-pointer transition-colors" > - <div - className={`sticky left-0 z-[60] ${CHECK_W} p-2 flex items-center justify-center ${rowBg} group-hover:bg-gray-50`} - onClick={(e) => e.stopPropagation()} - > - <input - type="checkbox" - checked={selectedIds.includes( - project.id, - )} - onChange={() => toggleOne(project.id)} - className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black" - /> - </div> - {/* Project Name */} - <div className={`sticky left-8 z-[60] ${NAME_COL_W} bg-white p-2 group-hover:bg-gray-50`}> - {renamingId === project.id ? ( + <div className={`sticky left-0 z-[60] ${NAME_COL_W} ${rowBg} py-2 pl-4 pr-2 transition-colors group-hover:bg-gray-100`}> + <div className="flex min-w-0 items-center gap-4"> <input - autoFocus - value={renameValue} - onChange={(e) => - setRenameValue(e.target.value) - } - onKeyDown={(e) => { - if (e.key === "Enter") - handleRenameSubmit( - project.id, - ); - if (e.key === "Escape") - setRenamingId(null); - }} - onBlur={() => - handleRenameSubmit(project.id) - } + type="checkbox" + checked={selectedIds.includes( + project.id, + )} + onChange={() => toggleOne(project.id)} onClick={(e) => e.stopPropagation()} - className="w-full text-sm text-gray-800 bg-transparent outline-none" + className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-pointer accent-black" /> - ) : ( - <span className="text-sm text-gray-800 truncate block"> - {project.name} - </span> - )} + {renamingId === project.id ? ( + <input + autoFocus + value={renameValue} + onChange={(e) => + setRenameValue(e.target.value) + } + onKeyDown={(e) => { + if (e.key === "Enter") + handleRenameSubmit( + project.id, + ); + if (e.key === "Escape") + setRenamingId(null); + }} + onBlur={() => + handleRenameSubmit(project.id) + } + onClick={(e) => e.stopPropagation()} + className="min-w-0 flex-1 text-sm text-gray-800 bg-transparent outline-none" + /> + ) : ( + <span className="min-w-0 flex-1 truncate text-sm text-gray-800"> + {project.name} + </span> + )} + </div> </div> <div diff --git a/frontend/src/app/components/shared/AddDocumentsModal.tsx b/frontend/src/app/components/shared/AddDocumentsModal.tsx index 674e13c..b87caac 100644 --- a/frontend/src/app/components/shared/AddDocumentsModal.tsx +++ b/frontend/src/app/components/shared/AddDocumentsModal.tsx @@ -1,26 +1,31 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { createPortal } from "react-dom"; -import { X, Upload, Search, Loader2 } from "lucide-react"; +import { AlertCircle, Upload, Search, Loader2, X } from "lucide-react"; import { uploadStandaloneDocument, uploadProjectDocument, addDocumentToProject, deleteDocument, } from "@/app/lib/mikeApi"; -import type { MikeDocument } from "./types"; +import type { Document } from "./types"; import { FileDirectory } from "./FileDirectory"; import { useDirectoryData, invalidateDirectoryCache } from "./useDirectoryData"; import { OwnerOnlyModal } from "./OwnerOnlyModal"; import { useAuth } from "@/contexts/AuthContext"; +import { Modal } from "./Modal"; +import { + SUPPORTED_DOCUMENT_ACCEPT, + formatUnsupportedDocumentWarning, + partitionSupportedDocumentFiles, +} from "@/app/lib/documentUploadValidation"; export { invalidateDirectoryCache }; interface Props { open: boolean; onClose: () => void; - onSelect: (documents: MikeDocument[], projectId?: string) => void; + onSelect: (documents: Document[], projectId?: string) => void; breadcrumb: string[]; allowMultiple?: boolean; projectId?: string; @@ -39,8 +44,9 @@ export function AddDocumentsModal({ const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()); const [uploading, setUploading] = useState(false); const [uploadingFilenames, setUploadingFilenames] = useState<string[]>([]); + const [uploadWarning, setUploadWarning] = useState<string | null>(null); const [search, setSearch] = useState(""); - const [extraUploadedDocs, setExtraUploadedDocs] = useState<MikeDocument[]>([]); + const [extraUploadedDocs, setExtraUploadedDocs] = useState<Document[]>([]); // IDs deleted in this session — hidden locally since `useDirectoryData`'s // cached state won't re-fetch until the modal reopens. const [deletedIds, setDeletedIds] = useState<Set<string>>(new Set()); @@ -54,6 +60,7 @@ export function AddDocumentsModal({ setExtraUploadedDocs([]); setDeletedIds(new Set()); setUploadingFilenames([]); + setUploadWarning(null); }, [open]); if (!open) return null; @@ -68,7 +75,9 @@ export function AddDocumentsModal({ ].filter((d) => !deletedIds.has(d.id)); const filteredStandalone = q - ? allStandalone.filter((d) => d.filename.toLowerCase().includes(q)) + ? allStandalone.filter((d) => + d.filename.toLowerCase().includes(q), + ) : allStandalone; const filteredProjects = projects @@ -78,7 +87,8 @@ export function AddDocumentsModal({ documents: (p.documents || []).filter( (d) => !deletedIds.has(d.id) && - (!q || d.filename.toLowerCase().includes(q)), + (!q || + d.filename.toLowerCase().includes(q)), ), })) .filter( @@ -134,7 +144,7 @@ export function AddDocumentsModal({ async function handleDelete(ids: string[]) { // Server only allows the doc creator to delete. Filter to owned // and warn for the rest. - const docsById = new Map<string, MikeDocument>(); + const docsById = new Map<string, Document>(); for (const d of [ ...standaloneDocuments, ...extraUploadedDocs, @@ -177,11 +187,17 @@ export function AddDocumentsModal({ async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) { const files = Array.from(e.target.files || []); if (!files.length) return; - setUploadingFilenames(files.map((file) => file.name)); + const { supported, unsupported } = partitionSupportedDocumentFiles(files); + setUploadWarning(formatUnsupportedDocumentWarning(unsupported)); + if (supported.length === 0) { + if (fileInputRef.current) fileInputRef.current.value = ""; + return; + } + setUploadingFilenames(supported.map((file) => file.name)); setUploading(true); try { const uploaded = await Promise.all( - files.map((f) => + supported.map((f) => projectId ? uploadProjectDocument(projectId, f) : uploadStandaloneDocument(f), @@ -201,29 +217,45 @@ export function AddDocumentsModal({ } } - return createPortal( - <div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/10 backdrop-blur-xs"> - <div className="w-full max-w-2xl rounded-2xl bg-white shadow-2xl flex flex-col h-[600px]"> - {/* Header */} - <div className="flex items-center justify-between px-5 py-4"> - <div className="flex items-center gap-1.5 text-xs text-gray-400"> - {breadcrumb.map((segment, i) => ( - <span key={i} className="flex items-center gap-1.5"> - {i > 0 && <span>›</span>} - {segment} - </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={open} + onClose={onClose} + breadcrumbs={breadcrumb} + secondaryAction={{ + label: uploading ? "Uploading…" : "Upload", + icon: uploading ? ( + <Loader2 className="h-3.5 w-3.5 animate-spin" /> + ) : ( + <Upload className="h-3.5 w-3.5" /> + ), + onClick: () => fileInputRef.current?.click(), + disabled: uploading, + }} + footerStatus={ + selectedIds.size > 0 ? ( + <span className="text-xs text-gray-400"> + {selectedIds.size} selected + </span> + ) : null + } + primaryAction={{ + label: uploading ? "Saving…" : "Confirm", + onClick: handleConfirm, + disabled: selectedIds.size === 0 || uploading, + }} + > + <input + ref={fileInputRef} + type="file" + accept={SUPPORTED_DOCUMENT_ACCEPT} + multiple + className="hidden" + onChange={handleUpload} + /> {/* Search bar */} - <div className="px-4 pt-1 pb-2"> + <div className="pt-1 pb-2"> <div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2"> <Search className="h-3.5 w-3.5 text-gray-400 shrink-0" /> <input @@ -245,76 +277,40 @@ export function AddDocumentsModal({ </div> </div> - {/* File browser */} - <div className="flex-1 overflow-y-auto px-4 pb-2"> - <FileDirectory - standaloneDocs={filteredStandalone} - directoryProjects={filteredProjects} - loading={loading} - selectedIds={selectedIds} - onChange={setSelectedIds} - allowMultiple={allowMultiple} - forceExpanded={!!q} - emptyMessage={ - q ? "No matches found" : "No documents yet" - } - onDelete={handleDelete} - uploadingFilenames={uploadingFilenames} - /> - </div> + {uploadWarning && ( + <div className="mb-2 flex items-center gap-2 rounded-lg border border-red-100 bg-red-50 px-3 py-2 text-xs text-gray-900"> + <AlertCircle className="h-3.5 w-3.5 shrink-0 text-red-600" /> + <span className="min-w-0 flex-1">{uploadWarning}</span> + <button + type="button" + onClick={() => setUploadWarning(null)} + className="shrink-0 rounded p-0.5 text-black hover:bg-gray-100" + aria-label="Dismiss warning" + > + <X className="h-3.5 w-3.5" /> + </button> + </div> + )} - {/* Footer */} - <div className="border-t border-gray-100 px-4 py-3 flex items-center justify-between gap-3"> - <div> - <input - ref={fileInputRef} - type="file" - accept=".pdf,.docx,.doc" - multiple - className="hidden" - onChange={handleUpload} - /> - <button - onClick={() => fileInputRef.current?.click()} - disabled={uploading} - className="flex items-center gap-1.5 rounded-lg border border-gray-200 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50" - > - {uploading ? ( - <Loader2 className="h-3.5 w-3.5 animate-spin" /> - ) : ( - <Upload className="h-3.5 w-3.5" /> - )} - {uploading ? "Uploading…" : "Upload"} - </button> - </div> - <div className="flex items-center gap-2"> - {selectedIds.size > 0 && ( - <span className="text-xs text-gray-400"> - {selectedIds.size} selected - </span> - )} - <button - onClick={onClose} - className="rounded-lg px-3 py-1.5 text-sm text-gray-500 hover:bg-gray-100" - > - Cancel - </button> - <button - onClick={handleConfirm} - disabled={selectedIds.size === 0 || uploading} - className="rounded-lg bg-gray-900 px-4 py-1.5 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-40" - > - {uploading ? "Saving…" : "Confirm"} - </button> - </div> - </div> - </div> + {/* File browser */} + <FileDirectory + standaloneDocs={filteredStandalone} + directoryProjects={filteredProjects} + loading={loading} + selectedIds={selectedIds} + onChange={setSelectedIds} + allowMultiple={allowMultiple} + forceExpanded={!!q} + emptyMessage={q ? "No matches found" : "No documents yet"} + onDelete={handleDelete} + uploadingFilenames={uploadingFilenames} + /> + </Modal> <OwnerOnlyModal open={!!ownerOnlyAction} action={ownerOnlyAction ?? undefined} onClose={() => setOwnerOnlyAction(null)} /> - </div>, - document.body, + </> ); } diff --git a/frontend/src/app/components/shared/AddProjectDocsModal.tsx b/frontend/src/app/components/shared/AddProjectDocsModal.tsx index 7b6c57b..88fe3b6 100644 --- a/frontend/src/app/components/shared/AddProjectDocsModal.tsx +++ b/frontend/src/app/components/shared/AddProjectDocsModal.tsx @@ -1,17 +1,17 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { createPortal } from "react-dom"; import { Check, Loader2, Search, Upload, X } from "lucide-react"; import { getProject, uploadProjectDocument } from "@/app/lib/mikeApi"; -import type { MikeDocument } from "./types"; +import type { Document } from "./types"; import { DocFileIcon } from "./FileDirectory"; import { VersionChip } from "./VersionChip"; +import { Modal } from "./Modal"; interface Props { open: boolean; onClose: () => void; - onSelect: (documents: MikeDocument[]) => void; + onSelect: (documents: Document[]) => void; breadcrumb: string[]; projectId: string; /** Docs already in the target list — rendered checked + disabled. */ @@ -37,7 +37,7 @@ export function AddProjectDocsModal({ excludeDocIds, allowMultiple = true, }: Props) { - const [docs, setDocs] = useState<MikeDocument[]>([]); + const [docs, setDocs] = useState<Document[]>([]); const [loading, setLoading] = useState(false); const [search, setSearch] = useState(""); const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()); @@ -115,185 +115,147 @@ export function AddProjectDocsModal({ } } - return createPortal( - <div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/10 backdrop-blur-xs"> - <div className="w-full max-w-2xl rounded-2xl bg-white shadow-2xl flex flex-col h-[600px]"> - {/* Header */} - <div className="flex items-center justify-between px-5 py-4"> - <div className="flex items-center gap-1.5 text-xs text-gray-400"> - {breadcrumb.map((segment, i) => ( - <span - key={i} - className="flex items-center gap-1.5" - > - {i > 0 && <span>›</span>} - {segment} - </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> - - {/* Search */} - <div className="px-4 pt-1 pb-2"> - <div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2"> - <Search className="h-3.5 w-3.5 text-gray-400 shrink-0" /> - <input - type="text" - placeholder="Search…" - value={search} - onChange={(e) => setSearch(e.target.value)} - className="flex-1 bg-transparent text-sm text-gray-700 placeholder:text-gray-400 outline-none" - autoFocus - /> - {search && ( - <button - onClick={() => setSearch("")} - className="text-gray-400 hover:text-gray-600" - > - <X className="h-3.5 w-3.5" /> - </button> - )} - </div> - </div> - - {/* File list */} - <div className="flex-1 overflow-y-auto px-4 pb-2"> - {loading ? ( - <div className="rounded-sm border border-gray-100 overflow-hidden"> - {[60, 45, 75, 55, 40].map((w, i) => ( - <div - key={i} - className="flex items-center gap-2 px-2 py-2" - > - <div className="h-3.5 w-3.5 rounded border border-gray-200 shrink-0" /> - <div className="h-3.5 w-3.5 rounded bg-gray-200 animate-pulse shrink-0" /> - <div - className="h-3 rounded bg-gray-200 animate-pulse" - style={{ width: `${w}%` }} - /> - </div> - ))} - </div> - ) : filtered.length === 0 ? ( - <p className="text-center text-sm text-gray-400 py-8"> - {q ? "No matches found" : "No documents in this project"} - </p> - ) : ( - <div className="rounded-sm border border-gray-100 overflow-hidden"> - {filtered.map((doc) => { - const excluded = isExcluded(doc.id); - const checked = - excluded || selectedIds.has(doc.id); - return ( - <button - type="button" - key={doc.id} - disabled={excluded} - onClick={() => toggle(doc.id)} - className={`w-full flex items-center gap-2 px-2 py-2 text-xs text-left transition-colors ${ - excluded - ? "opacity-50 cursor-not-allowed" - : checked - ? "bg-gray-100" - : "hover:bg-gray-50" - }`} - > - <span - className={`shrink-0 h-3.5 w-3.5 rounded border flex items-center justify-center ${ - checked - ? "bg-gray-900 border-gray-900" - : "border-gray-300" - }`} - > - {checked && ( - <Check className="h-2.5 w-2.5 text-white" /> - )} - </span> - <DocFileIcon - fileType={doc.file_type} - /> - <span - className={`flex-1 truncate ${ - checked - ? "text-gray-900" - : "text-gray-700" - }`} - > - {doc.filename} - </span> - {excluded && ( - <span className="text-[10px] text-gray-400 shrink-0"> - Already added - </span> - )} - <VersionChip - n={doc.latest_version_number} - /> - {doc.created_at && ( - <span className="shrink-0 text-gray-300"> - {formatDate(doc.created_at)} - </span> - )} - </button> - ); - })} - </div> + return ( + <Modal + open={open} + onClose={onClose} + breadcrumbs={breadcrumb} + secondaryAction={{ + label: uploading ? "Uploading…" : "Upload", + icon: uploading ? ( + <Loader2 className="h-3.5 w-3.5 animate-spin" /> + ) : ( + <Upload className="h-3.5 w-3.5" /> + ), + onClick: () => fileInputRef.current?.click(), + disabled: uploading, + }} + footerStatus={ + selectedIds.size > 0 ? ( + <span className="text-xs text-gray-400"> + {selectedIds.size} selected + </span> + ) : null + } + primaryAction={{ + label: "Confirm", + onClick: handleConfirm, + disabled: selectedIds.size === 0 || uploading, + }} + > + <input + ref={fileInputRef} + type="file" + accept=".pdf,.docx,.doc" + multiple + className="hidden" + onChange={handleUpload} + /> + {/* Search */} + <div className="pt-1 pb-2"> + <div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2"> + <Search className="h-3.5 w-3.5 text-gray-400 shrink-0" /> + <input + type="text" + placeholder="Search…" + value={search} + onChange={(e) => setSearch(e.target.value)} + className="flex-1 bg-transparent text-sm text-gray-700 placeholder:text-gray-400 outline-none" + autoFocus + /> + {search && ( + <button + onClick={() => setSearch("")} + className="text-gray-400 hover:text-gray-600" + > + <X className="h-3.5 w-3.5" /> + </button> )} </div> - - {/* Footer */} - <div className="border-t border-gray-100 px-4 py-3 flex items-center justify-between gap-3"> - <div> - <input - ref={fileInputRef} - type="file" - accept=".pdf,.docx,.doc" - multiple - className="hidden" - onChange={handleUpload} - /> - <button - onClick={() => fileInputRef.current?.click()} - disabled={uploading} - className="flex items-center gap-1.5 rounded-lg border border-gray-200 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50" - > - {uploading ? ( - <Loader2 className="h-3.5 w-3.5 animate-spin" /> - ) : ( - <Upload className="h-3.5 w-3.5" /> - )} - {uploading ? "Uploading…" : "Upload"} - </button> - </div> - <div className="flex items-center gap-2"> - {selectedIds.size > 0 && ( - <span className="text-xs text-gray-400"> - {selectedIds.size} selected - </span> - )} - <button - onClick={onClose} - className="rounded-lg px-3 py-1.5 text-sm text-gray-500 hover:bg-gray-100" - > - Cancel - </button> - <button - onClick={handleConfirm} - disabled={selectedIds.size === 0 || uploading} - className="rounded-lg bg-gray-900 px-4 py-1.5 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-40" - > - Confirm - </button> - </div> - </div> </div> - </div>, - document.body, + + {/* File list */} + {loading ? ( + <div className="rounded-sm border border-gray-100 overflow-hidden"> + {[60, 45, 75, 55, 40].map((w, i) => ( + <div + key={i} + className="flex items-center gap-2 px-2 py-2" + > + <div className="h-3.5 w-3.5 rounded border border-gray-200 shrink-0" /> + <div className="h-3.5 w-3.5 rounded bg-gray-200 animate-pulse shrink-0" /> + <div + className="h-3 rounded bg-gray-200 animate-pulse" + style={{ width: `${w}%` }} + /> + </div> + ))} + </div> + ) : filtered.length === 0 ? ( + <p className="text-center text-sm text-gray-400 py-8"> + {q ? "No matches found" : "No documents in this project"} + </p> + ) : ( + <div className="rounded-sm border border-gray-100 overflow-hidden"> + {filtered.map((doc) => { + const excluded = isExcluded(doc.id); + const checked = excluded || selectedIds.has(doc.id); + return ( + <button + type="button" + key={doc.id} + disabled={excluded} + onClick={() => toggle(doc.id)} + className={`w-full flex items-center gap-2 px-2 py-2 text-xs text-left transition-colors ${ + excluded + ? "opacity-50 cursor-not-allowed" + : checked + ? "bg-gray-100" + : "hover:bg-gray-50" + }`} + > + <span + className={`shrink-0 h-3.5 w-3.5 rounded border flex items-center justify-center ${ + checked + ? "bg-gray-900 border-gray-900" + : "border-gray-300" + }`} + > + {checked && ( + <Check className="h-2.5 w-2.5 text-white" /> + )} + </span> + <DocFileIcon fileType={doc.file_type} /> + <span + className={`flex-1 truncate ${ + checked + ? "text-gray-900" + : "text-gray-700" + }`} + > + {doc.filename} + </span> + {excluded && ( + <span className="text-[10px] text-gray-400 shrink-0"> + Already added + </span> + )} + <VersionChip + n={ + doc.active_version_number ?? + doc.latest_version_number + } + /> + {doc.created_at && ( + <span className="shrink-0 text-gray-300"> + {formatDate(doc.created_at)} + </span> + )} + </button> + ); + })} + </div> + )} + </Modal> ); } diff --git a/frontend/src/app/components/shared/ApiKeyMissingModal.tsx b/frontend/src/app/components/shared/ApiKeyMissingModal.tsx index 3d4f101..d5f4676 100644 --- a/frontend/src/app/components/shared/ApiKeyMissingModal.tsx +++ b/frontend/src/app/components/shared/ApiKeyMissingModal.tsx @@ -1,9 +1,9 @@ "use client"; -import { createPortal } from "react-dom"; import { useRouter } from "next/navigation"; -import { AlertTriangle, X } from "lucide-react"; +import { AlertTriangle } from "lucide-react"; import { providerLabel, type ModelProvider } from "@/app/lib/modelAvailability"; +import { WarningPopup } from "./WarningPopup"; interface Props { open: boolean; @@ -27,52 +27,19 @@ export function ApiKeyMissingModal({ open, onClose, provider, message }: Props) router.push("/account/models"); }; - return createPortal( - <div - className="fixed inset-0 z-[200] flex items-center justify-center bg-black/10 backdrop-blur-xs" - onClick={onClose} - > - <div - className="w-full max-w-md rounded-2xl bg-white shadow-2xl flex flex-col" - onClick={(e) => e.stopPropagation()} - > - <div className="flex items-start justify-between gap-3 px-5 pt-5 pb-2"> - <div className="flex items-center gap-2"> - <AlertTriangle className="h-4 w-4 text-amber-600" /> - <h2 className="text-base font-medium text-gray-900"> - API key required - </h2> - </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> - - <div className="px-5 pb-2 pt-1"> - <p className="text-sm text-gray-600 leading-relaxed"> - {body} - </p> - </div> - - <div className="flex justify-end gap-2 px-5 pb-5 pt-3"> - <button - onClick={onClose} - className="rounded-lg px-4 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-100" - > - Cancel - </button> - <button - onClick={handleGoToAccount} - className="rounded-lg bg-gray-900 px-4 py-1.5 text-sm font-medium text-white hover:bg-gray-700" - > - Go to account settings - </button> - </div> - </div> - </div>, - document.body, + return ( + <WarningPopup + open={open} + onClose={onClose} + title="API key required" + message={body} + icon={ + <AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-red-600" /> + } + primaryAction={{ + label: "Go to account settings", + onClick: handleGoToAccount, + }} + /> ); } diff --git a/frontend/src/app/components/shared/AppSidebar.tsx b/frontend/src/app/components/shared/AppSidebar.tsx index 36a0269..60ae958 100644 --- a/frontend/src/app/components/shared/AppSidebar.tsx +++ b/frontend/src/app/components/shared/AppSidebar.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import { PanelLeft, MessageSquare, @@ -19,7 +19,8 @@ import Link from "next/link"; import { MikeIcon } from "@/components/chat/mike-icon"; import { SidebarChatItem } from "@/app/components/shared/SidebarChatItem"; import { listProjects } from "@/app/lib/mikeApi"; -import type { MikeProject } from "@/app/components/shared/types"; +import type { Project } from "@/app/components/shared/types"; +import { cn } from "@/lib/utils"; const NAV_ITEMS = [ { href: "/assistant", label: "Assistant", icon: MessageSquare }, @@ -36,15 +37,20 @@ interface AppSidebarProps { export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) { const { user } = useAuth(); const { profile } = useUserProfile(); - const { - chats, - currentChatId, - hasMoreChats, - loadMoreChats, - setCurrentChatId, - } = useChatHistoryContext(); + const { chats, hasMoreChats, loadMoreChats, setCurrentChatId } = + useChatHistoryContext(); const router = useRouter(); const pathname = usePathname(); + const routeChatId = useMemo(() => { + if (pathname.startsWith("/assistant/chat/")) { + return pathname.split("/").pop() ?? null; + } + + const projectChatMatch = pathname.match( + /^\/projects\/[^/]+\/assistant\/chat\/([^/]+)/, + ); + return projectChatMatch?.[1] ?? null; + }, [pathname]); const [shouldAnimate, setShouldAnimate] = useState(false); const [isDropdownOpen, setIsDropdownOpen] = useState(false); const [projectsCollapsed, setProjectsCollapsed] = useState(false); @@ -52,7 +58,7 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) { const [projectNames, setProjectNames] = useState<Record<string, string>>( {}, ); - const [recentProjects, setRecentProjects] = useState<MikeProject[] | null>( + const [recentProjects, setRecentProjects] = useState<Project[] | null>( null, ); @@ -93,24 +99,8 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) { }, [isDropdownOpen]); useEffect(() => { - if (pathname.startsWith("/assistant/chat/")) { - const chatId = pathname.split("/").pop() ?? null; - setCurrentChatId(chatId); - return; - } - - const projectChatMatch = pathname.match( - /^\/projects\/[^/]+\/assistant\/chat\/([^/]+)/, - ); - if (projectChatMatch) { - setCurrentChatId(projectChatMatch[1]); - return; - } - - if (pathname === "/assistant") { - setCurrentChatId(null); - } - }, [pathname, setCurrentChatId]); + setCurrentChatId(routeChatId); + }, [routeChatId, setCurrentChatId]); const getUserInitials = (email: string) => { if (profile?.displayName) @@ -132,11 +122,13 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) { return ( <div - className={`${ + className={cn( isOpen - ? "w-64 h-dvh bg-gray-50 border-r" - : "w-14 md:h-dvh md:bg-gray-50 md:border-r h-auto bg-transparent pointer-events-none md:pointer-events-auto" - } border-gray-200 flex flex-col transition-all duration-300 absolute md:relative z-[99] overflow-visible`} + ? "w-64 h-[calc(100dvh-1rem)] md:h-[calc(100dvh-1.5rem)] bg-white/65" + : "max-md:hidden w-14 md:h-[calc(100dvh-1.5rem)] md:bg-white/65 h-auto bg-transparent pointer-events-none md:pointer-events-auto", + "my-2 ml-2 mr-0 md:my-3 md:ml-3 md:mr-0 rounded-2xl border border-white/70 shadow-[0_-2px_7px_rgba(15,23,42,0.044),0_5px_12px_rgba(15,23,42,0.095),inset_0_1px_0_rgba(255,255,255,0.85)] backdrop-blur-2xl overflow-visible", + "flex flex-col transition-all duration-300 absolute md:relative z-[99]", + )} > {/* Toggle + Logo */} <div @@ -145,7 +137,7 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) { }`} > {isOpen && ( - <div className="px-2.5"> + <div className="px-2"> <Link href="/assistant" className="flex items-center gap-1.5 hover:opacity-80 transition-opacity" @@ -163,7 +155,10 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) { )} <button onClick={onToggle} - className="h-9 w-9 p-2.5 items-center flex hover:bg-gray-100 rounded-md transition-colors" + className={cn( + "h-9 w-9 p-2.5 items-center flex transition-colors", + "rounded-xl hover:bg-gray-100", + )} title={isOpen ? "Close sidebar" : "Open sidebar"} > <PanelLeft className="h-4 w-4" /> @@ -173,17 +168,24 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) { {/* Nav items */} {NAV_ITEMS.map(({ href, label, icon: Icon }) => { const isActive = - pathname === href || pathname.startsWith(href + "/"); + href === "/assistant" + ? pathname === href + : href === "/projects" + ? pathname === href + : pathname === href || + pathname.startsWith(href + "/"); return ( <div key={href} className="py-0.5 px-2.5"> <button onClick={() => router.push(href)} title={!isOpen ? label : ""} - className={`w-full h-9 flex items-center gap-3 px-2.5 py-2 rounded-md transition-colors text-left ${ + className={cn( + "w-full h-9 flex items-center gap-3 px-2.5 py-2 rounded-md transition-colors text-left", isActive - ? "bg-gray-100 text-gray-900" - : "hover:bg-gray-100 text-gray-700" - } ${!isOpen ? "hidden md:flex" : "flex"}`} + ? "bg-gray-200/60 text-gray-900" + : "text-gray-700 hover:bg-gray-100", + !isOpen ? "hidden md:flex" : "flex", + )} > <Icon className={`h-4 w-4 flex-shrink-0 ${ @@ -271,11 +273,12 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) { ) } title={project.name} - className={`flex h-9 w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-xs transition-colors ${ + className={cn( + "flex h-9 w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-xs transition-colors", isActive - ? "bg-gray-100 text-gray-900" - : "text-gray-700 hover:bg-gray-100" - }`} + ? "bg-gray-200/60 text-gray-900" + : "text-gray-700 hover:bg-gray-100", + )} > <FolderOpen className="h-3.5 w-3.5 shrink-0 text-gray-500" /> <span className="min-w-0 flex-1 truncate"> @@ -346,7 +349,7 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) { key={chat.id} chat={chat} isActive={ - currentChatId === chat.id + routeChatId === chat.id } projectName={ chat.project_id @@ -370,7 +373,10 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) { <div className="px-2.5 pt-1"> <button onClick={loadMoreChats} - className="flex h-8 w-full items-center justify-start rounded-md px-3 text-left text-xs font-medium text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700" + className={cn( + "flex h-8 w-full items-center justify-start rounded-md px-3 text-left text-xs font-medium text-gray-500 transition-colors hover:text-gray-700", + "hover:bg-gray-100", + )} > Load more </button> @@ -384,21 +390,22 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) { )} {/* User Profile */} - <div className="mt-auto"> + <div className="mt-auto p-1"> {user && ( <div className="relative"> <button onClick={() => setIsDropdownOpen(!isDropdownOpen)} - className={`flex items-center transition-colors w-full px-3.5 py-4 border-t border-gray-200 ${ - !isOpen ? "hidden md:flex" : "" - } ${ + className={cn( + "flex items-center transition-colors w-full px-2.5 py-3 border-t", + "rounded-xl border-white/60", + !isOpen ? "hidden md:flex" : "", pathname === "/account" || isDropdownOpen - ? "bg-gray-100" - : "hover:bg-gray-100" - }`} + ? "bg-gray-200/60" + : "hover:bg-gray-100", + )} title={!isOpen ? user.email : undefined} > - <div className="h-7 w-7 flex-shrink-0 rounded-full bg-gray-700 flex items-center justify-center text-white text-sm font-medium font-serif"> + <div className="h-6.5 w-6.5 flex-shrink-0 rounded-full bg-gray-700 flex items-center justify-center text-white text-sm font-medium font-serif"> {getUserInitials(user.email)} </div> {isOpen && ( @@ -421,13 +428,21 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) { </button> {isDropdownOpen && ( - <div className="absolute bottom-full left-0 m-1 bg-white rounded-lg shadow-lg border border-gray-200 p-1 z-50 w-62 whitespace-nowrap"> + <div + className={cn( + "absolute bottom-full left-0 right-0 z-50 mb-1 p-1 whitespace-nowrap", + "bg-white/80 rounded-xl shadow-[0_6px_17px_rgba(15,23,42,0.1)] border border-white/70 backdrop-blur-xl", + )} + > <button onClick={() => { router.push("/account"); setIsDropdownOpen(false); }} - className="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 flex items-center gap-2 rounded-md" + className={cn( + "w-full px-4 py-2 text-left text-sm text-gray-700 flex items-center gap-2 rounded-md", + "hover:bg-white/70", + )} > <User className="h-4 w-4" /> Account Settings diff --git a/frontend/src/app/components/shared/ConfirmPopup.tsx b/frontend/src/app/components/shared/ConfirmPopup.tsx new file mode 100644 index 0000000..ca26f17 --- /dev/null +++ b/frontend/src/app/components/shared/ConfirmPopup.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { createPortal } from "react-dom"; +import type { ReactNode } from "react"; +import { Loader2 } from "lucide-react"; +import { cn } from "@/lib/utils"; + +type ConfirmStatus = "idle" | "loading" | "complete"; + +interface ConfirmPopupProps { + open: boolean; + title?: ReactNode; + message?: ReactNode; + confirmLabel?: ReactNode; + confirmStatus?: ConfirmStatus; + cancelLabel?: ReactNode; + onConfirm: () => void; + onCancel: () => void; + confirmDisabled?: boolean; + className?: string; +} + +export function ConfirmPopup({ + open, + title, + message, + confirmLabel = "Confirm", + confirmStatus = "idle", + cancelLabel = "Cancel", + onConfirm, + onCancel, + confirmDisabled = false, + className, +}: ConfirmPopupProps) { + if (!open) return null; + const confirmBusy = confirmStatus === "loading"; + const resolvedConfirmDisabled = confirmDisabled || confirmStatus !== "idle"; + const normalizedConfirmLabel = + typeof confirmLabel === "string" ? confirmLabel : "Confirm"; + const resolvedConfirmLabel = + confirmStatus === "loading" ? ( + <span className="inline-flex items-center gap-1.5"> + <Loader2 className="h-3 w-3 animate-spin" /> + {progressiveLabel(normalizedConfirmLabel)} + </span> + ) : confirmStatus === "complete" ? ( + completedLabel(normalizedConfirmLabel) + ) : ( + confirmLabel + ); + + return createPortal( + <div className="pointer-events-none fixed inset-x-0 bottom-5 z-[230] flex justify-center px-4"> + <div + className={cn( + "pointer-events-auto w-[min(92vw,520px)] rounded-2xl border border-white/70 bg-white/58 px-4 py-3 text-sm shadow-[0_8px_24px_rgba(15,23,42,0.13),inset_0_1px_0_rgba(255,255,255,0.92),inset_0_-10px_24px_rgba(255,255,255,0.2)] backdrop-blur-2xl", + className, + )} + > + {title && ( + <div className="text-sm font-medium text-gray-950"> + {title} + </div> + )} + {message && ( + <div className={cn("text-xs text-gray-700", title && "mt-1")}> + {message} + </div> + )} + <div className="mt-3 flex items-center justify-end gap-2"> + <button + type="button" + onClick={onCancel} + className="rounded-full px-3 py-1.5 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-100" + > + {cancelLabel} + </button> + <button + type="button" + onClick={onConfirm} + disabled={resolvedConfirmDisabled} + className="rounded-full bg-gray-950 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-40" + aria-busy={confirmBusy} + > + {resolvedConfirmLabel} + </button> + </div> + </div> + </div>, + document.body, + ); +} + +function progressiveLabel(label: string) { + const lower = label.toLowerCase(); + if (lower.endsWith("e")) return `${label.slice(0, -1)}ing...`; + return `${label}ing...`; +} + +function completedLabel(label: string) { + const lower = label.toLowerCase(); + if (lower.endsWith("e")) return `${label}d`; + return `${label}ed`; +} diff --git a/frontend/src/app/components/shared/DocPanel.tsx b/frontend/src/app/components/shared/DocPanel.tsx index 049f19d..5a7f8ec 100644 --- a/frontend/src/app/components/shared/DocPanel.tsx +++ b/frontend/src/app/components/shared/DocPanel.tsx @@ -7,14 +7,19 @@ import { applyOptimisticResolution } from "../assistant/EditCard"; import { DocView } from "./DocView"; import { DocxView } from "./DocxView"; import { - displayCitationQuote, + RelevantQuotes, + type RelevantQuoteItem, +} from "./RelevantQuotes"; +import { expandCitationToEntries, formatCitationPage, + getDocumentCitationQuotes, } from "./types"; import type { CitationQuote, - MikeCitationAnnotation, - MikeEditAnnotation, + CitationAnnotation, + DocumentCitationAnnotation, + EditAnnotation, } from "./types"; function isDocxFilename(name: string): boolean { @@ -24,16 +29,16 @@ function isDocxFilename(name: string): boolean { /** * Discriminated-union describing what the panel is showing above the viewer. - * - "document": no header card, no label — just the viewer. - * - "citation": "Citation Quote" card with the quoted text and page ref. - * - "edit": "Tracked Change" card with the diff + Accept/Reject. + * - "document": title row + viewer. + * - "citation": title row + relevant quote + viewer. + * - "edit": title row + tracked change + viewer. */ export type DocPanelMode = | { kind: "document" } - | { kind: "citation"; citation: MikeCitationAnnotation } + | { kind: "citation"; citation: CitationAnnotation } | { kind: "edit"; - edit: MikeEditAnnotation; + edit: EditAnnotation; /** * True while an accept/reject request for this exact edit is in * flight. Scoped per-edit (not per-document) so sibling edits on @@ -98,11 +103,42 @@ export function DocPanel({ // re-fetch every time they toggle. Tracked-change rendering still // only lives in DocxView, which is fine because edits are DOCX-only. const useDocxView = isDocxFilename(filename); + const citationQuoteId = + mode.kind === "citation" ? `document:${mode.citation.ref}:0` : null; + const [activeCitationQuoteId, setActiveCitationQuoteId] = useState< + string | null + >(citationQuoteId); + const [quoteFocusKey, setQuoteFocusKey] = useState(0); const quotes: CitationQuote[] | undefined = useMemo(() => { if (mode.kind !== "citation") return undefined; - return expandCitationToEntries(mode.citation); - }, [mode]); + if (!activeCitationQuoteId) return []; + const selectedIndex = Number(activeCitationQuoteId.split(":").at(-1)); + if (!Number.isFinite(selectedIndex)) return []; + const selectedQuote = + getDocumentCitationQuotes(mode.citation)[selectedIndex]; + if (!selectedQuote) return []; + const documentCitation = mode.citation as DocumentCitationAnnotation; + return expandCitationToEntries({ + ...documentCitation, + page: selectedQuote.page, + quote: selectedQuote.quote, + quotes: [selectedQuote], + }); + }, [activeCitationQuoteId, citationQuoteId, mode]); + + useEffect(() => { + setActiveCitationQuoteId(citationQuoteId); + }, [citationQuoteId]); + + const handleCitationQuoteSelect = useCallback( + (quoteId: string) => { + const shouldSelect = activeCitationQuoteId !== quoteId; + setActiveCitationQuoteId(shouldSelect ? quoteId : null); + if (shouldSelect) setQuoteFocusKey((current) => current + 1); + }, + [activeCitationQuoteId], + ); const highlightEdit = useMemo(() => { if (mode.kind !== "edit") return null; @@ -116,64 +152,50 @@ export function DocPanel({ }, [mode]); return ( - <div className="flex h-full flex-col px-3 pb-3"> - {mode.kind === "citation" ? ( - <CitationHeader + <div className="flex h-full flex-col"> + <DocumentTitleRow + documentId={documentId} + filename={filename} + versionId={versionId} + versionNumber={versionNumber} + isReloading={isReloading} + /> + + {mode.kind === "citation" && ( + <RelevantQuoteSection citation={mode.citation} - documentId={documentId} - versionId={versionId} filename={filename} - isReloading={isReloading} + activeQuoteId={activeCitationQuoteId} + onQuoteSelect={handleCitationQuoteSelect} /> - ) : mode.kind === "edit" ? ( - <TrackedChangeHeader - mode={mode} - documentId={documentId} - versionId={versionId} - filename={filename} - isReloading={isReloading} - /> - ) : ( - <div className="flex items-center justify-end gap-2 py-2"> - <div className="mr-auto flex min-w-0 items-center gap-2"> - <span className="truncate text-sm text-gray-700"> - {filename} - </span> - {versionNumber && versionNumber > 0 && ( - <span className="shrink-0 inline-flex items-center rounded-md border border-gray-200 bg-white px-1.5 py-0.5 text-[10px] font-medium text-gray-600"> - V{versionNumber} - </span> - )} - </div> - <DownloadButton - documentId={documentId} - versionId={versionId} - filename={filename} - isReloading={isReloading} - /> - </div> )} - {useDocxView ? ( - <DocxView - documentId={documentId} - versionId={versionId ?? undefined} - quotes={quotes} - highlightEdit={highlightEdit} - warning={warning ?? null} - onWarningDismiss={onWarningDismiss} - initialScrollTop={initialScrollTop ?? null} - onScrollChange={onScrollChange} - /> - ) : ( - <DocView - doc={{ - document_id: documentId, - version_id: versionId, - }} - quotes={quotes} - /> - )} + {mode.kind === "edit" && <TrackedChangeHeader mode={mode} />} + + <div className="flex flex-1 min-h-0 flex-col px-3 py-3"> + {useDocxView ? ( + <DocxView + documentId={documentId} + versionId={versionId ?? undefined} + quotes={quotes} + quoteFocusKey={quoteFocusKey} + highlightEdit={highlightEdit} + warning={warning ?? null} + onWarningDismiss={onWarningDismiss} + initialScrollTop={initialScrollTop ?? null} + onScrollChange={onScrollChange} + /> + ) : ( + <DocView + doc={{ + document_id: documentId, + version_id: versionId, + }} + quotes={quotes} + quoteFocusKey={quoteFocusKey} + /> + )} + </div> </div> ); } @@ -182,68 +204,106 @@ export function DocPanel({ // Header variants // --------------------------------------------------------------------------- -function SectionLabel({ children }: { children: React.ReactNode }) { - return <p className="text-xs font-medium text-gray-700">{children}</p>; -} - -function CitationHeader({ - citation, +function DocumentTitleRow({ documentId, - versionId, filename, + versionId, + versionNumber, isReloading, }: { - citation: MikeCitationAnnotation; documentId: string; - versionId: string | null; filename: string; + versionId: string | null; + versionNumber: number | null; isReloading: boolean; }) { - const displayQuote = displayCitationQuote(citation); - const pagesLabel = formatCitationPage(citation); return ( - <div className="pt-2 pb-3"> - <div className="flex items-center gap-2 mb-2"> - <SectionLabel>Citation</SectionLabel> - <div className="ml-auto shrink-0"> - <DownloadButton - documentId={documentId} - versionId={versionId} - filename={filename} - isReloading={isReloading} - /> - </div> - </div> - <div className="w-full rounded-md bg-gray-50 border border-gray-200 px-2 py-2"> - <p className="text-sm font-serif text-gray-600"> - “{displayQuote}” - {pagesLabel && ( - <span className="ml-1 text-gray-400"> - ({pagesLabel}) + <div className="flex items-start gap-3 px-3 pt-4 pb-3"> + <div className="min-w-0 flex-1"> + <div className="flex min-w-0 flex-wrap items-center gap-2"> + <h2 + className="min-w-0 break-words font-serif text-xl text-gray-900" + title={filename} + > + {filename} + </h2> + {versionNumber && versionNumber > 0 && ( + <span className="shrink-0 inline-flex items-center rounded-md border border-gray-200 bg-white px-1.5 py-0.5 text-[10px] font-medium text-gray-600"> + V{versionNumber} </span> )} - </p> + </div> + </div> + <div className="shrink-0"> + <DownloadButton + documentId={documentId} + versionId={versionId} + filename={filename} + isReloading={isReloading} + /> </div> </div> ); } +function SectionLabel({ children }: { children: React.ReactNode }) { + return <p className="text-xs font-medium text-gray-700">{children}</p>; +} + +function RelevantQuoteSection({ + citation, + filename, + activeQuoteId, + onQuoteSelect, +}: { + citation: CitationAnnotation; + filename: string; + activeQuoteId: string | null; + onQuoteSelect: (quoteId: string) => void; +}) { + const citationQuotes = getDocumentCitationQuotes(citation); + const pagesLabel = formatCitationPage(citation); + const citationText = [filename, pagesLabel].filter(Boolean).join(", "); + const relevantQuotes: RelevantQuoteItem[] = citationQuotes.map( + (quote, index) => { + const pageLabel = `Page ${quote.page}`; + return { + id: `document:${citation.ref}:${index}`, + quote: quote.quote.replaceAll("[[PAGE_BREAK]]", "..."), + inlineDetail: pageLabel, + citationText: [filename, pageLabel].filter(Boolean).join(", "), + }; + }, + ); + const currentIndex = Math.max( + 0, + relevantQuotes.findIndex((quote) => quote.id === activeQuoteId), + ); + + return ( + <RelevantQuotes + quotes={relevantQuotes} + activeQuoteId={activeQuoteId} + currentIndex={currentIndex} + citationRef={citation.ref} + citationText={citationText} + onSelect={(quote) => onQuoteSelect(quote.id)} + onIndexChange={(index) => { + const quote = relevantQuotes[index]; + if (quote) onQuoteSelect(quote.id); + }} + /> + ); +} + function TrackedChangeHeader({ mode, - documentId, - versionId, - filename, - isReloading, }: { mode: Extract<DocPanelMode, { kind: "edit" }>; - documentId: string; - versionId: string | null; - filename: string; - isReloading: boolean; }) { const { edit, isEditReloading, onResolveStart, onResolved, onError } = mode; return ( - <div className="pt-2 pb-3"> + <div className="px-3 pb-3"> <div className="flex items-center gap-2 mb-2"> <SectionLabel>Tracked Change</SectionLabel> <div className="ml-auto flex items-center gap-2 shrink-0"> @@ -254,12 +314,6 @@ function TrackedChangeHeader({ onResolved={onResolved} onError={onError} /> - <DownloadButton - documentId={documentId} - versionId={versionId} - filename={filename} - isReloading={isReloading} - /> </div> </div> {edit.reason && ( @@ -294,7 +348,7 @@ function EditResolveButtons({ onResolved, onError, }: { - edit: MikeEditAnnotation; + edit: EditAnnotation; /** * True while an accept/reject for any edit on this document is in * flight (triggered from here, the inline EditCard, the bulk bar, or diff --git a/frontend/src/app/components/shared/DocView.tsx b/frontend/src/app/components/shared/DocView.tsx index 41f8cd1..893915d 100644 --- a/frontend/src/app/components/shared/DocView.tsx +++ b/frontend/src/app/components/shared/DocView.tsx @@ -1,8 +1,7 @@ "use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { ZoomIn, ZoomOut } from "lucide-react"; -import { MikeIcon } from "@/components/chat/mike-icon"; +import { Loader2, ZoomIn, ZoomOut } from "lucide-react"; import { useFetchSingleDoc } from "@/app/hooks/useFetchSingleDoc"; import { DocxView } from "./DocxView"; import type { CitationQuote } from "./types"; @@ -17,6 +16,8 @@ interface Props { doc: { document_id: string; version_id?: string | null } | null; /** Preferred: one or more (page, quote) pairs to highlight. */ quotes?: CitationQuote[]; + /** Changes when the parent wants the current quote re-focused. */ + quoteFocusKey?: string | number; /** Back-compat single-quote API. Ignored if `quotes` is provided. */ quote?: string; fallbackPage?: number; @@ -42,6 +43,7 @@ type RenderedPage = { export function DocView({ doc, quotes, + quoteFocusKey, quote, fallbackPage, rounded = true, @@ -495,9 +497,8 @@ export function DocView({ useEffect(() => { if (!pdfDocRef.current) return; quoteListRef.current = quoteList; - if (quoteList.length === 0) return; rehighlightQuotes(quoteList); - }, [quoteKey, rehighlightQuotes]); // eslint-disable-line react-hooks/exhaustive-deps + }, [quoteKey, quoteFocusKey, rehighlightQuotes]); // eslint-disable-line react-hooks/exhaustive-deps function handleZoomIn() { const next = Math.min( @@ -536,13 +537,14 @@ export function DocView({ <DocxView documentId={doc.document_id} quotes={quotes} + quoteFocusKey={quoteFocusKey} /> ); } return ( <div - className={`relative flex flex-col flex-1 overflow-hidden ${bordered ? "border border-gray-200" : ""} ${rounded ? "rounded-xl" : ""}`} + className={`relative flex flex-col flex-1 overflow-hidden ${bordered ? "border border-gray-200" : ""} ${rounded ? "rounded-lg" : ""}`} > <div ref={scrollContainerRef} @@ -550,7 +552,7 @@ export function DocView({ > {loading && ( <div className="flex h-full items-center justify-center"> - <MikeIcon spin mike size={28} /> + <Loader2 className="h-7 w-7 animate-spin text-gray-400" /> </div> )} {error && ( diff --git a/frontend/src/app/components/shared/DocViewModal.tsx b/frontend/src/app/components/shared/DocViewModal.tsx index 00c0b22..0fb57e7 100644 --- a/frontend/src/app/components/shared/DocViewModal.tsx +++ b/frontend/src/app/components/shared/DocViewModal.tsx @@ -5,16 +5,16 @@ import { createPortal } from "react-dom"; import { Download, Trash2, X } from "lucide-react"; import { DocView } from "./DocView"; import { getDocumentUrl } from "@/app/lib/mikeApi"; -import type { MikeDocument } from "./types"; +import type { Document } from "./types"; interface Props { - doc: MikeDocument | null; + doc: Document | null; /** Optional specific version to display. Only honoured for DOCX. */ versionId?: string | null; /** Optional label suffix for the header (e.g. "V3"). */ versionLabel?: string | null; onClose: () => void; - onDelete?: (doc: MikeDocument) => void; + onDelete?: (doc: Document) => void; } export function DocViewModal({ diff --git a/frontend/src/app/components/shared/DocumentCard.tsx b/frontend/src/app/components/shared/DocumentCard.tsx index 9b72818..abd01c6 100644 --- a/frontend/src/app/components/shared/DocumentCard.tsx +++ b/frontend/src/app/components/shared/DocumentCard.tsx @@ -1,12 +1,12 @@ "use client"; import { FileText, File, X, AlertCircle, Loader2 } from "lucide-react"; -import type { MikeDocument } from "./types"; +import type { Document } from "./types"; interface Props { - document: MikeDocument; + document: Document; onRemove?: (id: string) => void; - onClick?: (doc: MikeDocument) => void; + onClick?: (doc: Document) => void; selected?: boolean; } @@ -29,6 +29,7 @@ function formatBytes(bytes: number): string { export function DocumentCard({ document, onRemove, onClick, selected }: Props) { const isError = document.status === "error"; const isProcessing = document.status === "pending" || document.status === "processing"; + const filename = document.filename; return ( <div @@ -52,8 +53,8 @@ export function DocumentCard({ document, onRemove, onClick, selected }: Props) { )} <div className="min-w-0 flex-1"> - <p className="truncate font-medium text-gray-800" title={document.filename}> - {document.filename} + <p className="truncate font-medium text-gray-800" title={filename}> + {filename} </p> <p className="text-xs text-gray-400"> {isProcessing diff --git a/frontend/src/app/components/shared/DocxView.tsx b/frontend/src/app/components/shared/DocxView.tsx index 1fc8115..c378f9d 100644 --- a/frontend/src/app/components/shared/DocxView.tsx +++ b/frontend/src/app/components/shared/DocxView.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useMemo, useRef } from "react"; -import { MikeIcon } from "@/components/chat/mike-icon"; +import { Loader2 } from "lucide-react"; import { useFetchDocxBytes } from "@/app/hooks/useFetchDocxBytes"; import { supabase } from "@/lib/supabase"; import { @@ -50,6 +50,8 @@ interface Props { * pagination the renderer can match against. */ quotes?: CitationQuote[]; + /** Changes when the parent wants the current quote re-focused. */ + quoteFocusKey?: string | number; /** * Warning banner copy rendered in the top-left of the viewer. Used * for non-blocking errors (e.g. "Accept failed — reverted"). @@ -201,6 +203,7 @@ export function DocxView({ highlightEdit, refetchKey, quotes, + quoteFocusKey, warning, onWarningDismiss, initialScrollTop, @@ -347,13 +350,6 @@ export function DocxView({ const scrollEl = scrollRef.current; const containerEl = containerRef.current; - console.log("[DocxView] render effect fired", { - documentId, - versionId, - refetchKey, - bytesLen: bytes.byteLength, - }); - // Remember scroll position across re-renders so Accept/Reject stays put. lastScrollTopRef.current = scrollEl.scrollTop; const thisRender = ++renderKeyRef.current; @@ -447,7 +443,7 @@ export function DocxView({ scrollRef.current, quotesRef.current, ); - }, [quoteKey]); // eslint-disable-line react-hooks/exhaustive-deps + }, [quoteKey, quoteFocusKey]); // eslint-disable-line react-hooks/exhaustive-deps // Fire onScrollChange (rAF-throttled) so parents can persist scroll // per-tab. We still maintain lastScrollTopRef locally for same-mount @@ -471,7 +467,7 @@ export function DocxView({ return ( <div - className={`relative flex flex-col flex-1 overflow-hidden ${bordered ? "border border-gray-200" : ""} ${rounded ? "rounded-xl" : ""}`} + className={`relative flex flex-col flex-1 overflow-hidden ${bordered ? "border border-gray-200" : ""} ${rounded ? "rounded-lg" : ""}`} > {warning && ( <div className="absolute top-2 left-2 z-10 flex items-center gap-2 rounded-md border border-amber-200 bg-amber-50 px-2 py-1 text-xs text-amber-800 shadow-sm"> @@ -494,7 +490,7 @@ export function DocxView({ > {loading && !bytes && ( <div className="flex h-full items-center justify-center"> - <MikeIcon spin mike size={28} /> + <Loader2 className="h-7 w-7 animate-spin text-gray-400" /> </div> )} {error && ( diff --git a/frontend/src/app/components/shared/FileDirectory.tsx b/frontend/src/app/components/shared/FileDirectory.tsx index fde1a3d..ae81817 100644 --- a/frontend/src/app/components/shared/FileDirectory.tsx +++ b/frontend/src/app/components/shared/FileDirectory.tsx @@ -11,7 +11,7 @@ import { Trash2, Loader2, } from "lucide-react"; -import type { MikeDocument, MikeProject } from "./types"; +import type { Document, Project } from "./types"; import { VersionChip } from "./VersionChip"; function formatDate(iso: string | null) { @@ -30,8 +30,8 @@ export function DocFileIcon({ fileType }: { fileType: string | null }) { } interface FileDirectoryProps { - standaloneDocs: MikeDocument[]; - directoryProjects: MikeProject[]; + standaloneDocs: Document[]; + directoryProjects: Project[]; loading: boolean; selectedIds: Set<string>; onChange: (ids: Set<string>) => void; @@ -238,7 +238,12 @@ export function FileDirectory({ > {doc.filename} </span> - <VersionChip n={doc.latest_version_number} /> + <VersionChip + n={ + doc.active_version_number ?? + doc.latest_version_number + } + /> {doc.created_at && ( <span className="shrink-0 text-gray-300"> {formatDate(doc.created_at)} @@ -333,7 +338,10 @@ export function FileDirectory({ {doc.filename} </span> <VersionChip - n={doc.latest_version_number} + n={ + doc.active_version_number ?? + doc.latest_version_number + } /> {doc.created_at && ( <span className="shrink-0 text-gray-300"> diff --git a/frontend/src/app/components/shared/HeaderSearchBtn.tsx b/frontend/src/app/components/shared/HeaderSearchBtn.tsx deleted file mode 100644 index ddc2e5d..0000000 --- a/frontend/src/app/components/shared/HeaderSearchBtn.tsx +++ /dev/null @@ -1,57 +0,0 @@ -"use client"; - -import { useEffect, useRef, useState } from "react"; -import { Search, X } from "lucide-react"; - -interface Props { - value: string; - onChange: (v: string) => void; - placeholder?: string; -} - -export function HeaderSearchBtn({ value, onChange, placeholder = "Search…" }: Props) { - const [open, setOpen] = useState(false); - const ref = useRef<HTMLDivElement>(null); - - useEffect(() => { - function handleClick(e: MouseEvent) { - if (ref.current && !ref.current.contains(e.target as Node)) { - setOpen(false); - onChange(""); - } - } - if (open) document.addEventListener("mousedown", handleClick); - return () => document.removeEventListener("mousedown", handleClick); - }, [open, onChange]); - - return ( - <div ref={ref} className="relative flex items-center"> - {open ? ( - <div className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-2 bg-white border border-gray-200 rounded-lg px-3 py-1.5 shadow-sm z-10 w-72"> - <Search className="h-3.5 w-3.5 text-gray-400 shrink-0" /> - <input - autoFocus - type="text" - placeholder={placeholder} - value={value} - onChange={(e) => onChange(e.target.value)} - className="flex-1 text-sm text-gray-700 placeholder:text-gray-400 outline-none bg-transparent" - /> - <button - onClick={() => { setOpen(false); onChange(""); }} - className="text-gray-400 hover:text-gray-600" - > - <X className="h-3.5 w-3.5" /> - </button> - </div> - ) : ( - <button - onClick={() => setOpen(true)} - className="flex h-8 w-8 items-center justify-center text-gray-500 hover:text-gray-900 transition-colors" - > - <Search className="h-4 w-4" /> - </button> - )} - </div> - ); -} diff --git a/frontend/src/app/components/shared/Modal.tsx b/frontend/src/app/components/shared/Modal.tsx new file mode 100644 index 0000000..e9cec32 --- /dev/null +++ b/frontend/src/app/components/shared/Modal.tsx @@ -0,0 +1,199 @@ +"use client"; + +import { createPortal } from "react-dom"; +import type { ButtonHTMLAttributes, ReactNode } from "react"; +import { X } from "lucide-react"; +import { cn } from "@/lib/utils"; + +type ModalSize = "sm" | "md" | "lg" | "xl"; +type ModalAction = Omit< + ButtonHTMLAttributes<HTMLButtonElement>, + "className" +> & { + label: ReactNode; + icon?: ReactNode; + variant?: "primary" | "secondary" | "danger"; +}; + +interface ModalProps { + open: boolean; + onClose: () => void; + children: ReactNode; + breadcrumbs?: ReactNode[]; + title?: ReactNode; + icon?: ReactNode; + size?: ModalSize; + className?: string; + footerInfo?: ReactNode; + footerStatus?: ReactNode; + primaryAction?: ModalAction; + secondaryAction?: ModalAction; + cancelAction?: ModalAction | false; +} + +const sizeClassName: Record<ModalSize, string> = { + sm: "max-w-md", + md: "max-w-xl", + lg: "max-w-2xl", + xl: "max-w-4xl", +}; + +export function Modal({ + open, + onClose, + children, + breadcrumbs, + title, + icon, + size = "lg", + className, + footerInfo, + footerStatus, + primaryAction, + secondaryAction, + cancelAction, +}: ModalProps) { + const hasHeader = breadcrumbs?.length || title || icon; + const hasFooter = + footerInfo || + footerStatus || + primaryAction || + secondaryAction || + cancelAction; + const resolvedCancelAction = + cancelAction === undefined && primaryAction + ? { label: "Cancel", onClick: onClose } + : cancelAction; + + if (!open) return null; + + return createPortal( + <div + className={cn( + "fixed inset-0 z-[200] flex items-center justify-center px-4", + "bg-white/30 backdrop-blur-[2px]", + )} + onClick={onClose} + > + <div + className={cn( + "w-full rounded-2xl shadow-2xl flex h-[600px] flex-col", + sizeClassName[size], + "border border-white/70 bg-white/80 shadow-[0_24px_80px_rgba(15,23,42,0.18)] backdrop-blur-2xl", + className, + )} + onClick={(e) => e.stopPropagation()} + > + {hasHeader && ( + <div className="flex items-start justify-between gap-3 px-4 py-4"> + {breadcrumbs?.length ? ( + <div className="flex min-w-0 flex-wrap items-center gap-1.5 text-xs text-gray-400"> + {breadcrumbs.map((segment, index) => ( + <span + key={index} + className="flex items-center gap-1.5" + > + {index > 0 && <span>›</span>} + <span className="truncate"> + {segment} + </span> + </span> + ))} + </div> + ) : ( + <div className="flex min-w-0 items-center gap-2"> + {icon} + <h2 className="truncate text-base font-medium text-gray-900"> + {title} + </h2> + </div> + )} + <button + onClick={onClose} + className="shrink-0 text-gray-400 transition-colors hover:text-gray-600" + aria-label="Close" + > + <X className="h-4 w-4" /> + </button> + </div> + )} + <div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4 pt-1 pb-2"> + {children} + </div> + {hasFooter && ( + <div + className={cn( + "flex items-center gap-3 px-4 py-3", + secondaryAction || footerInfo + ? "justify-between" + : "justify-end", + "border-t border-white/60", + )} + > + {(secondaryAction || footerInfo) && ( + <div className="flex min-w-0 items-center gap-2"> + {secondaryAction && ( + <ModalActionButton + action={secondaryAction} + fallbackVariant="secondary" + /> + )} + {footerInfo} + </div> + )} + <div className="flex items-center gap-2"> + {footerStatus} + {resolvedCancelAction && ( + <ModalActionButton + action={resolvedCancelAction} + fallbackVariant="cancel" + /> + )} + {primaryAction && ( + <ModalActionButton + action={primaryAction} + fallbackVariant="primary" + /> + )} + </div> + </div> + )} + </div> + </div>, + document.body, + ); +} + +function ModalActionButton({ + action, + fallbackVariant, +}: { + action: ModalAction; + fallbackVariant: "primary" | "secondary" | "danger" | "cancel"; +}) { + const { + label, + icon, + variant = fallbackVariant === "cancel" ? "secondary" : fallbackVariant, + ...props + } = action; + + return ( + <button + className={cn( + "inline-flex items-center justify-center gap-1.5 rounded-lg px-4 py-1.5 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40", + variant === "primary" && + "bg-gray-900 text-white hover:bg-gray-700", + variant === "secondary" && "text-gray-600 hover:bg-gray-100", + fallbackVariant === "secondary" && + "border border-gray-200 hover:bg-gray-50", + variant === "danger" && + "bg-red-600 text-white hover:bg-red-700", + )} + {...props} + > + {icon} + {label} + </button> + ); +} diff --git a/frontend/src/app/components/shared/OwnerOnlyModal.tsx b/frontend/src/app/components/shared/OwnerOnlyModal.tsx index b11d2ba..62d3f84 100644 --- a/frontend/src/app/components/shared/OwnerOnlyModal.tsx +++ b/frontend/src/app/components/shared/OwnerOnlyModal.tsx @@ -1,7 +1,7 @@ "use client"; -import { createPortal } from "react-dom"; -import { Lock, X } from "lucide-react"; +import { Lock } from "lucide-react"; +import { WarningPopup } from "./WarningPopup"; interface Props { open: boolean; @@ -38,56 +38,21 @@ export function OwnerOnlyModal({ ? `Only the project owner can ${action}.` : "Only the project owner can perform this action."); - return createPortal( - <div - className="fixed inset-0 z-[200] flex items-center justify-center bg-black/10 backdrop-blur-xs" - onClick={onClose} + return ( + <WarningPopup + open={open} + onClose={onClose} + title={title} + message={body} + icon={<Lock className="mt-0.5 h-3.5 w-3.5 shrink-0 text-red-600" />} + primaryAction={{ label: "OK", onClick: onClose }} > - <div - className="w-full max-w-md rounded-2xl bg-white shadow-2xl flex flex-col" - onClick={(e) => e.stopPropagation()} - > - {/* Header */} - <div className="flex items-start justify-between gap-3 px-5 pt-5 pb-2"> - <div className="flex items-center gap-2"> - <Lock className="h-4 w-4 text-amber-600" /> - <h2 className="text-base font-medium text-gray-900"> - {title} - </h2> - </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> - - {/* Body */} - <div className="px-5 pb-2 pt-1"> - <p className="text-sm text-gray-600 leading-relaxed"> - {body} - </p> - {ownerEmail && ( - <p className="mt-2 text-xs text-gray-400"> - Ask{" "} - <span className="text-gray-600">{ownerEmail}</span>{" "} - if you need access. - </p> - )} - </div> - - {/* Footer */} - <div className="flex justify-end gap-2 px-5 pb-5 pt-3"> - <button - onClick={onClose} - className="rounded-lg bg-gray-900 px-4 py-1.5 text-sm font-medium text-white hover:bg-gray-700" - > - OK - </button> - </div> - </div> - </div>, - document.body, + {ownerEmail && ( + <p className="mt-1 text-xs text-gray-600"> + Ask <span className="text-gray-600">{ownerEmail}</span> if + you need access. + </p> + )} + </WarningPopup> ); } diff --git a/frontend/src/app/components/shared/PageHeader.tsx b/frontend/src/app/components/shared/PageHeader.tsx new file mode 100644 index 0000000..e28a92c --- /dev/null +++ b/frontend/src/app/components/shared/PageHeader.tsx @@ -0,0 +1,442 @@ +"use client"; + +import { + Fragment, + isValidElement, + useEffect, + useRef, + useState, + type ButtonHTMLAttributes, + type ReactNode, +} from "react"; +import { ChevronLeft, Loader2, Plus, Search, Trash2 } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export interface PageHeaderBreadcrumb { + label?: ReactNode; + suffix?: ReactNode; + onClick?: () => void; + loading?: boolean; + skeletonClassName?: string; + title?: string; +} + +type PageHeaderButtonAction = { + type?: "button"; + icon?: ReactNode; + label?: ReactNode; + onClick?: () => void; + disabled?: boolean; + title?: string; + variant?: "default" | "danger"; + iconOnly?: boolean; + className?: string; + tooltip?: ReactNode; +}; + +type PageHeaderSearchAction = { + type: "search"; + value: string; + onChange: (value: string) => void; + placeholder?: string; +}; + +type PageHeaderDeleteAction = { + type: "delete"; + onClick?: () => void; + disabled?: boolean; + loading?: boolean; + title?: string; +}; + +type PageHeaderNewAction = { + type: "new"; + onClick?: () => void; + disabled?: boolean; + loading?: boolean; + title?: string; +}; + +type PageHeaderCustomAction = { + type: "custom"; + render: ReactNode; +}; + +export type PageHeaderAction = + | PageHeaderButtonAction + | PageHeaderSearchAction + | PageHeaderDeleteAction + | PageHeaderNewAction + | PageHeaderCustomAction + | ReactNode; + +interface PageHeaderProps { + children?: ReactNode; + actions?: PageHeaderAction[]; + actionGroups?: PageHeaderAction[][]; + align?: "center" | "start"; + shrink?: boolean; + className?: string; + actionGap?: "sm" | "md" | "lg"; + breadcrumbs?: PageHeaderBreadcrumb[]; +} + +const actionGapClassName = { + sm: "gap-2.5", + md: "gap-2.5", + lg: "gap-2.5", +}; + +export function PageHeader({ + children, + actions, + actionGroups, + align = "center", + shrink = false, + className, + actionGap = "sm", + breadcrumbs, +}: PageHeaderProps) { + const headerContent = breadcrumbs?.length ? ( + <PageHeaderBreadcrumbs items={breadcrumbs} /> + ) : ( + children + ); + const actionItems = actions?.filter(Boolean) ?? []; + const groupedActionItems = + actionGroups + ?.map((group) => group.filter(Boolean)) + .filter((group) => group.length > 0) ?? + (actionItems.length > 0 ? [actionItems] : []); + + return ( + <div + className={cn( + "flex justify-between", + align === "start" ? "items-start" : "items-center", + "px-4 md:px-10", + "pb-4 pt-5.5", + shrink && "shrink-0", + className, + )} + > + {headerContent} + {groupedActionItems.length > 0 && ( + <div className="ml-4 flex shrink-0 items-center gap-3"> + {groupedActionItems.map((group, groupIndex) => ( + <div + key={groupIndex} + className={cn( + "flex shrink-0 items-center", + actionGapClassName[actionGap], + "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) => ( + <Fragment key={index}> + <PageHeaderActionRenderer action={action} /> + </Fragment> + ))} + </div> + ))} + </div> + )} + </div> + ); +} + +function PageHeaderActionRenderer({ action }: { action: PageHeaderAction }) { + if (!isPageHeaderActionObject(action)) return <>{action}</>; + + switch (action.type) { + case "search": + return <PageHeaderSearchActionControl action={action} />; + case "delete": + return <PageHeaderDeleteActionControl action={action} />; + case "new": + return <PageHeaderNewActionControl action={action} />; + case "custom": + return <>{action.render}</>; + case "button": + default: + return <PageHeaderButtonActionControl action={action} />; + } +} + +function isPageHeaderActionObject( + action: PageHeaderAction, +): action is Exclude<PageHeaderAction, ReactNode> { + return !!action && typeof action === "object" && !isValidElement(action); +} + +function PageHeaderButtonActionControl({ + action, +}: { + action: PageHeaderButtonAction; +}) { + const iconOnly = action.iconOnly ?? !action.label; + return ( + <div className={action.tooltip ? "relative group" : undefined}> + <PageHeaderActionButton + onClick={action.onClick} + disabled={action.disabled} + title={action.title} + aria-label={action.title} + variant={action.variant} + iconOnly={iconOnly} + className={action.className} + > + {action.icon} + {action.label} + </PageHeaderActionButton> + {action.tooltip && ( + <div className="pointer-events-none absolute right-0 top-full mt-1.5 z-10 hidden items-center whitespace-nowrap rounded-lg bg-gray-900 px-2.5 py-1.5 text-xs text-white shadow-lg group-hover:flex"> + {action.tooltip} + </div> + )} + </div> + ); +} + +function PageHeaderNewActionControl({ + action, +}: { + action: PageHeaderNewAction; +}) { + const title = action.title ?? "New"; + return ( + <PageHeaderActionButton + onClick={action.onClick} + disabled={action.disabled || action.loading} + title={title} + aria-label={title} + iconOnly + > + {action.loading ? ( + <Loader2 className="h-4 w-4 animate-spin" /> + ) : ( + <Plus className="h-4 w-4" /> + )} + </PageHeaderActionButton> + ); +} + +function PageHeaderDeleteActionControl({ + action, +}: { + action: PageHeaderDeleteAction; +}) { + const title = action.title ?? "Delete"; + return ( + <PageHeaderActionButton + onClick={action.onClick} + disabled={action.disabled || action.loading} + title={title} + aria-label={title} + iconOnly + variant="danger" + > + {action.loading ? ( + <Loader2 className="h-4 w-4 animate-spin" /> + ) : ( + <Trash2 className="h-4 w-4" /> + )} + </PageHeaderActionButton> + ); +} + +function PageHeaderSearchActionControl({ + action, +}: { + action: PageHeaderSearchAction; +}) { + const [open, setOpen] = useState(false); + const ref = useRef<HTMLDivElement>(null); + const placeholder = action.placeholder ?? "Search…"; + + useEffect(() => { + function handleClick(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false); + action.onChange(""); + } + } + if (open) document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, [open, action]); + + return ( + <div ref={ref} className="relative flex items-center"> + {open ? ( + <div + className={cn( + pageHeaderActionControlClassName({ + className: + "cursor-text justify-start gap-2 px-3 text-gray-700 hover:text-gray-700", + }), + "w-56 bg-gray-100 sm:w-80", + )} + > + <Search className="h-3.5 w-3.5 text-gray-400 shrink-0" /> + <input + autoFocus + type="text" + placeholder={placeholder} + value={action.value} + onChange={(e) => action.onChange(e.target.value)} + className="flex-1 text-sm text-gray-700 placeholder:text-gray-400 outline-none bg-transparent" + /> + </div> + ) : ( + <PageHeaderActionButton + onClick={() => setOpen(true)} + iconOnly + title={placeholder} + aria-label={placeholder} + > + <Search className="h-4 w-4" /> + </PageHeaderActionButton> + )} + </div> + ); +} + +type PageHeaderActionButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & { + variant?: "default" | "danger"; + iconOnly?: boolean; +}; + +type PageHeaderActionControlClassNameOptions = { + variant?: "default" | "danger"; + iconOnly?: boolean; + disabled?: boolean; + className?: string; +}; + +function pageHeaderActionControlClassName({ + variant = "default", + iconOnly = false, + disabled = false, + className, +}: PageHeaderActionControlClassNameOptions = {}) { + return cn( + "flex h-7 items-center justify-center rounded-full text-sm transition-colors hover:bg-gray-100 active:bg-gray-100 disabled:cursor-default disabled:text-gray-300 disabled:hover:bg-transparent disabled:hover:text-gray-300", + iconOnly ? "w-7" : "gap-1.5 px-3", + disabled ? "cursor-default" : "cursor-pointer", + "hover:bg-gray-100 active:bg-gray-100", + variant === "danger" + ? "text-gray-500 hover:text-red-600" + : "text-gray-500 hover:text-gray-900", + className, + ); +} + +function PageHeaderActionButton({ + children, + className, + variant = "default", + iconOnly = false, + disabled, + ...props +}: PageHeaderActionButtonProps) { + return ( + <button + disabled={disabled} + className={pageHeaderActionControlClassName({ + variant, + iconOnly, + disabled, + className, + })} + {...props} + > + {children} + </button> + ); +} + +function PageHeaderBreadcrumbs({ items }: { items: PageHeaderBreadcrumb[] }) { + const current = items[items.length - 1]; + const parent = [...items] + .slice(0, -1) + .reverse() + .find((item) => item.onClick); + + return ( + <div className="flex min-w-0 items-center gap-1.5 text-2xl font-medium font-serif"> + {parent?.onClick && ( + <button + onClick={parent.onClick} + className="shrink-0 text-gray-400 transition-colors hover:text-gray-600 sm:hidden" + title={parent.title ?? "Back"} + aria-label={parent.title ?? "Back"} + > + <ChevronLeft className="h-5 w-5" /> + </button> + )} + <div className="hidden min-w-0 items-center gap-1.5 sm:flex"> + {items.map((item, index) => ( + <BreadcrumbItem + key={index} + item={item} + current={index === items.length - 1} + showSuffix + /> + ))} + </div> + <div className="min-w-0 sm:hidden"> + {current ? ( + <BreadcrumbItem item={current} current showSuffix={false} /> + ) : null} + </div> + </div> + ); +} + +function BreadcrumbItem({ + item, + current, + showSuffix, +}: { + item: PageHeaderBreadcrumb; + current: boolean; + showSuffix: boolean; +}) { + const content = item.loading ? ( + <div + className={cn( + "h-6 rounded bg-gray-100 animate-pulse", + item.skeletonClassName ?? "w-32", + )} + /> + ) : ( + <> + <span className="truncate">{item.label}</span> + {showSuffix && item.suffix} + </> + ); + + const className = cn( + "min-w-0 truncate transition-colors", + current + ? "text-gray-900" + : item.onClick + ? "text-gray-500 hover:text-gray-700" + : "text-gray-500", + ); + + return ( + <> + {current ? ( + <span className={className}>{content}</span> + ) : item.onClick ? ( + <button onClick={item.onClick} className={className}> + {content} + </button> + ) : ( + <span className={className}>{content}</span> + )} + {!current && <span className="shrink-0 text-gray-300">›</span>} + </> + ); +} diff --git a/frontend/src/app/components/shared/PeopleModal.tsx b/frontend/src/app/components/shared/PeopleModal.tsx index 8a70d39..bc3c0a8 100644 --- a/frontend/src/app/components/shared/PeopleModal.tsx +++ b/frontend/src/app/components/shared/PeopleModal.tsx @@ -1,9 +1,9 @@ "use client"; import { useEffect, useState } from "react"; -import { createPortal } from "react-dom"; -import { X, User, UserPlus, Loader2, Plus } from "lucide-react"; +import { User, UserPlus, Loader2, Plus } from "lucide-react"; import type { ProjectPeople } from "@/app/lib/mikeApi"; +import { Modal } from "./Modal"; /** * Any resource the modal can manage members for — projects today, tabular @@ -194,30 +194,22 @@ export function PeopleModal({ } } - return createPortal( - <div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/10 backdrop-blur-xs"> - <div className="w-full max-w-2xl rounded-2xl bg-white shadow-2xl flex flex-col h-[600px]"> - {/* Header */} - <div className="flex items-center justify-between px-5 py-4"> - <div className="flex items-center gap-1.5 text-xs text-gray-400"> - {breadcrumb.map((segment, i) => ( - <span key={i} className="flex items-center gap-1.5"> - {i > 0 && <span>›</span>} - {segment} - </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={open} + onClose={onClose} + breadcrumbs={breadcrumb} + footerInfo={ + roster.length === 0 + ? "No one has access yet." + : `${roster.length} ${ + roster.length === 1 ? "person" : "people" + } with access.` + } + > {/* Add-member row */} {onSharedWithChange && ( - <div className="px-4 pt-1 pb-2"> + <div className="pt-1 pb-2"> <div className="flex items-center gap-2"> <div className="flex flex-1 items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2"> <UserPlus className="h-3.5 w-3.5 text-gray-400 shrink-0" /> @@ -281,7 +273,7 @@ export function PeopleModal({ )} {/* Section heading */} - <div className="px-4 pt-3 pb-1 flex items-center gap-2"> + <div className="pt-3 pb-1 flex items-center gap-2"> <h3 className="text-xs font-medium text-gray-500"> People with Access </h3> @@ -291,89 +283,77 @@ export function PeopleModal({ </div> {/* Member list */} - <div className="flex-1 overflow-y-auto px-4 pb-2"> - {roster.length === 0 ? ( - <div className="flex h-full items-center justify-center text-sm text-gray-400"> - No one has access yet. - </div> - ) : ( - <ul className="divide-y divide-gray-100 [&>li:nth-child(2)]:border-t-0"> - {roster.map((entry) => { - const isYou = - !!currentUserEmail && - entry.email.toLowerCase() === - currentUserEmail.toLowerCase(); - const isRemoving = - busy === "remove" && - removingEmail === entry.email; - const primary = - entry.display_name?.trim() || entry.email; - const showSecondary = - !!entry.display_name?.trim() && - primary !== entry.email; - return ( - <li - key={`${entry.role}-${entry.email}`} - className="flex items-center gap-3 py-3" - > - <div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-gray-900 text-white"> - <User className="h-3 w-3" /> - </div> - <div className="min-w-0 flex-1"> - <p className="truncate text-sm text-gray-800"> - {primary} - {isYou && ( - <span className="ml-1.5 text-xs text-gray-400"> - (You) - </span> - )} - {entry.role === "owner" && ( - <span className="ml-1.5 text-[10px] text-gray-400"> - Owner - </span> - )} + {roster.length === 0 ? ( + <div className="flex h-full items-center justify-center text-sm text-gray-400"> + No one has access yet. + </div> + ) : ( + <ul className="divide-y divide-gray-100 [&>li:nth-child(2)]:border-t-0"> + {roster.map((entry) => { + const isYou = + !!currentUserEmail && + entry.email.toLowerCase() === + currentUserEmail.toLowerCase(); + const isRemoving = + busy === "remove" && + removingEmail === entry.email; + const primary = + entry.display_name?.trim() || entry.email; + const showSecondary = + !!entry.display_name?.trim() && + primary !== entry.email; + return ( + <li + key={`${entry.role}-${entry.email}`} + className="flex items-center gap-3 py-3" + > + <div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-gray-900 text-white"> + <User className="h-3 w-3" /> + </div> + <div className="min-w-0 flex-1"> + <p className="truncate text-sm text-gray-800"> + {primary} + {isYou && ( + <span className="ml-1.5 text-xs text-gray-400"> + (You) + </span> + )} + {entry.role === "owner" && ( + <span className="ml-1.5 text-[10px] text-gray-400"> + Owner + </span> + )} + </p> + {showSecondary && ( + <p className="truncate text-xs text-gray-400"> + {entry.email} </p> - {showSecondary && ( - <p className="truncate text-xs text-gray-400"> - {entry.email} - </p> - )} - </div> - {entry.role === "member" && - onSharedWithChange && ( - <button - onClick={() => - void handleRemove( - entry.email, - ) - } - disabled={busy !== null} - title="Remove access" - className="self-center inline-flex items-center gap-1 rounded px-2 py-1 text-xs text-gray-500 hover:bg-red-50 hover:text-red-600 disabled:opacity-50" - > - {isRemoving && ( - <Loader2 className="h-3 w-3 animate-spin" /> - )} - Remove - </button> - )} - </li> - ); - })} - </ul> - )} - </div> + )} + </div> + {entry.role === "member" && + onSharedWithChange && ( + <button + onClick={() => + void handleRemove( + entry.email, + ) + } + disabled={busy !== null} + title="Remove access" + className="self-center inline-flex items-center gap-1 rounded px-2 py-1 text-xs text-gray-500 hover:bg-red-50 hover:text-red-600 disabled:opacity-50" + > + {isRemoving && ( + <Loader2 className="h-3 w-3 animate-spin" /> + )} + Remove + </button> + )} + </li> + ); + })} + </ul> + )} - {/* Footer */} - <div className="px-5 py-3 text-[11px] text-gray-400"> - {roster.length === 0 - ? "No one has access yet." - : `${roster.length} ${ - roster.length === 1 ? "person" : "people" - } with access.`} - </div> - </div> - </div>, - document.body, + </Modal> ); } diff --git a/frontend/src/app/components/shared/PreResponseWrapper.tsx b/frontend/src/app/components/shared/PreResponseWrapper.tsx index 0afbf16..f5a26a3 100644 --- a/frontend/src/app/components/shared/PreResponseWrapper.tsx +++ b/frontend/src/app/components/shared/PreResponseWrapper.tsx @@ -40,7 +40,7 @@ export function PreResponseWrapper({ const childrenGapClass = compact ? "gap-2.5" : "gap-4"; return ( - <div className="border border-gray-200 rounded-lg px-3 py-2"> + <div className="rounded-xl border border-white/70 bg-white/55 px-3 py-2 shadow-[0_3px_9px_rgba(15,23,42,0.03),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-4px_9px_rgba(255,255,255,0.05)] backdrop-blur-2xl"> <button type="button" onClick={() => { @@ -61,7 +61,7 @@ export function PreResponseWrapper({ </span> <ChevronDown size={12} - className={`shrink-0 ml-2 transition-transform duration-200 ${isOpen ? "" : "-rotate-90"}`} + className={`relative top-px shrink-0 ml-2 transition-transform duration-200 ${isOpen ? "" : "-rotate-90"}`} /> </button> {isOpen && ( diff --git a/frontend/src/app/components/shared/ProjectPicker.tsx b/frontend/src/app/components/shared/ProjectPicker.tsx index 154dc04..05a945c 100644 --- a/frontend/src/app/components/shared/ProjectPicker.tsx +++ b/frontend/src/app/components/shared/ProjectPicker.tsx @@ -2,10 +2,10 @@ import { useState } from "react"; import { Folder, Search, X } from "lucide-react"; -import type { MikeProject } from "./types"; +import type { Project } from "./types"; interface Props { - projects: MikeProject[]; + projects: Project[]; loading: boolean; selectedId: string | null; onSelect: (id: string | null) => void; @@ -18,7 +18,7 @@ export function ProjectPicker({ projects, loading, selectedId, onSelect }: Props return ( <> - <div className="px-4 pt-1 pb-2"> + <div className="pt-1 pb-2"> <div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2"> <Search className="h-3.5 w-3.5 text-gray-400 shrink-0" /> <input @@ -36,7 +36,7 @@ export function ProjectPicker({ projects, loading, selectedId, onSelect }: Props )} </div> </div> - <div className="flex-1 overflow-y-auto px-4 pb-2"> + <div className="flex-1 overflow-y-auto pb-2"> {loading ? ( <div className="rounded-sm border border-gray-100 overflow-hidden"> <div className="flex items-center px-2 py-2"> diff --git a/frontend/src/app/components/shared/RelevantQuotes.tsx b/frontend/src/app/components/shared/RelevantQuotes.tsx new file mode 100644 index 0000000..faf4d63 --- /dev/null +++ b/frontend/src/app/components/shared/RelevantQuotes.tsx @@ -0,0 +1,297 @@ +"use client"; + +import { useEffect, useState, type ReactNode } from "react"; +import { Minus, RectangleHorizontal, Rows3 } from "lucide-react"; +import { CiteButton } from "@/components/ui/cite-button"; + +export type RelevantQuoteItem = { + id: string; + quote: string; + eyebrow?: string | null; + inlineDetail?: string | null; + detail?: string | null; + citationText?: string | null; +}; + +interface Props { + quotes: RelevantQuoteItem[]; + error?: string | null; + isLoading?: boolean; + activeQuoteId?: string | null; + currentIndex?: number; + citationRef?: number; + citationText?: string; + onSelect?: (quote: RelevantQuoteItem, index: number) => void; + onIndexChange?: (index: number) => void; +} + +export function RelevantQuotes({ + quotes, + error = null, + isLoading = false, + activeQuoteId = null, + currentIndex = 0, + citationRef, + citationText, + onSelect, + onIndexChange, +}: Props) { + const [isExpanded, setIsExpanded] = useState(true); + const [viewMode, setViewMode] = useState<"single" | "list">("single"); + const hasMultipleQuotes = quotes.length > 1; + const currentQuote = quotes[currentIndex]; + + useEffect(() => { + if (!hasMultipleQuotes && viewMode === "list") { + setViewMode("single"); + } + }, [hasMultipleQuotes, viewMode]); + + return ( + <div className="px-3"> + <div className="rounded-lg border border-gray-200"> + <div className="flex h-10 items-center justify-between px-2"> + <div className="flex items-center gap-2"> + <p className="text-xs font-medium text-gray-700"> + {typeof citationRef === "number" + ? `Citation ${citationRef}` + : "Citation"} + </p> + {hasMultipleQuotes && ( + <div className="flex items-center gap-1"> + {quotes.map((quote, index) => ( + <button + key={quote.id} + type="button" + onClick={() => + onIndexChange?.(index) + } + className={`flex h-4 w-4 items-center justify-center rounded-full text-[9px] transition-colors ${ + currentIndex === index + ? "bg-white font-medium text-gray-800 shadow-[0_1px_3px_rgba(0,0,0,0.22)]" + : "bg-gray-200 text-gray-500 hover:bg-gray-300 hover:text-gray-700" + }`} + > + {index + 1} + </button> + ))} + </div> + )} + </div> + <div className="flex items-center gap-2"> + {currentQuote && ( + <CiteButton + quoteText={currentQuote.quote} + citationText={ + currentQuote.citationText ?? + citationText ?? + "" + } + className="rounded-sm bg-white px-2 h-6 text-gray-600 shadow-[0_1px_3px_rgba(0,0,0,0.22)] hover:bg-gray-50" + showText + /> + )} + <div + className={`relative flex h-6 items-center justify-start gap-1 rounded-sm bg-gray-200 p-1 ${ + hasMultipleQuotes ? "w-16" : "w-11" + }`} + > + <div + className={`absolute top-1 h-4 w-4 rounded bg-white shadow-sm transition-all ${ + !isExpanded + ? "left-1" + : hasMultipleQuotes && + viewMode === "list" + ? "left-11" + : "left-6" + }`} + /> + <button + type="button" + onClick={() => setIsExpanded(false)} + className={`relative z-10 flex h-4 w-4 items-center justify-center rounded ${ + !isExpanded + ? "text-gray-800" + : "text-gray-500 hover:text-gray-700" + }`} + title="Minimize" + > + <Minus className="h-3 w-3" /> + </button> + <button + type="button" + onClick={() => { + setIsExpanded(true); + setViewMode("single"); + }} + className={`relative z-10 flex h-4 w-4 items-center justify-center rounded ${ + isExpanded && viewMode === "single" + ? "text-gray-800" + : "text-gray-500 hover:text-gray-700" + }`} + title="Single quote" + > + <RectangleHorizontal className="h-3 w-3" /> + </button> + {hasMultipleQuotes && ( + <button + type="button" + onClick={() => { + setIsExpanded(true); + setViewMode("list"); + }} + className={`relative z-10 flex h-4 w-4 items-center justify-center rounded ${ + isExpanded && viewMode === "list" + ? "text-gray-800" + : "text-gray-500 hover:text-gray-700" + }`} + title="Quote list" + > + <Rows3 className="h-3 w-3" /> + </button> + )} + </div> + </div> + </div> + {isExpanded && ( + <div className="px-2 pb-2"> + {isLoading ? ( + <RelevantQuoteSkeleton /> + ) : error ? ( + <RelevantQuoteMessage tone="error"> + {error} + </RelevantQuoteMessage> + ) : quotes.length > 0 ? ( + viewMode === "list" ? ( + <div className="space-y-2"> + {quotes.map((quote, index) => ( + <QuoteItem + key={quote.id} + quote={quote} + isActive={ + activeQuoteId === quote.id + } + onClick={() => + onSelect?.(quote, index) + } + /> + ))} + </div> + ) : currentQuote ? ( + <div className="flex flex-col gap-2"> + <QuoteItem + quote={currentQuote} + isActive={ + activeQuoteId === currentQuote.id + } + onClick={() => + onSelect?.( + currentQuote, + currentIndex, + ) + } + /> + </div> + ) : null + ) : ( + <RelevantQuoteMessage> + No relevant quotes. + </RelevantQuoteMessage> + )} + </div> + )} + </div> + </div> + ); +} + +function RelevantQuoteSkeleton() { + return ( + <div className="animate-pulse rounded-md border border-gray-200 bg-gray-50 px-3 py-2.5"> + <div className="h-3 w-28 rounded bg-gray-200" /> + <div className="mt-2.5 h-3 w-full rounded bg-gray-200" /> + <div className="mt-2 h-3 w-11/12 rounded bg-gray-200" /> + <div className="mt-2 h-3 w-2/3 rounded bg-gray-200" /> + </div> + ); +} + +function RelevantQuoteMessage({ + children, + tone = "neutral", +}: { + children: ReactNode; + tone?: "neutral" | "error"; +}) { + return ( + <div className="rounded-md border border-gray-200 bg-gray-50 px-3 py-2.5"> + <p + className={`font-serif text-sm leading-6 ${ + tone === "error" ? "text-red-700" : "text-gray-600" + }`} + > + {children} + </p> + </div> + ); +} + +function QuoteItem({ + quote, + isActive, + onClick, +}: { + quote: RelevantQuoteItem; + isActive: boolean; + onClick: () => void; +}) { + return ( + <button + type="button" + onClick={onClick} + className={`w-full rounded-md border px-3 py-2.5 text-left transition-colors ${ + isActive + ? "border-blue-300 bg-blue-50" + : "border-gray-200 bg-gray-50 hover:border-blue-300 hover:bg-blue-50/50" + }`} + > + <div className="flex flex-col gap-1.5"> + {quote.eyebrow && ( + <p + className={`font-serif text-xs ${ + isActive ? "text-blue-900" : "text-gray-500" + }`} + > + {quote.eyebrow} + </p> + )} + <p + className={`font-serif text-sm leading-6 ${ + isActive ? "text-blue-950" : "text-gray-700" + }`} + > + “{quote.quote.replace(/"/g, "'")}” + {quote.inlineDetail && ( + <span + className={`text-sm ${ + isActive ? "text-blue-900" : "text-gray-500" + }`} + > + {" "} + ({quote.inlineDetail}) + </span> + )} + </p> + {quote.detail && ( + <p + className={`font-serif text-xs ${ + isActive ? "text-blue-900" : "text-gray-500" + }`} + > + {quote.detail} + </p> + )} + </div> + </button> + ); +} diff --git a/frontend/src/app/components/shared/SidebarChatItem.tsx b/frontend/src/app/components/shared/SidebarChatItem.tsx index eb64920..8e9a606 100644 --- a/frontend/src/app/components/shared/SidebarChatItem.tsx +++ b/frontend/src/app/components/shared/SidebarChatItem.tsx @@ -11,10 +11,11 @@ import { import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext"; import { useAuth } from "@/contexts/AuthContext"; import { OwnerOnlyModal } from "@/app/components/shared/OwnerOnlyModal"; -import type { MikeChat } from "@/app/components/shared/types"; +import type { Chat } from "@/app/components/shared/types"; +import { cn } from "@/lib/utils"; interface Props { - chat: MikeChat; + chat: Chat; isActive: boolean; onSelect: () => void; projectName?: string; @@ -48,9 +49,10 @@ export function SidebarChatItem({ chat, isActive, onSelect, projectName }: Props return ( <div - className={`group relative flex items-center w-full h-9 rounded-md transition-colors ${ - isActive ? "bg-gray-100" : "hover:bg-gray-100" - }`} + className={cn( + "group relative flex items-center w-full h-9 rounded-md transition-colors", + isActive ? "bg-gray-200/60" : "hover:bg-gray-100", + )} > {isRenaming ? ( <div className="flex items-center w-full px-2 py-1"> @@ -104,7 +106,7 @@ export function SidebarChatItem({ chat, isActive, onSelect, projectName }: Props <DropdownMenu> <DropdownMenuTrigger asChild> <button - className={`p-1 mr-1 text-gray-500 transition-opacity hover:text-gray-900 ${ + className={`mr-1 rounded-md p-1 text-gray-500 transition-all hover:bg-gray-200 hover:text-gray-900 ${ isActive ? "opacity-100" : "opacity-0 group-hover:opacity-100" diff --git a/frontend/src/app/components/shared/UploadNewVersionModal.tsx b/frontend/src/app/components/shared/UploadNewVersionModal.tsx index b90c814..e1b17ce 100644 --- a/frontend/src/app/components/shared/UploadNewVersionModal.tsx +++ b/frontend/src/app/components/shared/UploadNewVersionModal.tsx @@ -1,16 +1,16 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { createPortal } from "react-dom"; -import { X, Upload } from "lucide-react"; +import { Upload } from "lucide-react"; import { listDocumentVersions } from "@/app/lib/mikeApi"; -import type { MikeDocument } from "./types"; +import type { Document } from "./types"; +import { Modal } from "./Modal"; interface Props { open: boolean; onClose: () => void; - doc: MikeDocument | null; - onSubmit: (file: File, displayName: string) => Promise<void>; + doc: Document | null; + onSubmit: (file: File, filename: string) => Promise<void>; } export function UploadNewVersionModal({ open, onClose, doc, onSubmit }: Props) { @@ -35,7 +35,7 @@ export function UploadNewVersionModal({ open, onClose, doc, onSubmit }: Props) { (v) => v.id === current_version_id, ); const initial = - (current?.display_name && current.display_name.trim()) || + (current?.filename && current.filename.trim()) || doc.filename; if (!cancelled) { setName(initial); @@ -72,87 +72,52 @@ export function UploadNewVersionModal({ open, onClose, doc, onSubmit }: Props) { } } - return createPortal( - <div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/10 backdrop-blur-xs"> - <div className="w-full max-w-md rounded-2xl bg-white shadow-2xl flex flex-col"> - {/* Header */} - <div className="flex items-center justify-between px-5 py-4"> - <div className="text-xs text-gray-400"> - Upload new version · {doc.filename} - </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> - - {/* Name input */} - <div className="px-5 pb-4"> - <label className="block text-xs font-medium text-gray-500 mb-1"> - New version name - </label> - <input - type="text" - value={name} - onChange={(e) => setName(e.target.value)} - placeholder="Version name" - className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-gray-400" - /> - <div className="mt-2 text-xs text-gray-500"> - Current Version:{" "} - <span className="text-gray-700 font-medium"> - {currentVersion ?? "—"} - </span> - </div> - {stagedFile && ( - <div className="mt-2 text-xs text-gray-500 truncate"> - New Version File:{" "} - <span className="text-gray-700"> - {stagedFile.name} - </span> - </div> - )} - </div> - - {/* Footer */} - <div className="border-t border-gray-100 px-4 py-3 flex items-center justify-between gap-3"> - <div> - <input - ref={fileInputRef} - type="file" - accept={accept} - className="hidden" - onChange={handleFilePick} - /> - <button - onClick={() => fileInputRef.current?.click()} - disabled={submitting} - className="flex items-center gap-1.5 rounded-lg border border-gray-200 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50" - > - <Upload className="h-3.5 w-3.5" /> - {stagedFile ? "Change file" : "Upload"} - </button> - </div> - <div className="flex items-center gap-2"> - <button - onClick={onClose} - className="rounded-lg px-3 py-1.5 text-sm text-gray-500 hover:bg-gray-100" - > - Cancel - </button> - <button - onClick={handleSubmit} - disabled={!stagedFile || submitting} - className="rounded-lg bg-gray-900 px-4 py-1.5 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-40" - > - {submitting ? "Saving…" : "Save"} - </button> - </div> - </div> + return ( + <Modal + open={open} + onClose={onClose} + breadcrumbs={["Upload new version", doc.filename]} + secondaryAction={{ + label: stagedFile ? "Change file" : "Upload", + icon: <Upload className="h-3.5 w-3.5" />, + onClick: () => fileInputRef.current?.click(), + disabled: submitting, + }} + primaryAction={{ + label: submitting ? "Saving…" : "Save", + onClick: handleSubmit, + disabled: !stagedFile || submitting, + }} + > + <input + ref={fileInputRef} + type="file" + accept={accept} + className="hidden" + onChange={handleFilePick} + /> + <label className="block text-xs font-medium text-gray-500 mb-1"> + New version name + </label> + <input + type="text" + value={name} + onChange={(e) => setName(e.target.value)} + placeholder="Version name" + className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-gray-400" + /> + <div className="mt-2 text-xs text-gray-500"> + Current Version:{" "} + <span className="text-gray-700 font-medium"> + {currentVersion ?? "—"} + </span> </div> - </div>, - document.body, + {stagedFile && ( + <div className="mt-2 text-xs text-gray-500 truncate"> + New Version File:{" "} + <span className="text-gray-700">{stagedFile.name}</span> + </div> + )} + </Modal> ); } diff --git a/frontend/src/app/components/shared/WarningPopup.tsx b/frontend/src/app/components/shared/WarningPopup.tsx new file mode 100644 index 0000000..1a1e406 --- /dev/null +++ b/frontend/src/app/components/shared/WarningPopup.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { createPortal } from "react-dom"; +import type { ReactNode } from "react"; +import { AlertCircle, X } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface WarningPopupAction { + label: ReactNode; + onClick: () => void; + disabled?: boolean; +} + +interface WarningPopupProps { + open: boolean; + onClose: () => void; + title?: ReactNode; + message?: ReactNode; + children?: ReactNode; + icon?: ReactNode; + primaryAction?: WarningPopupAction; + secondaryAction?: WarningPopupAction; + className?: string; +} + +export function WarningPopup({ + open, + onClose, + title, + message, + children, + icon, + primaryAction, + secondaryAction, + className, +}: WarningPopupProps) { + if (!open) return null; + + return createPortal( + <div className="pointer-events-none fixed left-1/2 top-5 z-[220] w-[min(92vw,520px)] -translate-x-1/2 px-4"> + <div + className={cn( + "pointer-events-auto flex items-start gap-2 rounded-2xl border border-white/70 bg-red-50/75 px-3 py-2 text-xs shadow-[0_4px_12px_rgba(15,23,42,0.11),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-6px_12px_rgba(255,255,255,0.2)] backdrop-blur-2xl", + className, + )} + > + {icon ?? ( + <AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-red-600" /> + )} + <div className="min-w-0 flex-1 self-center text-gray-900"> + {title && ( + <div className="font-medium text-gray-950"> + {title} + </div> + )} + {message && <div>{message}</div>} + {children} + {(primaryAction || secondaryAction) && ( + <div className="mt-2 flex items-center gap-2"> + {secondaryAction && ( + <WarningPopupButton action={secondaryAction} /> + )} + {primaryAction && ( + <WarningPopupButton + action={primaryAction} + primary + /> + )} + </div> + )} + </div> + <button + type="button" + onClick={onClose} + className="shrink-0 text-gray-700 transition-colors hover:text-gray-950" + aria-label="Dismiss warning" + > + <X className="h-3.5 w-3.5" /> + </button> + </div> + </div>, + document.body, + ); +} + +function WarningPopupButton({ + action, + primary = false, +}: { + action: WarningPopupAction; + primary?: boolean; +}) { + return ( + <button + type="button" + onClick={action.onClick} + disabled={action.disabled} + className={cn( + "rounded-lg px-3 py-1 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40", + primary + ? "bg-gray-900 text-white hover:bg-gray-700" + : "text-gray-700 hover:bg-white/70", + )} + > + {action.label} + </button> + ); +} diff --git a/frontend/src/app/components/shared/highlightDocxQuote.ts b/frontend/src/app/components/shared/highlightDocxQuote.ts index b643d0d..c0d1d19 100644 --- a/frontend/src/app/components/shared/highlightDocxQuote.ts +++ b/frontend/src/app/components/shared/highlightDocxQuote.ts @@ -1,4 +1,5 @@ const HIGHLIGHT_CLASS = "docx-text-highlight"; +const IGNORED_TEXT_SELECTOR = ".star-pagination,.case-page-number"; function onlyLetters(s: string): string { return s.replace(/[^a-zA-Z0-9]/g, "").toLowerCase(); @@ -23,6 +24,8 @@ function collectTextNodes(root: HTMLElement): Text[] { const tag = p.tagName; if (tag === "STYLE" || tag === "SCRIPT") return NodeFilter.FILTER_REJECT; + if (p.closest(IGNORED_TEXT_SELECTOR)) + return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_ACCEPT; }, }); diff --git a/frontend/src/app/components/shared/types.ts b/frontend/src/app/components/shared/types.ts index d7bac4e..5539013 100644 --- a/frontend/src/app/components/shared/types.ts +++ b/frontend/src/app/components/shared/types.ts @@ -1,6 +1,6 @@ // Shared TypeScript types for Mike AI legal assistant -export interface MikeFolder { +export interface Folder { id: string; project_id: string; user_id: string; @@ -10,7 +10,7 @@ export interface MikeFolder { updated_at: string; } -export interface MikeProject { +export interface Project { id: string; user_id: string; is_owner?: boolean; @@ -19,14 +19,14 @@ export interface MikeProject { shared_with: string[]; created_at: string; updated_at: string; - documents?: MikeDocument[]; - folders?: MikeFolder[]; + documents?: Document[]; + folders?: Folder[]; document_count?: number; chat_count?: number; review_count?: number; } -export interface MikeDocument { +export interface Document { id: string; user_id?: string; project_id: string | null; @@ -41,7 +41,9 @@ export interface MikeDocument { status: "pending" | "processing" | "ready" | "error"; created_at: string | null; updated_at?: string | null; - /** Max version_number across assistant_edit rows, null if doc is unedited. */ + /** Version number of the document row pointed to by current_version_id. */ + active_version_number?: number | null; + /** Legacy: max version_number across assistant_edit rows, null if doc is unedited. */ latest_version_number?: number | null; } @@ -53,7 +55,7 @@ export interface StructureNode { children: StructureNode[]; } -export interface MikeChat { +export interface Chat { id: string; project_id: string | null; user_id: string; @@ -61,7 +63,7 @@ export interface MikeChat { created_at: string; } -export interface MikeEditAnnotation { +export interface EditAnnotation { type?: "edit_data"; kind?: "edit"; edit_id: string; @@ -82,161 +84,315 @@ export interface MikeEditAnnotation { export type AssistantEvent = | { type: "reasoning"; text: string; isStreaming?: boolean } + | { type: "error"; message: string } | { - type: "tool_call_start"; - name: string; - isStreaming?: boolean; + type: "tool_call_start"; + name: string; + isStreaming?: boolean; } | { type: "thinking"; isStreaming?: boolean } | { - type: "doc_read"; - filename: string; - document_id?: string; - isStreaming?: boolean; + type: "doc_read"; + filename: string; + document_id?: string; + isStreaming?: boolean; } | { - type: "doc_find"; - filename: string; - query: string; - total_matches: number; - isStreaming?: boolean; + type: "doc_find"; + filename: string; + query: string; + total_matches: number; + isStreaming?: boolean; } | { - type: "doc_created"; - filename: string; - download_url: string; - /** Set when the generated doc is persisted as a first-class document. */ - document_id?: string; - version_id?: string; - version_number?: number | null; - isStreaming?: boolean; + type: "doc_created"; + filename: string; + download_url: string; + /** Set when the generated doc is persisted as a first-class document. */ + document_id?: string; + version_id?: string; + version_number?: number | null; + isStreaming?: boolean; } | { type: "doc_download"; filename: string; download_url: string } | { - type: "doc_replicated"; - /** Source document filename. */ - filename: string; - /** How many copies were produced in this single tool call. */ - count: number; - /** One entry per new copy. Empty while streaming. */ - copies?: { - new_filename: string; - document_id: string; - version_id: string; - }[]; - error?: string; - isStreaming?: boolean; + type: "doc_replicated"; + /** Source document filename. */ + filename: string; + /** How many copies were produced in this single tool call. */ + count: number; + /** One entry per new copy. Empty while streaming. */ + copies?: { + new_filename: string; + document_id: string; + version_id: string; + }[]; + error?: string; + isStreaming?: boolean; } | { type: "workflow_applied"; workflow_id: string; title: string } | { - type: "doc_edited"; - filename: string; - document_id: string; - version_id: string; - /** Per-document monotonic Vn written at emit time. */ - version_number?: number | null; - download_url: string; - annotations: MikeEditAnnotation[]; + type: "doc_edited"; + filename: string; + document_id: string; + version_id: string; + /** Per-document monotonic Vn written at emit time. */ + version_number?: number | null; + download_url: string; + annotations: EditAnnotation[]; + error?: string; + isStreaming?: boolean; + } + | { + type: "courtlistener_search_case_law"; + query: string; + result_count?: number; + error?: string; + isStreaming?: boolean; + } + | { + type: "courtlistener_get_cases"; + cluster_ids: number[]; + case_count?: number; + opinion_count?: number; + cases?: { + cluster_id: number; + case_name: string | null; + citation: string | null; + dateFiled?: string | null; + url?: string | null; + }[]; + error?: string; + isStreaming?: boolean; + } + | { + type: "courtlistener_find_in_case"; + cluster_id: number | null; + query: string; + total_matches?: number; + case_name?: string | null; + citation?: string | null; + searches?: { + cluster_id: number | null; + query: string; + total_matches?: number; + case_name?: string | null; + citation?: string | null; error?: string; - isStreaming?: boolean; + }[]; + error?: string; + isStreaming?: boolean; + } + | { + type: "courtlistener_read_case"; + cluster_id: number | null; + case_name?: string | null; + citation?: string | null; + opinion_count?: number; + error?: string; + isStreaming?: boolean; + } + | { + type: "courtlistener_verify_citations"; + citation_count?: number; + match_count?: number; + error?: string; + isStreaming?: boolean; + } + | { + type: "case_citation"; + cluster_id: number | null; + case_name: string | null; + citation: string | null; + url: string; + pdfUrl?: string | null; + dateFiled?: string | null; + judges?: string | null; + case?: Extract<AssistantEvent, { type: "case_opinions" }>["case"]; + } + | { + type: "case_opinions"; + cluster_id: number; + case: { + id: number | null; + caseName?: string | null; + dateFiled?: string | null; + citations?: string[]; + url?: string | null; + pdfUrl?: string | null; + opinions: { + opinionId: number | null; + apiUrl?: string | null; + type: string | null; + author: string | null; + url: string | null; + text?: string | null; + html?: string | null; + }[]; + }; } | { type: "content"; text: string; isStreaming?: boolean }; -export interface MikeMessage { +export type CaseCitationQuote = { + opinionId: number | null; + type: string | null; + author: string | null; + quote: string; +}; + +export interface Message { role: "user" | "assistant"; content: string; files?: { filename: string; document_id?: string }[]; workflow?: { id: string; title: string }; model?: string; - annotations?: MikeCitationAnnotation[]; + annotations?: CitationAnnotation[]; + citationStatus?: "started" | "partial" | "final"; events?: AssistantEvent[]; /** Set when streaming failed; rendered as a red error block. */ error?: string; } export interface CitationQuote { - page: number; + page?: number; quote: string; } -/** - * A citation emitted by the assistant. Single-page citations have a numeric - * `page` and a plain `quote`. A citation that spans a page break (one - * continuous sentence cut by a page boundary) has `page` as a range string - * like "41-42" and a `quote` containing the `[[PAGE_BREAK]]` sentinel at the - * break point (text before is on page 41, text after is on page 42). - */ -export interface MikeCitationAnnotation { +export type DocumentCitationQuote = { + page: number | string; + quote: string; +}; + +export type DocumentCitationAnnotation = { type: "citation_data"; + kind?: "document"; ref: number; doc_id: string; document_id: string; version_id?: string | null; version_number?: number | null; filename: string; + /** Legacy single-quote fields. Prefer `quotes` for new annotations. */ page: number | string; quote: string; -} + quotes?: DocumentCitationQuote[]; +}; + +export type CaseCitationAnnotation = { + type: "citation_data"; + kind: "case"; + ref: number; + cluster_id: number; + case_name?: string | null; + citation?: string | null; + url?: string | null; + pdfUrl?: string | null; + dateFiled?: string | null; + judges?: string | null; + quotes: CaseCitationQuote[]; +}; + +/** + * A citation emitted by the assistant. Document citations have doc/page + * anchors. Case citations anchor to a CourtListener cluster and include a + * quoted opinion passage. + */ +export type CitationAnnotation = + | DocumentCitationAnnotation + | CaseCitationAnnotation; const PAGE_BREAK_SENTINEL = "[[PAGE_BREAK]]"; +function expandDocumentQuoteEntry(entry: DocumentCitationQuote): CitationQuote[] { + const rangeMatch = + typeof entry.page === "string" + ? entry.page.match(/^(\d+)\s*-\s*(\d+)$/) + : null; + if (rangeMatch && entry.quote.includes(PAGE_BREAK_SENTINEL)) { + const startPage = parseInt(rangeMatch[1], 10); + const endPage = parseInt(rangeMatch[2], 10); + const [before, after] = entry.quote.split(PAGE_BREAK_SENTINEL); + return [ + { page: startPage, quote: before.trim() }, + { page: endPage, quote: after.trim() }, + ].filter((e) => e.quote.length > 0); + } + const pageNum = + typeof entry.page === "number" + ? entry.page + : parseInt(String(entry.page), 10); + if (!Number.isFinite(pageNum)) return []; + return [{ page: pageNum, quote: entry.quote }]; +} + +export function getDocumentCitationQuotes( + a: CitationAnnotation, +): DocumentCitationQuote[] { + if (a.kind === "case") return []; + if (Array.isArray(a.quotes) && a.quotes.length) { + return a.quotes.filter((entry) => entry.quote.trim().length > 0); + } + return [{ page: a.page, quote: a.quote }]; +} + /** * Expand a citation into one or more (page, quote) entries suitable for * highlighting in the PDF viewer. A single-page citation yields one entry; a * cross-page citation with page "N-M" and a `[[PAGE_BREAK]]` split yields two. */ export function expandCitationToEntries( - a: MikeCitationAnnotation, + a: CitationAnnotation, ): CitationQuote[] { - const rangeMatch = - typeof a.page === "string" - ? a.page.match(/^(\d+)\s*-\s*(\d+)$/) - : null; - if (rangeMatch && a.quote.includes(PAGE_BREAK_SENTINEL)) { - const startPage = parseInt(rangeMatch[1], 10); - const endPage = parseInt(rangeMatch[2], 10); - const [before, after] = a.quote.split(PAGE_BREAK_SENTINEL); - return [ - { page: startPage, quote: before.trim() }, - { page: endPage, quote: after.trim() }, - ].filter((e) => e.quote.length > 0); - } - const pageNum = - typeof a.page === "number" ? a.page : parseInt(String(a.page), 10); - if (!Number.isFinite(pageNum)) return []; - return [{ page: pageNum, quote: a.quote }]; + if (a.kind === "case") return []; + return getDocumentCitationQuotes(a).flatMap(expandDocumentQuoteEntry); } /** Format the page(s) of a citation for display, e.g. "Page 3" or "Page 41-42". */ -export function formatCitationPage(a: MikeCitationAnnotation): string { +export function formatCitationPage(a: CitationAnnotation): string { + if (a.kind === "case") { + return a.citation || a.case_name || `Case ${a.cluster_id}`; + } + const quotes = getDocumentCitationQuotes(a); + const pages = Array.from( + new Set(quotes.map((q) => String(q.page)).filter(Boolean)), + ); + if (pages.length > 1) return `Pages ${pages.join(", ")}`; + if (pages.length === 1) return `Page ${pages[0]}`; if (typeof a.page === "string") return `Page ${a.page}`; return `Page ${a.page}`; } /** Produce a reader-friendly version of the quote (replaces [[PAGE_BREAK]] with "..."). */ -export function displayCitationQuote(a: MikeCitationAnnotation): string { - return a.quote.replaceAll(PAGE_BREAK_SENTINEL, "..."); +export function displayCitationQuote(a: CitationAnnotation): string { + if (a.kind === "case") { + return a.quotes + .map((q) => q.quote.replaceAll(PAGE_BREAK_SENTINEL, "...")) + .join(" / "); + } + return getDocumentCitationQuotes(a) + .map((q) => q.quote.replaceAll(PAGE_BREAK_SENTINEL, "...")) + .join(" / "); } // Tabular Review export type ColumnFormat = - | "text" - | "bulleted_list" - | "number" - | "currency" - | "yes_no" - | "date" - | "tag" - | "percentage" - | "monetary_amount"; + | "text" + | "bulleted_list" + | "number" + | "currency" + | "yes_no" + | "date" + | "tag" + | "percentage" + | "monetary_amount"; export interface ColumnConfig { - index: number; - name: string; - prompt: string; - format?: ColumnFormat; - tags?: string[]; + index: number; + name: string; + prompt: string; + format?: ColumnFormat; + tags?: string[]; } export interface TabularReview { @@ -273,7 +429,7 @@ export interface TabularCell { // Workflows -export interface MikeWorkflow { +export interface Workflow { id: string; user_id: string | null; title: string; @@ -290,13 +446,13 @@ export interface MikeWorkflow { // API helpers -export interface MikeChatDetailOut { - chat: MikeChat; - messages: MikeMessage[]; +export interface ChatDetailOut { + chat: Chat; + messages: Message[]; } export interface TabularReviewDetailOut { review: TabularReview; cells: TabularCell[]; - documents: MikeDocument[]; + documents: Document[]; } diff --git a/frontend/src/app/components/shared/useDirectoryData.ts b/frontend/src/app/components/shared/useDirectoryData.ts index 282327d..8e96094 100644 --- a/frontend/src/app/components/shared/useDirectoryData.ts +++ b/frontend/src/app/components/shared/useDirectoryData.ts @@ -2,13 +2,13 @@ import { useEffect, useState } from "react"; import { getProject, listProjects, listStandaloneDocuments } from "@/app/lib/mikeApi"; -import type { MikeDocument, MikeProject } from "./types"; +import type { Document, Project } from "./types"; const CACHE_TTL_MS = 30_000; interface DirectoryCache { - standaloneDocuments: MikeDocument[]; - projects: MikeProject[]; + standaloneDocuments: Document[]; + projects: Project[]; fetchedAt: number; } @@ -20,8 +20,8 @@ export function invalidateDirectoryCache() { export function useDirectoryData(enabled: boolean) { const [loading, setLoading] = useState(true); - const [standaloneDocuments, setStandaloneDocuments] = useState<MikeDocument[]>([]); - const [projects, setProjects] = useState<MikeProject[]>([]); + const [standaloneDocuments, setStandaloneDocuments] = useState<Document[]>([]); + const [projects, setProjects] = useState<Project[]>([]); useEffect(() => { if (!enabled) return; diff --git a/frontend/src/app/components/tabular/AddNewTRModal.tsx b/frontend/src/app/components/tabular/AddNewTRModal.tsx index f76a081..ff142c3 100644 --- a/frontend/src/app/components/tabular/AddNewTRModal.tsx +++ b/frontend/src/app/components/tabular/AddNewTRModal.tsx @@ -1,9 +1,8 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { createPortal } from "react-dom"; -import { Check, ChevronDown, Loader2, Upload, X } from "lucide-react"; -import type { MikeDocument, MikeProject, MikeWorkflow } from "../shared/types"; +import { Check, ChevronDown, Loader2, Upload } from "lucide-react"; +import type { Document, Project, Workflow } from "../shared/types"; import { getProject, listProjects, @@ -14,6 +13,7 @@ import { } from "@/app/lib/mikeApi"; import { FileDirectory } from "../shared/FileDirectory"; import { BUILT_IN_WORKFLOWS } from "../workflows/builtinWorkflows"; +import { Modal } from "../shared/Modal"; interface Props { open: boolean; @@ -22,11 +22,11 @@ interface Props { title: string, projectId?: string, documentIds?: string[], - columnsConfig?: MikeWorkflow["columns_config"], + columnsConfig?: Workflow["columns_config"], ) => void; - projects?: MikeProject[]; + projects?: Project[]; /** When provided, skip the project/directory picker and show only these docs */ - projectDocs?: MikeDocument[]; + projectDocs?: Document[]; projectName?: string; projectCmNumber?: string | null; } @@ -47,12 +47,12 @@ export function AddNewTRModal({ const [projectDropdownOpen, setProjectDropdownOpen] = useState(false); // Project-scoped docs (when underProject is true and no fixedProjectDocs) - const [projectDocs, setProjectDocs] = useState<MikeDocument[]>([]); + const [projectDocs, setProjectDocs] = useState<Document[]>([]); const [loadingDocs, setLoadingDocs] = useState(false); // Full directory (when underProject is false) - const [standaloneDocs, setStandaloneDocs] = useState<MikeDocument[]>([]); - const [directoryProjects, setDirectoryProjects] = useState<MikeProject[]>( + const [standaloneDocs, setStandaloneDocs] = useState<Document[]>([]); + const [directoryProjects, setDirectoryProjects] = useState<Project[]>( [], ); const [loadingDirectory, setLoadingDirectory] = useState(false); @@ -64,12 +64,13 @@ export function AddNewTRModal({ const fileInputRef = useRef<HTMLInputElement>(null); // Workflow templates - const [workflows, setWorkflows] = useState<MikeWorkflow[]>([]); + const [workflows, setWorkflows] = useState<Workflow[]>([]); const [loadingWorkflows, setLoadingWorkflows] = useState(false); const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null>( null, ); const [workflowDropdownOpen, setWorkflowDropdownOpen] = useState(false); + const formId = "new-tabular-review-modal-form"; useEffect(() => { if (!open) return; @@ -205,7 +206,7 @@ export function AddNewTRModal({ : underProject ? [] : directoryProjects; - const flatProjectDocs: MikeDocument[] = + const flatProjectDocs: Document[] = !isProjectMode && underProject ? projectDocs : []; const directoryLoading = isProjectMode ? false @@ -213,56 +214,59 @@ export function AddNewTRModal({ ? loadingDocs : loadingDirectory; const showDirectory = isProjectMode || !underProject || !!selectedProjectId; + const breadcrumbs = + isProjectMode && projectName + ? [ + "Projects", + `${projectName}${projectCmNumber ? ` (#${projectCmNumber})` : ""}`, + "New Tabular Review", + ] + : ["Tabular Reviews", "New Tabular Review"]; - return createPortal( - <div className="fixed inset-0 z-[101] flex items-center justify-center bg-black/20 backdrop-blur-xs"> - <div className="w-full max-w-2xl rounded-2xl bg-white shadow-2xl flex flex-col h-[600px]"> - {/* Header */} - <div className="flex items-center justify-between px-6 pt-5 pb-2 shrink-0"> - <div className="flex items-center gap-1.5 text-xs text-gray-400"> - {isProjectMode && projectName ? ( - <> - <span>Projects</span> - <span>›</span> - <span> - {projectName} - {projectCmNumber ? ` (#${projectCmNumber})` : ""} - </span> - <span>›</span> - <span>Tabular Reviews</span> - <span>›</span> - <span>New review</span> - </> - ) : ( - <> - <span>Tabular Reviews</span> - <span>›</span> - <span>New review</span> - </> - )} - </div> - <button - onClick={handleClose} - className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 transition-colors" - > - <X className="h-4 w-4" /> - </button> - </div> - - <form - onSubmit={handleSubmit} - className="flex flex-col min-h-0 flex-1" - > - <div className="px-6 pt-3 pb-4 space-y-5 overflow-y-auto flex-1"> - {/* Title */} - <input - type="text" - value={title} - onChange={(e) => setTitle(e.target.value)} - placeholder="Review name" - className="w-full text-2xl font-serif text-gray-800 placeholder-gray-400 focus:outline-none bg-transparent" - autoFocus - /> + return ( + <Modal + open={open} + onClose={handleClose} + breadcrumbs={breadcrumbs} + secondaryAction={{ + label: uploading ? "Uploading…" : "Upload", + icon: uploading ? ( + <Loader2 className="h-3.5 w-3.5 animate-spin" /> + ) : ( + <Upload className="h-3.5 w-3.5" /> + ), + onClick: () => fileInputRef.current?.click(), + disabled: uploading, + }} + primaryAction={{ + label: "Create", + type: "submit", + form: formId, + disabled: !title.trim() || (underProject && !selectedProjectId), + }} + > + <input + ref={fileInputRef} + type="file" + accept=".pdf,.docx,.doc" + multiple + className="hidden" + onChange={handleUpload} + /> + <form + id={formId} + onSubmit={handleSubmit} + className="flex flex-col min-h-0 flex-1" + > + <div className="space-y-5"> + <input + type="text" + value={title} + onChange={(e) => setTitle(e.target.value)} + placeholder="Review name" + className="w-full text-2xl font-serif text-gray-800 placeholder-gray-400 focus:outline-none bg-transparent" + autoFocus + /> {/* Workflow template */} <div className="space-y-2"> @@ -477,56 +481,8 @@ export function AddNewTRModal({ </div> </div> )} - </div> - - {/* Footer */} - <div className="flex items-center justify-between gap-2 border-t border-gray-100 px-6 py-4 shrink-0"> - <div> - <input - ref={fileInputRef} - type="file" - accept=".pdf,.docx,.doc" - multiple - className="hidden" - onChange={handleUpload} - /> - <button - type="button" - onClick={() => fileInputRef.current?.click()} - disabled={uploading} - className="flex items-center gap-1.5 rounded-lg border border-gray-200 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50 transition-colors" - > - {uploading ? ( - <Loader2 className="h-3.5 w-3.5 animate-spin" /> - ) : ( - <Upload className="h-3.5 w-3.5" /> - )} - {uploading ? "Uploading…" : "Upload"} - </button> - </div> - <div className="flex items-center gap-2"> - <button - type="button" - onClick={handleClose} - className="rounded-lg px-4 py-2 text-sm text-gray-500 hover:bg-gray-100 transition-colors" - > - Cancel - </button> - <button - type="submit" - disabled={ - !title.trim() || - (underProject && !selectedProjectId) - } - className="rounded-lg bg-gray-900 px-5 py-2 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-40 transition-colors" - > - Create - </button> - </div> - </div> - </form> - </div> - </div>, - document.body, + </div> + </form> + </Modal> ); } diff --git a/frontend/src/app/components/tabular/TRChatPanel.tsx b/frontend/src/app/components/tabular/TRChatPanel.tsx index e066486..388fee4 100644 --- a/frontend/src/app/components/tabular/TRChatPanel.tsx +++ b/frontend/src/app/components/tabular/TRChatPanel.tsx @@ -4,13 +4,13 @@ import { useEffect, useRef, useState } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { - X, Clock, MessageSquarePlus, Search, Square, ArrowRight, ChevronDown, + ChevronLeft, Trash2, } from "lucide-react"; import { MikeIcon } from "@/components/chat/mike-icon"; @@ -23,11 +23,7 @@ import { type TRChat, type TRCitationAnnotation, } from "@/app/lib/mikeApi"; -import type { - AssistantEvent, - ColumnConfig, - MikeDocument, -} from "../shared/types"; +import type { AssistantEvent, ColumnConfig, Document } from "../shared/types"; import { ModelToggle } from "../assistant/ModelToggle"; import { ApiKeyMissingModal } from "../shared/ApiKeyMissingModal"; import { PreResponseWrapper } from "../shared/PreResponseWrapper"; @@ -38,6 +34,7 @@ import { type ModelProvider, } from "@/app/lib/modelAvailability"; import type { ApiKeyState } from "@/app/lib/mikeApi"; +import { cn } from "@/lib/utils"; // --------------------------------------------------------------------------- // Types @@ -51,12 +48,64 @@ interface TRMessage { isStreaming?: boolean; } +function parseCourtlistenerEventCases(value: unknown) { + if (!Array.isArray(value)) return undefined; + return value + .map((item) => { + if (!item || typeof item !== "object" || Array.isArray(item)) { + return null; + } + const row = item as Record<string, unknown>; + return { + cluster_id: + typeof row.cluster_id === "number" ? row.cluster_id : 0, + case_name: + typeof row.case_name === "string" ? row.case_name : null, + citation: + typeof row.citation === "string" ? row.citation : null, + dateFiled: + typeof row.dateFiled === "string" ? row.dateFiled : null, + url: typeof row.url === "string" ? row.url : null, + }; + }) + .filter( + (item): item is NonNullable<typeof item> => + !!item && item.cluster_id > 0, + ); +} + +function parseCourtlistenerCaseSearches(value: unknown) { + if (!Array.isArray(value)) return undefined; + return value + .map((item) => { + if (!item || typeof item !== "object" || Array.isArray(item)) { + return null; + } + const row = item as Record<string, unknown>; + return { + cluster_id: + typeof row.cluster_id === "number" ? row.cluster_id : null, + query: typeof row.query === "string" ? row.query : "", + total_matches: + typeof row.total_matches === "number" + ? row.total_matches + : 0, + case_name: + typeof row.case_name === "string" ? row.case_name : null, + citation: + typeof row.citation === "string" ? row.citation : null, + error: typeof row.error === "string" ? row.error : undefined, + }; + }) + .filter((item): item is NonNullable<typeof item> => !!item); +} + interface Props { reviewId: string; reviewTitle?: string | null; projectName?: string | null; columns: ColumnConfig[]; - documents: MikeDocument[]; + documents: Document[]; onCitationClick: (colIdx: number, rowIdx: number) => void; onClose: () => void; initialChatId?: string | null; @@ -73,6 +122,8 @@ const THINKING_PHRASES = [ "Analyzing...", "Reasoning...", ]; +const REASONING_COLLAPSED_MAX_LINES = 6; +const REASONING_COLLAPSED_MAX_HEIGHT_REM = 9; function ReasoningBlock({ text, @@ -82,7 +133,11 @@ function ReasoningBlock({ isStreaming: boolean; }) { const [isOpen, setIsOpen] = useState(false); + const [userToggled, setUserToggled] = useState(false); + const [isOverflowing, setIsOverflowing] = useState(false); + const [hasMeasured, setHasMeasured] = useState(false); const [phraseIdx, setPhraseIdx] = useState(0); + const contentRef = useRef<HTMLDivElement | null>(null); useEffect(() => { if (!isStreaming) return; @@ -93,10 +148,28 @@ function ReasoningBlock({ return () => clearInterval(interval); }, [isStreaming]); + useEffect(() => { + const el = contentRef.current; + if (!el) return; + const lineHeight = parseFloat(getComputedStyle(el).lineHeight) || 24; + const maxHeight = lineHeight * REASONING_COLLAPSED_MAX_LINES; + const nextOverflowing = el.scrollHeight > maxHeight + 2; + setIsOverflowing(nextOverflowing); + setHasMeasured(true); + if (nextOverflowing && !userToggled) setIsOpen(false); + }, [text, userToggled]); + + const showContent = isOpen || isStreaming || isOverflowing || !hasMeasured; + const isCollapsed = isOverflowing && !isOpen; + return ( <div className="ml-1"> <button - onClick={() => !isStreaming && setIsOpen((v) => !v)} + onClick={() => { + if (isStreaming) return; + setUserToggled(true); + setIsOpen((v) => !v); + }} className="flex items-center text-sm text-gray-400 hover:text-gray-500 transition-colors" > {isStreaming ? ( @@ -116,11 +189,56 @@ function ReasoningBlock({ /> )} </button> - {(isOpen || isStreaming) && ( - <div className="mt-1.5 ml-[14px] text-sm text-gray-400 prose prose-sm max-w-none [&>*]:text-gray-400 [&>*]:text-sm"> - <ReactMarkdown remarkPlugins={[remarkGfm]}> - {text} - </ReactMarkdown> + {showContent && ( + <div className="mt-1.5 ml-[14px]"> + <div + className={`relative ${isCollapsed ? "overflow-hidden" : ""}`} + style={ + isCollapsed + ? { + maxHeight: `${REASONING_COLLAPSED_MAX_HEIGHT_REM}rem`, + } + : undefined + } + > + <div + ref={contentRef} + className="text-sm text-gray-400 prose prose-sm max-w-none [&>*]:text-gray-400 [&>*]:text-sm" + > + <ReactMarkdown remarkPlugins={[remarkGfm]}> + {text} + </ReactMarkdown> + </div> + {isCollapsed && ( + <> + <div className="pointer-events-none absolute inset-x-0 bottom-0 h-10 bg-gradient-to-b from-white/0 to-white" /> + <button + type="button" + onClick={() => { + setUserToggled(true); + setIsOpen(true); + }} + className="absolute left-1/2 bottom-2 z-10 -translate-x-1/2 text-gray-400 transition-colors hover:text-gray-600" + aria-label="Expand thought process" + > + <ChevronDown className="h-3.5 w-3.5" /> + </button> + </> + )} + </div> + {isOverflowing && isOpen && ( + <button + type="button" + onClick={() => { + setUserToggled(true); + setIsOpen(false); + }} + className="mx-auto mt-2 flex text-gray-400 transition-colors hover:text-gray-600" + aria-label="Minimise thought process" + > + <ChevronDown className="h-3.5 w-3.5 rotate-180" /> + </button> + )} </div> )} </div> @@ -507,9 +625,17 @@ function TRChatInput({ return ( <div ref={rootRef} - className="absolute bottom-0 left-0 right-0 px-4 pb-4 bg-white" + className={cn( + "absolute bottom-0 left-0 right-0 px-4 pb-3", + "bg-transparent", + )} > - <div className="border border-gray-300 rounded-xl bg-white pt-2 pb-1.5 flex flex-col gap-1"> + <div + className={cn( + "pt-2 pb-1.5 flex flex-col gap-1", + "rounded-[18px] border border-white/65 bg-white/60 shadow-[0_6px_18px_rgba(15,23,42,0.16),inset_0_1px_0_rgba(255,255,255,0.85),inset_0_-6px_14px_rgba(255,255,255,0.18)] backdrop-blur-2xl", + )} + > <textarea ref={textareaRef} rows={1} @@ -537,7 +663,10 @@ function TRChatInput({ type="button" onClick={handleAction} disabled={!isLoading && !value.trim()} - className="relative bg-gradient-to-b from-neutral-700 to-black text-white rounded-[10px] h-7 w-7 shrink-0 flex items-center justify-center disabled:cursor-default disabled:from-neutral-600 disabled:to-black border border-white/30 active:enabled:scale-95 transition-all duration-150" + className={cn( + "relative bg-gradient-to-b from-neutral-700 to-black text-white rounded-[10px] h-7 w-7 shrink-0 flex items-center justify-center disabled:cursor-default disabled:from-neutral-600 disabled:to-black border border-white/30 active:enabled:scale-95 transition-all duration-150", + "shadow-[0_5px_14px_rgba(15,23,42,0.18),inset_0_1px_0_rgba(255,255,255,0.24)]", + )} > {isLoading ? ( <Square @@ -930,7 +1059,7 @@ export function TRChatPanel({ .map((_, i) => i) .reverse() .find((i) => predicate(events[i])); - if (idx === undefined) return; + if (idx === undefined) return false; const newEvents = [...events]; newEvents[idx] = updater(events[idx]); eventsRef.current = newEvents; @@ -943,6 +1072,7 @@ export function TRChatPanel({ } return updated; }); + return true; } // ---- chat actions ---- @@ -1225,6 +1355,295 @@ export function TRChatPanel({ continue; } + if ( + data.type === "courtlistener_search_case_law_start" + ) { + pushEvent({ + type: "courtlistener_search_case_law", + query: (data.query as string) ?? "", + isStreaming: true, + }); + continue; + } + + if (data.type === "courtlistener_search_case_law") { + updateMatchingEvent( + (e) => + e.type === + "courtlistener_search_case_law" && + e.query === (data.query as string) && + !!e.isStreaming, + () => ({ + type: "courtlistener_search_case_law", + query: (data.query as string) ?? "", + result_count: + typeof data.result_count === "number" + ? (data.result_count as number) + : 0, + error: + typeof data.error === "string" + ? (data.error as string) + : undefined, + isStreaming: false, + }), + ); + pushThinkingPlaceholder(); + continue; + } + + if (data.type === "courtlistener_get_cases_start") { + pushEvent({ + type: "courtlistener_get_cases", + cluster_ids: Array.isArray(data.cluster_ids) + ? (data.cluster_ids as unknown[]).filter( + (value: unknown): value is number => + typeof value === "number", + ) + : [], + isStreaming: true, + }); + continue; + } + + if (data.type === "courtlistener_get_cases") { + updateMatchingEvent( + (e) => + e.type === "courtlistener_get_cases" && + !!e.isStreaming, + () => ({ + type: "courtlistener_get_cases", + cluster_ids: Array.isArray(data.cluster_ids) + ? ( + data.cluster_ids as unknown[] + ).filter( + ( + value: unknown, + ): value is number => + typeof value === "number", + ) + : [], + case_count: + typeof data.case_count === "number" + ? (data.case_count as number) + : 0, + opinion_count: + typeof data.opinion_count === "number" + ? (data.opinion_count as number) + : 0, + cases: parseCourtlistenerEventCases( + data.cases, + ), + error: + typeof data.error === "string" + ? (data.error as string) + : undefined, + isStreaming: false, + }), + ); + pushThinkingPlaceholder(); + continue; + } + + if ( + data.type === "courtlistener_find_in_case_start" + ) { + const searches = parseCourtlistenerCaseSearches( + data.searches, + ); + pushEvent({ + type: "courtlistener_find_in_case", + cluster_id: searches?.length + ? null + : typeof data.cluster_id === "number" + ? (data.cluster_id as number) + : null, + query: searches?.length + ? "" + : ((data.query as string) ?? ""), + searches, + isStreaming: true, + }); + continue; + } + + if (data.type === "courtlistener_find_in_case") { + const searches = parseCourtlistenerCaseSearches( + data.searches, + ); + updateMatchingEvent( + (e) => + e.type === + "courtlistener_find_in_case" && + (searches?.length + ? Array.isArray(e.searches) + : e.cluster_id === + (typeof data.cluster_id === + "number" + ? (data.cluster_id as number) + : null) && + e.query === + (data.query as string)) && + !!e.isStreaming, + () => ({ + type: "courtlistener_find_in_case", + cluster_id: searches?.length + ? null + : typeof data.cluster_id === "number" + ? (data.cluster_id as number) + : null, + query: searches?.length + ? "" + : ((data.query as string) ?? ""), + total_matches: + typeof data.total_matches === "number" + ? (data.total_matches as number) + : 0, + searches, + case_name: + typeof data.case_name === "string" + ? (data.case_name as string) + : null, + citation: + typeof data.citation === "string" + ? (data.citation as string) + : null, + error: + typeof data.error === "string" + ? (data.error as string) + : undefined, + isStreaming: false, + }), + ); + pushThinkingPlaceholder(); + continue; + } + + if (data.type === "courtlistener_read_case_start") { + pushEvent({ + type: "courtlistener_read_case", + cluster_id: + typeof data.cluster_id === "number" + ? (data.cluster_id as number) + : null, + isStreaming: true, + }); + continue; + } + + if (data.type === "courtlistener_read_case") { + updateMatchingEvent( + (e) => + e.type === "courtlistener_read_case" && + e.cluster_id === + (typeof data.cluster_id === "number" + ? (data.cluster_id as number) + : null) && + !!e.isStreaming, + () => ({ + type: "courtlistener_read_case", + cluster_id: + typeof data.cluster_id === "number" + ? (data.cluster_id as number) + : null, + case_name: + typeof data.case_name === "string" + ? (data.case_name as string) + : null, + citation: + typeof data.citation === "string" + ? (data.citation as string) + : null, + opinion_count: + typeof data.opinion_count === "number" + ? (data.opinion_count as number) + : 0, + error: + typeof data.error === "string" + ? (data.error as string) + : undefined, + isStreaming: false, + }), + ); + pushThinkingPlaceholder(); + continue; + } + + if ( + data.type === "courtlistener_verify_citations_start" + ) { + pushEvent({ + type: "courtlistener_verify_citations", + citation_count: + typeof data.citation_count === "number" + ? (data.citation_count as number) + : 0, + isStreaming: true, + }); + continue; + } + + if (data.type === "courtlistener_verify_citations") { + updateMatchingEvent( + (e) => + e.type === + "courtlistener_verify_citations" && + !!e.isStreaming, + () => ({ + type: "courtlistener_verify_citations", + citation_count: + typeof data.citation_count === "number" + ? (data.citation_count as number) + : 0, + match_count: + typeof data.match_count === "number" + ? (data.match_count as number) + : 0, + error: + typeof data.error === "string" + ? (data.error as string) + : undefined, + isStreaming: false, + }), + ); + pushThinkingPlaceholder(); + continue; + } + + if (data.type === "case_citation") { + pushEvent({ + type: "case_citation", + cluster_id: + typeof data.cluster_id === "number" + ? (data.cluster_id as number) + : null, + case_name: + typeof data.case_name === "string" + ? (data.case_name as string) + : null, + citation: + typeof data.citation === "string" + ? (data.citation as string) + : null, + url: data.url as string, + }); + continue; + } + + if (data.type === "case_opinions") { + pushEvent({ + type: "case_opinions", + cluster_id: + typeof data.cluster_id === "number" + ? (data.cluster_id as number) + : 0, + case: data.case as Extract< + AssistantEvent, + { type: "case_opinions" } + >["case"], + }); + continue; + } + if (data.type === "doc_read_start") { pushEvent({ type: "doc_read", @@ -1337,7 +1756,10 @@ export function TRChatPanel({ return ( <div style={{ width: panelWidth }} - className="shrink-0 flex flex-col border-r border-gray-200 bg-white h-full relative" + className={cn( + "shrink-0 flex flex-col border-r border-gray-200 h-full relative", + "bg-transparent", + )} > {/* Resize handle */} <div @@ -1352,9 +1774,15 @@ export function TRChatPanel({ }`} /> {/* Header */} - <div className="flex items-center justify-between h-8 px-2 border-b border-gray-200 shrink-0"> - <div className="flex items-center gap-1.5 px-2 min-w-0"> - <MikeIcon mike size={14} /> + <div className="flex items-center justify-between h-8 pr-2 border-b border-gray-200 shrink-0"> + <div className="flex items-center gap-1 pl-2 pr-2 min-w-0"> + <button + onClick={onClose} + title="Close" + className="flex items-center justify-center h-7 w-7 shrink-0 rounded-md text-gray-600 hover:text-gray-900 transition-colors" + > + <ChevronLeft className="h-3.5 w-3.5" /> + </button> <div onMouseEnter={(e) => { const el = e.currentTarget; @@ -1374,7 +1802,7 @@ export function TRChatPanel({ className="min-w-0 overflow-x-hidden whitespace-nowrap scrollbar-none" > <span className="text-xs font-medium text-gray-700"> - {currentChatTitle ?? "Assistant"} + {currentChatTitle ?? "New chat"} </span> </div> </div> @@ -1383,7 +1811,7 @@ export function TRChatPanel({ <button onClick={() => setHistoryOpen((v) => !v)} title="Chat history" - className={`flex items-center justify-center h-7 w-7 rounded-md transition-colors ${historyOpen ? "text-gray-900" : "text-gray-400 hover:text-gray-700"}`} + className={`flex items-center justify-center h-7 w-7 rounded-md transition-colors ${historyOpen ? "text-gray-900" : "text-gray-600 hover:text-gray-900"}`} > <Clock className="h-3.5 w-3.5" /> </button> @@ -1400,7 +1828,7 @@ export function TRChatPanel({ <button onClick={handleNewChat} title="New chat" - className="flex items-center justify-center h-7 w-7 rounded-md text-gray-400 hover:text-gray-700 transition-colors" + className="flex items-center justify-center h-7 w-7 rounded-md text-gray-600 hover:text-gray-900 transition-colors" > <MessageSquarePlus className="h-3.5 w-3.5" /> </button> @@ -1408,18 +1836,11 @@ export function TRChatPanel({ <button onClick={handleDeleteChat} title="Delete chat" - className="flex items-center justify-center h-7 w-7 rounded-md text-gray-400 hover:text-red-600 transition-colors" + className="flex items-center justify-center h-7 w-7 rounded-md text-gray-600 hover:text-red-600 transition-colors" > <Trash2 className="h-3.5 w-3.5" /> </button> )} - <button - onClick={onClose} - title="Close" - className="flex items-center justify-center h-7 w-7 rounded-md text-gray-400 hover:text-gray-700 transition-colors" - > - <X className="h-3.5 w-3.5" /> - </button> </div> </div> @@ -1432,7 +1853,7 @@ export function TRChatPanel({ {messages.length === 0 && !isLoadingMessages && ( <div className="flex flex-1 flex-col items-center justify-center gap-2"> <MikeIcon size={24} /> - <p className="text-sm text-gray-400 text-center"> + <p className="text-gray-400 font-serif text-center"> Ask a question about this tabular review. </p> </div> diff --git a/frontend/src/app/components/tabular/TREditColumnMenu.tsx b/frontend/src/app/components/tabular/TREditColumnMenu.tsx index b16ccb5..46b4831 100644 --- a/frontend/src/app/components/tabular/TREditColumnMenu.tsx +++ b/frontend/src/app/components/tabular/TREditColumnMenu.tsx @@ -85,8 +85,6 @@ export function TREditColumnMenu({ setSaving(false); } } - console.log(tags); - async function handleDelete() { setDeleting(true); try { diff --git a/frontend/src/app/components/tabular/TRSidePanel.tsx b/frontend/src/app/components/tabular/TRSidePanel.tsx index 9a6763a..9bcac49 100644 --- a/frontend/src/app/components/tabular/TRSidePanel.tsx +++ b/frontend/src/app/components/tabular/TRSidePanel.tsx @@ -12,11 +12,16 @@ import { RefreshCw, X, } from "lucide-react"; -import type { ColumnConfig, MikeDocument, TabularCell } from "../shared/types"; +import type { + ColumnConfig, + Document, + TabularCell, +} from "../shared/types"; import { preprocessCitations, type ParsedCitation } from "./citation-utils"; import { getPillClass } from "./pillUtils"; import { DocView } from "../shared/DocView"; import { DocxView } from "../shared/DocxView"; +import { cn } from "@/lib/utils"; function isDocxDocument(d: { file_type?: string | null; @@ -30,7 +35,7 @@ function isDocxDocument(d: { interface Props { cell: TabularCell; - document: MikeDocument; + document: Document; column: ColumnConfig; columns: ColumnConfig[]; onClose: () => void; @@ -109,22 +114,16 @@ export function TRSidePanel({ const { processed: reasoningText, citations: reasoningCitations } = preprocessCitations(cell.content?.reasoning ?? ""); - useEffect(() => { - console.log("[TRSidePanel] summary:", cell.content?.summary ?? ""); - }, [cell.id, cell.content?.summary]); - return ( <div - className="fixed right-0 top-0 bottom-0 z-100 flex flex-row shadow-md border-l border-gray-200" - style={{ - background: "rgba(255,255,255,0.08)", - backdropFilter: "blur(10px) saturate(50%)", - WebkitBackdropFilter: "blur(10px) saturate(50%)", - }} + className={cn( + "fixed z-100 flex flex-row", + "right-3 top-3 bottom-3 overflow-hidden rounded-2xl border border-white/70 bg-white/20 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", + )} > {/* Document panel — left, 600px */} {docCitation !== undefined && ( - <div className="relative flex w-[600px] shrink-0 flex-col border-r border-white/30 px-3"> + <div className="relative flex w-[600px] shrink-0 flex-col border-r border-white/30 px-3 pb-3"> {/* Doc header */} <div className="flex items-center gap-2 pt-3 shrink-0 border-b border-white/30"> <p @@ -255,7 +254,9 @@ export function TRSidePanel({ </span> </div> {/* Document name */} - <p className="text-xs mb-4">{doc.filename}</p> + <p className="text-xs mb-4"> + {doc.filename} + </p> {/* Flag section */} {cell.content?.flag && ( diff --git a/frontend/src/app/components/tabular/TRTable.tsx b/frontend/src/app/components/tabular/TRTable.tsx index 43ab9d1..9317183 100644 --- a/frontend/src/app/components/tabular/TRTable.tsx +++ b/frontend/src/app/components/tabular/TRTable.tsx @@ -2,7 +2,11 @@ import { forwardRef, useImperativeHandle, useRef } from "react"; import { Loader2, Plus, Table2, Upload } from "lucide-react"; -import type { ColumnConfig, MikeDocument, TabularCell } from "../shared/types"; +import type { + ColumnConfig, + Document, + TabularCell, +} from "../shared/types"; import { TabularCell as TabularCellComponent } from "./TabularCell"; import { TREditColumnMenu } from "./TREditColumnMenu"; @@ -10,13 +14,12 @@ const SKELETON_COLS = 4; const SKELETON_ROWS = 5; const COL_W = "w-[300px] shrink-0"; -const CHECK_W = "w-8 shrink-0"; +const DOC_COL_W = "w-[332px] shrink-0"; // Pixel widths matching the CSS constants above -const CHECK_W_PX = 32; // w-8 = 2rem = 32px -const DOC_COL_W_PX = 300; +const DOC_COL_W_PX = 332; const DATA_COL_W_PX = 300; -const STICKY_LEFT_PX = CHECK_W_PX + DOC_COL_W_PX; // 332px +const STICKY_LEFT_PX = DOC_COL_W_PX; export interface TRTableHandle { scrollToCell: (colIdx: number, rowIdx: number) => void; @@ -25,7 +28,7 @@ export interface TRTableHandle { interface Props { loading: boolean; columns: ColumnConfig[]; - documents: MikeDocument[]; + documents: Document[]; cells: TabularCell[]; savingColumn: boolean; savingColumnsConfig: boolean; @@ -64,10 +67,11 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable( }, ref, ) { + const stickyCellBg = "bg-[#fcfcfd]"; const scrollContainerRef = useRef<HTMLDivElement>(null); const sortedColumns = [...columns].sort((a, b) => a.index - b.index); const totalContentWidth = - CHECK_W_PX + DOC_COL_W_PX + sortedColumns.length * DATA_COL_W_PX + 32; + DOC_COL_W_PX + sortedColumns.length * DATA_COL_W_PX + 32; useImperativeHandle(ref, () => ({ scrollToCell(colIdx: number, rowIdx: number) { @@ -130,12 +134,10 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable( {/* Header */} <div className="flex border-b border-gray-200"> <div - className={`${CHECK_W} border-r border-gray-200 p-2`} - /> - <div - className={`${COL_W} border-r border-gray-200 p-2 text-xs font-medium text-gray-500`} + className={`${DOC_COL_W} flex items-center gap-4 border-r border-gray-200 py-2 pl-4 pr-2 text-xs font-medium text-gray-500`} > - Document + <div className="h-2.5 w-2.5 shrink-0 rounded bg-gray-100 animate-pulse" /> + <span>Document</span> </div> {Array.from({ length: SKELETON_COLS }).map((_, i) => ( <div @@ -151,10 +153,10 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable( {Array.from({ length: SKELETON_ROWS }).map((_, row) => ( <div key={row} - className={`flex border-b border-gray-50 ${row % 2 === 0 ? "bg-white" : "bg-gray-50/50"}`} + className={`flex border-b border-gray-50 ${row % 2 === 0 ? "" : "bg-gray-50/50"}`} > - <div className={`${CHECK_W} p-2`} /> - <div className={`${COL_W} p-2`}> + <div className={`${DOC_COL_W} flex items-center gap-4 py-2 pl-4 pr-2`}> + <div className="h-2.5 w-2.5 shrink-0 rounded bg-gray-100 animate-pulse" /> <div className="h-4 w-32 rounded bg-gray-100 animate-pulse" /> </div> {Array.from({ length: SKELETON_COLS }).map((_, col) => ( @@ -177,9 +179,8 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable( return ( <div className="flex flex-1 flex-col overflow-hidden"> <div className="flex items-center border-b border-gray-200"> - <div className={`${CHECK_W} border-r border-gray-200`} /> <div - className={`${COL_W} border-r border-gray-200 p-2 text-xs font-medium text-gray-500 select-none`} + className={`${DOC_COL_W} border-r border-gray-200 py-2 pl-4 pr-2 text-xs font-medium text-gray-500 select-none`} > Document </div> @@ -225,11 +226,11 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable( > {/* Header */} <div - className="sticky top-0 z-20 flex bg-white h-8" + className={`sticky top-0 z-20 flex h-8 ${stickyCellBg}`} style={{ minWidth: totalContentWidth }} > <div - className={`sticky left-0 z-30 ${CHECK_W} bg-white border-b border-r border-gray-200 flex justify-center items-center select-none`} + className={`sticky left-0 z-30 ${DOC_COL_W} ${stickyCellBg} border-b border-r border-gray-200 flex items-center gap-4 py-2 pl-4 pr-2 text-left text-xs font-medium text-gray-500 select-none`} > <input type="checkbox" @@ -240,11 +241,7 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable( onChange={toggleAll} className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black" /> - </div> - <div - className={`sticky left-8 z-30 ${COL_W} bg-white border-b border-r border-gray-200 p-2 text-left text-xs font-medium text-gray-500 select-none`} - > - Document + <span>Document</span> </div> {columns.map((col) => ( <div @@ -281,21 +278,17 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable( {uploadingFilenames.map((filename) => ( <div key={`uploading-${filename}`} - className="flex bg-white" + className="flex" style={{ minWidth: totalContentWidth }} > <div - className={`sticky left-0 z-[60] ${CHECK_W} border-b border-r border-gray-200 p-2 flex items-center justify-center bg-white`} + className={`sticky left-0 z-[60] ${DOC_COL_W} ${stickyCellBg} border-b border-r border-gray-200 py-2 pl-4 pr-2 text-xs text-gray-400 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" /> - </div> - <div - className={`sticky left-8 z-[60] ${COL_W} border-b border-r border-gray-200 p-2 text-xs text-gray-400 flex items-center gap-2 bg-white`} - > <Loader2 className="h-3.5 w-3.5 animate-spin shrink-0" /> <span className="line-clamp-1" title={filename}> {filename} @@ -314,7 +307,7 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable( ))} {documents.map((doc, docIdx) => { const baseRowBg = - docIdx % 2 === 0 ? "bg-white" : "bg-gray-50"; + docIdx % 2 === 0 ? stickyCellBg : "bg-gray-50"; const rowBg = selectedDocIds.includes(doc.id) ? "bg-gray-100" : baseRowBg; @@ -325,7 +318,7 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable( style={{ minWidth: totalContentWidth }} > <div - className={`sticky left-0 z-[60] ${CHECK_W} border-b border-r border-gray-200 p-2 flex items-center justify-center ${rowBg}`} + className={`sticky left-0 z-[60] ${DOC_COL_W} border-b border-r border-gray-200 py-2 pl-4 pr-2 text-xs text-gray-800 flex items-center gap-4 ${rowBg}`} > <input type="checkbox" @@ -333,10 +326,6 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable( onChange={() => toggleDoc(doc.id)} className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-pointer accent-black" /> - </div> - <div - className={`sticky left-8 z-[60] ${COL_W} border-b border-r border-gray-200 p-2 text-xs text-gray-800 flex items-center ${baseRowBg}`} - > <span className="line-clamp-1" title={doc.filename} diff --git a/frontend/src/app/components/tabular/TabularReviewView.tsx b/frontend/src/app/components/tabular/TabularReviewView.tsx index cd59d12..3cf9642 100644 --- a/frontend/src/app/components/tabular/TabularReviewView.tsx +++ b/frontend/src/app/components/tabular/TabularReviewView.tsx @@ -2,8 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; -import { Plus, Loader2, Play, ChevronDown, MessageSquare, Download, Users, Upload } from "lucide-react"; -import { HeaderSearchBtn } from "../shared/HeaderSearchBtn"; +import { Plus, Loader2, Play, ChevronDown, MessageSquare, Download, Users, Upload, X } from "lucide-react"; import { clearTabularCells, @@ -17,8 +16,8 @@ import { } from "@/app/lib/mikeApi"; import type { ColumnConfig, - MikeDocument, - MikeProject, + Document, + Project, TabularCell, TabularReview, } from "../shared/types"; @@ -42,6 +41,7 @@ import type { TRTableHandle } from "./TRTable"; import { TRChatPanel } from "./TRChatPanel"; import { exportTabularReviewToExcel } from "./exportToExcel"; import { useSidebar } from "@/app/contexts/SidebarContext"; +import { PageHeader } from "../shared/PageHeader"; interface Props { reviewId: string; @@ -51,9 +51,9 @@ interface Props { export function TRView({ reviewId, projectId }: Props) { const { setSidebarOpen } = useSidebar(); const [review, setReview] = useState<TabularReview | null>(null); - const [project, setProject] = useState<MikeProject | null>(null); + const [project, setProject] = useState<Project | null>(null); const [cells, setCells] = useState<TabularCell[]>([]); - const [documents, setDocuments] = useState<MikeDocument[]>([]); + const [documents, setDocuments] = useState<Document[]>([]); const [columns, setColumns] = useState<ColumnConfig[]>([]); const [loading, setLoading] = useState(true); const [generating, setGenerating] = useState(false); @@ -160,7 +160,7 @@ export function TRView({ reviewId, projectId }: Props) { } } - async function handleAddDocuments(newDocs: MikeDocument[]) { + async function handleAddDocuments(newDocs: Document[]) { const toAdd = newDocs.filter( (d) => !documents.some((existing) => existing.id === d.id), ); @@ -201,7 +201,7 @@ export function TRView({ reviewId, projectId }: Props) { if (files.length === 0) return; setUploadingDroppedFilenames(files.map((file) => file.name)); try { - const uploaded: MikeDocument[] = []; + const uploaded: Document[] = []; const documentIds = documents.map((document) => document.id); for (const file of files) { const document = await uploadReviewDocument(reviewId, file, { @@ -526,135 +526,123 @@ export function TRView({ reviewId, projectId }: Props) { : documents; return ( - <div className="flex h-full overflow-hidden bg-white"> + <div className="flex h-full overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden"> {/* Header */} - <div className="mb-1 bg-white px-4 py-3 md:px-10 flex items-start justify-between shrink-0 gap-4"> - <div className="flex items-center gap-1.5 text-2xl font-medium font-serif"> - {projectId && ( - <> - <button - onClick={() => router.push("/projects")} - className="text-gray-500 hover:text-gray-700 transition-colors" - > - Projects - </button> - <span className="text-gray-300">›</span> - <button - onClick={() => - router.push(`/projects/${projectId}`) - } - className="text-gray-500 hover:text-gray-700 transition-colors" - > - {loading ? ( - <div className="h-6 w-32 rounded bg-gray-100 animate-pulse" /> - ) : ( - <> - {project?.name ?? ""} - {project?.cm_number && ( + <PageHeader + align="start" + shrink + className="gap-4" + breadcrumbs={[ + ...(projectId + ? [ + { + label: "Projects", + onClick: () => router.push("/projects"), + }, + loading + ? { + loading: true, + skeletonClassName: "w-32", + onClick: () => + router.push(`/projects/${projectId}`), + title: "Back to project", + } + : { + label: project?.name ?? "", + suffix: project?.cm_number ? ( <span className="ml-1 text-gray-400"> (#{project.cm_number}) </span> - )} - </> - )} - </button> - <span className="text-gray-300">›</span> - <button - onClick={() => - router.push( - `/projects/${projectId}?tab=reviews`, - ) - } - className="text-gray-500 hover:text-gray-700 transition-colors" - > - Tabular Reviews - </button> - </> - )} - {!projectId && ( - <button - onClick={() => router.push("/tabular-reviews")} - className="text-gray-500 hover:text-gray-700 transition-colors" - > - Tabular Reviews - </button> - )} - <span className="text-gray-300">›</span> - {loading ? ( - <div className="h-6 w-40 rounded bg-gray-100 animate-pulse" /> - ) : ( - <RenameableTitle - value={review?.title || "Untitled Review"} - onCommit={handleTitleCommit} - /> - )} - </div> - {!loading && ( - <div className="flex items-center gap-2"> - <HeaderSearchBtn value={search} onChange={setSearch} placeholder="Search documents…" /> - {!projectId && ( - <button - onClick={() => setPeopleModalOpen(true)} - disabled={loading} - className={`flex h-8 w-8 items-center justify-center text-sm transition-colors ${ - loading - ? "text-gray-300 cursor-default" - : "text-gray-500 hover:text-gray-900 cursor-pointer" - }`} - title="People with access" - aria-label="People with access" - > - <Users className="h-4 w-4" /> - </button> - )} - <button - onClick={() => - exportTabularReviewToExcel({ - reviewTitle: review?.title || "Tabular Review", - columns, - documents, - cells, - }) - } - disabled={columns.length === 0 || documents.length === 0} - title="Export to Excel" - className={`flex h-8 items-center justify-center gap-1.5 px-3 text-sm transition-colors ${ - columns.length === 0 || documents.length === 0 - ? "text-gray-300 cursor-default" - : "text-gray-700 hover:text-gray-900 cursor-pointer" - }`} - > - <Download className="h-4 w-4" /> - Export - </button> - <button - onClick={handleGenerate} - disabled={ - generating || - columns.length === 0 || - documents.length === 0 || - savingColumnsConfig - } - className={`flex h-8 items-center justify-center gap-1.5 px-3 text-sm transition-colors ${ - generating || - columns.length === 0 || - documents.length === 0 || - savingColumnsConfig - ? "text-gray-300 cursor-default" - : "text-gray-700 hover:text-gray-900 cursor-pointer" - }`} - > - {generating ? ( - <Loader2 className="h-4 w-4 animate-spin" /> - ) : ( - <Play className="h-4 w-4" /> - )} - {generating ? "Running…" : "Run"} - </button> - </div> - )} - </div> + ) : null, + onClick: () => + router.push(`/projects/${projectId}`), + title: "Back to project", + }, + ] + : [ + { + label: "Tabular Reviews", + onClick: () => router.push("/tabular-reviews"), + title: "Back to Tabular Reviews", + }, + ]), + loading + ? { + loading: true, + skeletonClassName: "w-40", + } + : { + label: ( + <RenameableTitle + value={review?.title || "Untitled Review"} + onCommit={handleTitleCommit} + /> + ), + }, + ]} + 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 + } + /> {/* Toolbar */} <div className="flex items-center h-10 px-4 md:px-10 border-b border-gray-200 gap-4"> @@ -671,8 +659,12 @@ export function TRView({ reviewId, projectId }: Props) { : "text-gray-700 hover:text-gray-900" }`} > - <MessageSquare className="h-3.5 w-3.5" /> - Assistant in Tabular Review + {chatOpen ? ( + <X className="h-3.5 w-3.5" /> + ) : ( + <MessageSquare className="h-3.5 w-3.5" /> + )} + Assistant </button> <div className="ml-auto flex items-center gap-5"> {loading ? ( @@ -870,7 +862,7 @@ export function TRView({ reviewId, projectId }: Props) { <AddProjectDocsModal open={addDocsOpen} onClose={() => setAddDocsOpen(false)} - onSelect={(docs: MikeDocument[]) => + onSelect={(docs: Document[]) => handleAddDocuments(docs) } breadcrumb={[ @@ -890,7 +882,7 @@ export function TRView({ reviewId, projectId }: Props) { <AddDocumentsModal open={addDocsOpen} onClose={() => setAddDocsOpen(false)} - onSelect={(docs: MikeDocument[]) => + onSelect={(docs: Document[]) => handleAddDocuments(docs) } breadcrumb={[ diff --git a/frontend/src/app/components/tabular/exportToExcel.ts b/frontend/src/app/components/tabular/exportToExcel.ts index e11353c..6ba577e 100644 --- a/frontend/src/app/components/tabular/exportToExcel.ts +++ b/frontend/src/app/components/tabular/exportToExcel.ts @@ -1,7 +1,11 @@ "use client"; import ExcelJS from "exceljs"; -import type { ColumnConfig, MikeDocument, TabularCell } from "../shared/types"; +import type { + ColumnConfig, + Document, + TabularCell, +} from "../shared/types"; import { preprocessCitations } from "./citation-utils"; function formatCellForExport(cell: TabularCell | undefined): string { @@ -31,7 +35,7 @@ function sanitizeFilename(name: string): string { export async function exportTabularReviewToExcel(params: { reviewTitle: string; columns: ColumnConfig[]; - documents: MikeDocument[]; + documents: Document[]; cells: TabularCell[]; }) { const { reviewTitle, columns, documents, cells } = params; diff --git a/frontend/src/app/components/workflows/DisplayWorkflowModal.tsx b/frontend/src/app/components/workflows/DisplayWorkflowModal.tsx index 4755fb1..b5719f5 100644 --- a/frontend/src/app/components/workflows/DisplayWorkflowModal.tsx +++ b/frontend/src/app/components/workflows/DisplayWorkflowModal.tsx @@ -12,18 +12,21 @@ import { } from "lucide-react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; -import type { MikeDocument, MikeWorkflow } from "../shared/types"; +import type { + Document, + Workflow, +} from "../shared/types"; import { createTabularReview } from "@/app/lib/mikeApi"; import { useRouter } from "next/navigation"; import { formatIcon, formatLabel } from "../tabular/columnFormat"; import { useDirectoryData } from "../shared/useDirectoryData"; import { FileDirectory } from "../shared/FileDirectory"; -import type { MikeProject } from "../shared/types"; +import type { Project } from "../shared/types"; import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext"; interface Props { - workflows: MikeWorkflow[]; - workflow: MikeWorkflow | null; + workflows: Workflow[]; + workflow: Workflow | null; onClose: () => void; } @@ -52,7 +55,7 @@ function SimpleProjectPicker({ selectedId, onSelect, }: { - projects: MikeProject[]; + projects: Project[]; selectedId: string | null; onSelect: (id: string | null) => void; }) { @@ -172,7 +175,7 @@ function MarkdownBody({ content }: { content: string }) { // --------------------------------------------------------------------------- // Right panel for assistant workflows (select screen) // --------------------------------------------------------------------------- -function AssistantPanel({ workflow }: { workflow: MikeWorkflow }) { +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="py-3 shrink-0"> @@ -192,7 +195,7 @@ function AssistantPanel({ workflow }: { workflow: MikeWorkflow }) { // --------------------------------------------------------------------------- // Right panel for tabular workflows — accordion column list (select screen) // --------------------------------------------------------------------------- -function TabularPanel({ workflow }: { workflow: MikeWorkflow }) { +function TabularPanel({ workflow }: { workflow: Workflow }) { const [expandedIndex, setExpandedIndex] = useState<number | null>(null); const columns = (workflow.columns_config ?? []).sort( (a, b) => a.index - b.index, @@ -283,7 +286,7 @@ function TabularPanel({ workflow }: { workflow: MikeWorkflow }) { // --------------------------------------------------------------------------- export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) { const [screen, setScreen] = useState<"select" | "configure">("select"); - const [selected, setSelected] = useState<MikeWorkflow | null>(workflow); + const [selected, setSelected] = useState<Workflow | null>(workflow); const [listSearch, setListSearch] = useState(""); const selectedRowRef = useRef<HTMLButtonElement>(null); @@ -352,13 +355,16 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) { const projectId = inProject ? selectedProjectId! : undefined; const chatId = await saveChat(projectId); if (!chatId) return; - const allDocs: MikeDocument[] = [ + const allDocs: Document[] = [ ...standaloneDocuments, ...projects.flatMap((p) => p.documents || []), ]; const files = allDocs .filter((d) => selectedDocIds.has(d.id)) - .map((d) => ({ filename: d.filename, document_id: d.id })); + .map((d) => ({ + filename: d.filename, + document_id: d.id, + })); const content = assistantPrompt.trim() ? `implement workflow\n\n${assistantPrompt.trim()}` : "implement workflow"; @@ -381,7 +387,7 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) { } async function handleCreateReview() { - const allDocs: MikeDocument[] = [ + const allDocs: Document[] = [ ...standaloneDocuments, ...projects.flatMap((p) => p.documents || []), ]; @@ -418,7 +424,9 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) { const projectDocs = selectedProject?.documents ?? []; const filteredProjectDocs = q - ? projectDocs.filter((d) => d.filename.toLowerCase().includes(q)) + ? projectDocs.filter((d) => + d.filename.toLowerCase().includes(q), + ) : projectDocs; const filteredStandalone = q @@ -431,7 +439,8 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) { .map((p) => ({ ...p, documents: (p.documents || []).filter( - (d) => !q || d.filename.toLowerCase().includes(q), + (d) => + !q || d.filename.toLowerCase().includes(q), ), })) .filter( diff --git a/frontend/src/app/components/workflows/NewWorkflowModal.tsx b/frontend/src/app/components/workflows/NewWorkflowModal.tsx index 091073b..82e44f1 100644 --- a/frontend/src/app/components/workflows/NewWorkflowModal.tsx +++ b/frontend/src/app/components/workflows/NewWorkflowModal.tsx @@ -1,17 +1,18 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { X, MessageSquare, Table2 } from "lucide-react"; +import { MessageSquare, Table2 } from "lucide-react"; import { createWorkflow, updateWorkflow } from "@/app/lib/mikeApi"; -import type { MikeWorkflow } from "../shared/types"; +import type { Workflow } from "../shared/types"; import { PRACTICE_OPTIONS } from "./practices"; +import { Modal } from "../shared/Modal"; interface Props { open: boolean; onClose: () => void; - onCreated: (workflow: MikeWorkflow) => void; - editWorkflow?: MikeWorkflow; - onUpdated?: (workflow: MikeWorkflow) => void; + onCreated: (workflow: Workflow) => void; + editWorkflow?: Workflow; + onUpdated?: (workflow: Workflow) => void; } export function NewWorkflowModal({ open, onClose, onCreated, editWorkflow, onUpdated }: Props) { @@ -26,6 +27,7 @@ export function NewWorkflowModal({ open, onClose, onCreated, editWorkflow, onUpd const isEditing = !!editWorkflow; const isOthers = practice === "Others"; const effectivePractice = isOthers ? (customPractice.trim() || null) : (practice || null); + const formId = "workflow-modal-form"; useEffect(() => { if (open && editWorkflow) { @@ -95,124 +97,106 @@ export function NewWorkflowModal({ open, onClose, onCreated, editWorkflow, onUpd } return ( - <div className="fixed inset-0 z-101 flex items-center justify-center bg-black/20 backdrop-blur-xs"> - <div className="w-full max-w-2xl rounded-2xl bg-white shadow-2xl overflow-hidden flex flex-col" style={{ height: 600 }}> - {/* Header */} - <div className="flex items-center justify-between px-6 pt-5 pb-2 shrink-0"> - <div className="flex items-center gap-1.5 text-xs text-gray-400"> - <span>Workflows</span> - <span>›</span> - <span>{isEditing ? "Edit workflow" : "New workflow"}</span> + <Modal + open={open} + onClose={handleClose} + breadcrumbs={[ + "Workflows", + isEditing ? "Edit workflow" : "New workflow", + ]} + primaryAction={{ + label: loading + ? isEditing + ? "Saving…" + : "Creating…" + : isEditing + ? "Save changes" + : "Create workflow", + type: "submit", + form: formId, + disabled: !title.trim() || loading, + }} + > + <form + id={formId} + onSubmit={handleSubmit} + className="flex flex-col flex-1 min-h-0" + > + <input + type="text" + value={title} + onChange={(e) => setTitle(e.target.value)} + placeholder="Workflow name" + className="w-full text-2xl font-serif text-gray-800 placeholder-gray-300 focus:outline-none bg-transparent" + autoFocus + /> + + {!isEditing && ( + <div className="mt-5"> + <p className="mb-2 text-sm font-medium text-gray-500">Type</p> + <div className="flex items-center gap-2"> + <button + type="button" + onClick={() => setType("assistant")} + className={`flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs transition-colors ${ + type === "assistant" + ? "border-gray-900 bg-gray-900 text-white" + : "border-gray-200 text-gray-600 hover:bg-gray-50" + }`} + > + <MessageSquare className="h-3 w-3" /> + Assistant + </button> + <button + type="button" + onClick={() => setType("tabular")} + className={`flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs transition-colors ${ + type === "tabular" + ? "border-gray-900 bg-gray-900 text-white" + : "border-gray-200 text-gray-600 hover:bg-gray-50" + }`} + > + <Table2 className="h-3 w-3" /> + Tabular + </button> + </div> </div> - <button - onClick={handleClose} - className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 transition-colors" - > - <X className="h-4 w-4" /> - </button> + )} + + <div className="mt-5"> + <p className="mb-2 text-sm font-medium text-gray-500">Practice Area</p> + <div className="flex flex-wrap gap-2"> + {PRACTICE_OPTIONS.map((p) => ( + <button + key={p} + type="button" + onClick={() => setPractice(practice === p ? "" : p)} + className={`rounded-full border px-3 py-1 text-xs transition-colors ${ + practice === p + ? "border-gray-900 bg-gray-900 text-white" + : "border-gray-200 text-gray-600 hover:bg-gray-50" + }`} + > + {p} + </button> + ))} + </div> + {isOthers && ( + <input + ref={customInputRef} + type="text" + value={customPractice} + onChange={(e) => setCustomPractice(e.target.value)} + placeholder="Enter practice area…" + className="mt-3 w-full rounded-md border border-gray-200 px-3 py-1.5 text-sm text-gray-700 placeholder-gray-400 focus:border-gray-400 focus:outline-none" + /> + )} </div> - <form onSubmit={handleSubmit} className="flex flex-col flex-1 min-h-0"> - {/* Body */} - <div className="px-6 pt-3 pb-5 flex-1 overflow-y-auto"> - {/* Title */} - <input - type="text" - value={title} - onChange={(e) => setTitle(e.target.value)} - placeholder="Workflow name" - className="w-full text-2xl font-serif text-gray-800 placeholder-gray-300 focus:outline-none bg-transparent" - autoFocus - /> - - {/* Type pills — only shown when creating */} - {!isEditing && ( - <div className="mt-5"> - <p className="mb-2 text-sm font-medium text-gray-500">Type</p> - <div className="flex items-center gap-2"> - <button - type="button" - onClick={() => setType("assistant")} - className={`flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs transition-colors ${ - type === "assistant" - ? "border-gray-900 bg-gray-900 text-white" - : "border-gray-200 text-gray-600 hover:bg-gray-50" - }`} - > - <MessageSquare className="h-3 w-3" /> - Assistant - </button> - <button - type="button" - onClick={() => setType("tabular")} - className={`flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs transition-colors ${ - type === "tabular" - ? "border-gray-900 bg-gray-900 text-white" - : "border-gray-200 text-gray-600 hover:bg-gray-50" - }`} - > - <Table2 className="h-3 w-3" /> - Tabular - </button> - </div> - </div> - )} - - {/* Practice */} - <div className="mt-5"> - <p className="mb-2 text-sm font-medium text-gray-500">Practice Area</p> - <div className="flex flex-wrap gap-2"> - {PRACTICE_OPTIONS.map((p) => ( - <button - key={p} - type="button" - onClick={() => setPractice(practice === p ? "" : p)} - className={`rounded-full border px-3 py-1 text-xs transition-colors ${ - practice === p - ? "border-gray-900 bg-gray-900 text-white" - : "border-gray-200 text-gray-600 hover:bg-gray-50" - }`} - > - {p} - </button> - ))} - </div> - {isOthers && ( - <input - ref={customInputRef} - type="text" - value={customPractice} - onChange={(e) => setCustomPractice(e.target.value)} - placeholder="Enter practice area…" - className="mt-3 w-full rounded-md border border-gray-200 px-3 py-1.5 text-sm text-gray-700 placeholder-gray-400 focus:border-gray-400 focus:outline-none" - /> - )} - </div> - - {error && ( - <p className="mt-4 text-sm text-red-500">{error}</p> - )} - </div> - - {/* Footer */} - <div className="flex items-center justify-end gap-2 border-t border-gray-100 px-6 py-4 shrink-0"> - <button - type="button" - onClick={handleClose} - className="rounded-lg px-4 py-2 text-sm text-gray-500 hover:bg-gray-100 transition-colors" - > - Cancel - </button> - <button - type="submit" - disabled={!title.trim() || loading} - className="rounded-lg bg-gray-900 px-5 py-2 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-40 transition-colors" - > - {loading ? (isEditing ? "Saving…" : "Creating…") : (isEditing ? "Save changes" : "Create workflow")} - </button> - </div> - </form> - </div> - </div> + {error && ( + <p className="mt-4 text-sm text-red-500">{error}</p> + )} + </form> + </Modal> ); } diff --git a/frontend/src/app/components/workflows/ShareWorkflowModal.tsx b/frontend/src/app/components/workflows/ShareWorkflowModal.tsx index bfca0df..4783122 100644 --- a/frontend/src/app/components/workflows/ShareWorkflowModal.tsx +++ b/frontend/src/app/components/workflows/ShareWorkflowModal.tsx @@ -1,7 +1,6 @@ "use client"; import { useEffect, useState } from "react"; -import { createPortal } from "react-dom"; import { X } from "lucide-react"; import { deleteWorkflowShare, @@ -10,6 +9,7 @@ import { } from "@/app/lib/mikeApi"; import { useAuth } from "@/contexts/AuthContext"; import { EmailPillInput } from "../shared/EmailPillInput"; +import { Modal } from "../shared/Modal"; interface Share { id: string; @@ -67,103 +67,74 @@ export function ShareWorkflowModal({ } } - return createPortal( - <div className="fixed inset-0 z-[101] flex items-center justify-center bg-black/20 backdrop-blur-xs"> - <div className="w-full max-w-2xl rounded-2xl bg-white shadow-2xl flex flex-col h-[600px]"> - {/* Header */} - <div className="flex items-center justify-between px-5 py-4 border-b border-gray-100"> - <div className="flex items-center gap-1.5 text-xs text-gray-400"> - <span>Workflows</span> - <span>›</span> - <span className="truncate max-w-[220px]"> - {workflowName} - </span> - <span>›</span> - <span>People</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" /> + return ( + <Modal + open + onClose={onClose} + breadcrumbs={["Workflows", workflowName, "People"]} + primaryAction={{ + label: saving ? "Sharing…" : "Share", + onClick: handleConfirm, + disabled: saving || pendingEmails.length === 0, + }} + > + <EmailPillInput + emails={pendingEmails} + onChange={setPendingEmails} + validate={async (email) => + ownEmail && email === ownEmail + ? "You cannot share a workflow with yourself." + : null + } + placeholder="Add people by email…" + autoFocus + /> + + {/* Permission toggle */} + <div className="flex flex-col gap-2"> + <span className="text-xs font-medium text-gray-700">Allow editing by share recipients</span> + <button + type="button" + onClick={() => setAllowEdit((v) => !v)} + className={`relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors duration-200 ${allowEdit ? "bg-gray-900" : "bg-gray-200"}`} + > + <span className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transition-transform duration-200 ${allowEdit ? "translate-x-4" : "translate-x-0"}`} /> </button> </div> - <div className="px-5 py-4 flex flex-col gap-4 flex-1 overflow-y-auto"> - <EmailPillInput - emails={pendingEmails} - onChange={setPendingEmails} - validate={async (email) => - ownEmail && email === ownEmail - ? "You cannot share a workflow with yourself." - : null - } - placeholder="Add people by email…" - autoFocus - /> - - {/* Permission toggle */} - <div className="flex flex-col gap-2"> - <span className="text-xs font-medium text-gray-700">Allow editing by share recipients</span> - <button - type="button" - onClick={() => setAllowEdit((v) => !v)} - className={`relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors duration-200 ${allowEdit ? "bg-gray-900" : "bg-gray-200"}`} - > - <span className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transition-transform duration-200 ${allowEdit ? "translate-x-4" : "translate-x-0"}`} /> - </button> - </div> - - {/* Existing access */} - <div> - <p className="text-xs font-medium text-gray-700 mb-2">People with access</p> - {loading ? ( - <div className="space-y-2"> - {[1, 2].map((i) => ( - <div key={i} className="flex items-center justify-between"> - <div className="h-3 w-40 rounded bg-gray-100 animate-pulse" /> - <div className="h-3 w-16 rounded bg-gray-100 animate-pulse" /> - </div> - ))} - </div> - ) : existingShares.length === 0 ? ( - <p className="text-sm text-gray-400">None</p> - ) : ( - <div className="space-y-1"> - {existingShares.map((share) => ( - <div key={share.id} className="flex items-center justify-between py-1"> - <span className="text-sm text-gray-700 truncate">{share.shared_with_email}</span> - <div className="flex items-center gap-3 shrink-0"> - <span className="text-xs text-gray-400">{share.allow_edit ? "Can edit" : "Read-only"}</span> - <button - onClick={() => handleRemoveShare(share.id)} - className="text-gray-300 hover:text-red-500 transition-colors" - > - <X className="h-3.5 w-3.5" /> - </button> + {/* Existing access */} + <div> + <p className="text-xs font-medium text-gray-700 mb-2">People with access</p> + {loading ? ( + <div className="space-y-2"> + {[1, 2].map((i) => ( + <div key={i} className="flex items-center justify-between"> + <div className="h-3 w-40 rounded bg-gray-100 animate-pulse" /> + <div className="h-3 w-16 rounded bg-gray-100 animate-pulse" /> + </div> + ))} + </div> + ) : existingShares.length === 0 ? ( + <p className="text-sm text-gray-400">None</p> + ) : ( + <div className="space-y-1"> + {existingShares.map((share) => ( + <div key={share.id} className="flex items-center justify-between py-1"> + <span className="text-sm text-gray-700 truncate">{share.shared_with_email}</span> + <div className="flex items-center gap-3 shrink-0"> + <span className="text-xs text-gray-400">{share.allow_edit ? "Can edit" : "Read-only"}</span> + <button + onClick={() => handleRemoveShare(share.id)} + className="text-gray-300 hover:text-red-500 transition-colors" + > + <X className="h-3.5 w-3.5" /> + </button> </div> </div> ))} </div> )} </div> - </div> - - {/* Footer */} - <div className="border-t border-gray-100 px-5 py-3 flex justify-end gap-2 mt-auto shrink-0"> - <button - onClick={onClose} - className="rounded-lg px-5 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 transition-colors" - > - Cancel - </button> - <button - onClick={handleConfirm} - disabled={saving || pendingEmails.length === 0} - className="rounded-lg bg-gray-900 px-5 py-2 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-40 transition-colors" - > - {saving ? "Sharing…" : "Share"} - </button> - </div> - </div> - </div>, - document.body, + </Modal> ); } diff --git a/frontend/src/app/components/workflows/WFColumnViewModal.tsx b/frontend/src/app/components/workflows/WFColumnViewModal.tsx index 827bd90..31c03a2 100644 --- a/frontend/src/app/components/workflows/WFColumnViewModal.tsx +++ b/frontend/src/app/components/workflows/WFColumnViewModal.tsx @@ -1,11 +1,10 @@ "use client"; -import { createPortal } from "react-dom"; -import { X } from "lucide-react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import type { ColumnConfig } from "../shared/types"; import { formatIcon, formatLabel } from "../tabular/columnFormat"; +import { Modal } from "../shared/Modal"; interface Props { col: ColumnConfig; @@ -14,55 +13,46 @@ interface Props { export function WFColumnViewModal({ col, onClose }: Props) { const FormatIcon = formatIcon(col.format ?? "text"); - return createPortal( - <div className="fixed inset-0 z-[101] flex items-center justify-center bg-black/20 backdrop-blur-xs"> - <div className="w-full max-w-2xl rounded-2xl bg-white shadow-2xl flex flex-col h-[600px]"> - <div className="flex items-center justify-between px-6 pt-5 pb-2"> - <div className="flex items-center gap-1.5 text-xs text-gray-400"> - <span>Workflows</span> - <span>›</span> - <span className="truncate max-w-[200px] text-gray-600">{col.name}</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> + return ( + <Modal + open + onClose={onClose} + breadcrumbs={["Workflows", col.name]} + primaryAction={{ + label: "Close", + onClick: onClose, + }} + cancelAction={false} + > + <div className="flex flex-col gap-4"> + <div> + <p className="text-sm font-medium text-gray-500 mb-2">Column Title</p> + <p className="text-sm text-gray-800">{col.name}</p> </div> - <div className="px-6 pt-3 pb-5 flex flex-col gap-4 overflow-y-auto flex-1"> + <div> + <p className="text-sm font-medium text-gray-500 mb-2">Format</p> + <span className="inline-flex items-center gap-1.5 text-sm text-gray-700"> + <FormatIcon className="h-3.5 w-3.5 text-gray-400" /> + {formatLabel(col.format ?? "text")} + </span> + </div> + {col.tags && col.tags.length > 0 && ( <div> - <p className="text-sm font-medium text-gray-500 mb-2">Column Title</p> - <p className="text-sm text-gray-800">{col.name}</p> - </div> - <div> - <p className="text-sm font-medium text-gray-500 mb-2">Format</p> - <span className="inline-flex items-center gap-1.5 text-sm text-gray-700"> - <FormatIcon className="h-3.5 w-3.5 text-gray-400" /> - {formatLabel(col.format ?? "text")} - </span> - </div> - {col.tags && col.tags.length > 0 && ( - <div> - <p className="text-sm font-medium text-gray-500 mb-2.5">Tags</p> - <div className="flex flex-wrap gap-1.5"> - {col.tags.map((tag) => ( - <span key={tag} className="inline-block rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-600">{tag}</span> - ))} - </div> - </div> - )} - <div> - <p className="text-sm font-medium text-gray-500 mb-2">Prompt</p> - <div className="text-base text-gray-700 leading-relaxed font-serif prose prose-base max-w-none"> - <ReactMarkdown remarkPlugins={[remarkGfm]}>{col.prompt || "_No prompt defined._"}</ReactMarkdown> + <p className="text-sm font-medium text-gray-500 mb-2.5">Tags</p> + <div className="flex flex-wrap gap-1.5"> + {col.tags.map((tag) => ( + <span key={tag} className="inline-block rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-600">{tag}</span> + ))} </div> </div> - </div> - <div className="border-t border-gray-100 px-6 py-4 flex justify-end shrink-0"> - <button onClick={onClose} className="rounded-lg bg-gray-900 px-5 py-2 text-sm font-medium text-white hover:bg-gray-700"> - Close - </button> + )} + <div> + <p className="text-sm font-medium text-gray-500 mb-2">Prompt</p> + <div className="text-base text-gray-700 leading-relaxed font-serif prose prose-base max-w-none"> + <ReactMarkdown remarkPlugins={[remarkGfm]}>{col.prompt || "_No prompt defined._"}</ReactMarkdown> + </div> </div> </div> - </div>, - document.body, + </Modal> ); } diff --git a/frontend/src/app/components/workflows/WorkflowList.tsx b/frontend/src/app/components/workflows/WorkflowList.tsx index b0f1288..7f22994 100644 --- a/frontend/src/app/components/workflows/WorkflowList.tsx +++ b/frontend/src/app/components/workflows/WorkflowList.tsx @@ -3,7 +3,6 @@ import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { - Plus, Library, Table2, MessageSquare, @@ -11,7 +10,6 @@ import { ChevronDown, Check, } from "lucide-react"; -import { HeaderSearchBtn } from "../shared/HeaderSearchBtn"; import { listWorkflows, deleteWorkflow, @@ -19,7 +17,7 @@ import { hideWorkflow, unhideWorkflow, } from "@/app/lib/mikeApi"; -import type { MikeWorkflow } from "../shared/types"; +import type { Workflow } from "../shared/types"; import { BUILT_IN_WORKFLOWS, BUILT_IN_IDS } from "./builtinWorkflows"; import { DisplayWorkflowModal } from "./DisplayWorkflowModal"; import { NewWorkflowModal } from "./NewWorkflowModal"; @@ -27,11 +25,11 @@ import { ToolbarTabs } from "../shared/ToolbarTabs"; import { RowActions } from "../shared/RowActions"; import { MikeIcon } from "@/components/chat/mike-icon"; import { useAuth } from "@/contexts/AuthContext"; +import { PageHeader } from "@/app/components/shared/PageHeader"; type Tab = "all" | "builtin" | "custom" | "hidden"; -const CHECK_W = "w-8 shrink-0"; -const NAME_COL_W = "w-[300px] shrink-0"; +const NAME_COL_W = "w-[332px] shrink-0"; const TABS: { id: Tab; label: string }[] = [ { id: "all", label: "All" }, @@ -43,9 +41,10 @@ const TABS: { id: Tab; label: string }[] = [ export function WorkflowList() { const router = useRouter(); const { user } = useAuth(); - const [custom, setCustom] = useState<MikeWorkflow[]>([]); + const stickyCellBg = "bg-[#fcfcfd]"; + const [custom, setCustom] = useState<Workflow[]>([]); const [loading, setLoading] = useState(true); - const [selected, setSelected] = useState<MikeWorkflow | null>(null); + const [selected, setSelected] = useState<Workflow | null>(null); const [activeTab, setActiveTab] = useState<Tab>("all"); const [newModalOpen, setNewModalOpen] = useState(false); const [hiddenBuiltinIds, setHiddenBuiltinIds] = useState<string[]>([]); @@ -53,7 +52,7 @@ export function WorkflowList() { const [actionsOpen, setActionsOpen] = useState(false); const [practiceFilter, setPracticeFilter] = useState<string | null>(null); const [practiceFilterOpen, setPracticeFilterOpen] = useState(false); - const [typeFilter, setTypeFilter] = useState<MikeWorkflow["type"] | null>( + const [typeFilter, setTypeFilter] = useState<Workflow["type"] | null>( null, ); const [typeFilterOpen, setTypeFilterOpen] = useState(false); @@ -199,7 +198,7 @@ export function WorkflowList() { await Promise.all(ids.map((id) => unhideWorkflow(id).catch(() => {}))); } - const getTypeMeta = (type: MikeWorkflow["type"]) => + const getTypeMeta = (type: Workflow["type"]) => type === "tabular" ? { label: "Tabular", Icon: Table2, className: "text-violet-700" } : { @@ -358,26 +357,28 @@ export function WorkflowList() { ); return ( - <div className="flex flex-col flex-1 overflow-hidden bg-white"> + <div className="flex flex-col flex-1 overflow-hidden"> {/* Page header */} - <div className="mb-1 flex items-center justify-between px-4 py-3 md:px-10 shrink-0"> + <PageHeader + shrink + actions={[ + { + type: "search", + value: search, + onChange: setSearch, + placeholder: "Search workflows…", + }, + { + type: "new", + onClick: () => setNewModalOpen(true), + title: "New workflow", + }, + ]} + > <h1 className="text-2xl font-medium font-serif text-gray-900"> Workflows </h1> - <div className="flex items-center gap-2"> - <HeaderSearchBtn - value={search} - onChange={setSearch} - placeholder="Search workflows…" - /> - <button - onClick={() => setNewModalOpen(true)} - className="flex items-center justify-center p-1.5 text-gray-500 hover:text-gray-900 transition-colors" - > - <Plus className="h-4 w-4" /> - </button> - </div> - </div> + </PageHeader> <ToolbarTabs tabs={TABS} @@ -391,8 +392,10 @@ export function WorkflowList() { <div className="min-w-max"> {/* Column headers */} <div className="flex items-center h-8 pr-3 md:pr-10 border-b border-gray-200 text-xs text-gray-500 font-medium select-none"> - <div className={`sticky left-0 z-[60] ${CHECK_W} relative bg-white flex items-center justify-center self-stretch before:absolute before:inset-x-0 before:bottom-0 before:h-px before:bg-white`}> - {!loading && ( + <div className={`sticky left-0 z-[60] ${NAME_COL_W} ${stickyCellBg} flex items-center gap-4 self-stretch pl-4 pr-2 text-left`}> + {loading ? ( + <div className="h-2.5 w-2.5 shrink-0 rounded bg-gray-100 animate-pulse" /> + ) : ( <input type="checkbox" checked={allSelected} @@ -403,9 +406,7 @@ export function WorkflowList() { className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black" /> )} - </div> - <div className={`sticky left-8 z-[60] ${NAME_COL_W} bg-white pl-2 text-left`}> - Name + <span>Name</span> </div> <div className="ml-auto w-28 shrink-0">Type</div> <div className="w-40 shrink-0">Practice</div> @@ -420,8 +421,8 @@ export function WorkflowList() { key={i} className="flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50" > - <div className="w-8 shrink-0" /> - <div className="flex-1 min-w-0 pl-3 pr-4"> + <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> <div className="w-28 shrink-0"> @@ -486,28 +487,26 @@ export function WorkflowList() { filtered.map((wf) => { const rowBg = selectedIds.includes(wf.id) ? "bg-gray-50" - : "bg-white"; + : stickyCellBg; return ( <div key={wf.id} onClick={() => setSelected(wf)} - className="group flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors" + className="group flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50 hover:bg-gray-100 cursor-pointer transition-colors" > - <div - className={`sticky left-0 z-[60] ${CHECK_W} p-2 flex items-center justify-center ${rowBg} group-hover:bg-gray-50`} - onClick={(e) => e.stopPropagation()} - > - <input - type="checkbox" - checked={selectedIds.includes(wf.id)} - onChange={() => toggleOne(wf.id)} - className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black" - /> - </div> - <div className={`sticky left-8 z-[60] ${NAME_COL_W} p-2 ${rowBg} group-hover:bg-gray-50`}> - <span className="text-sm text-gray-800 truncate block"> - {wf.title} - </span> + <div className={`sticky left-0 z-[60] ${NAME_COL_W} py-2 pl-4 pr-2 ${rowBg} transition-colors group-hover:bg-gray-100`}> + <div className="flex min-w-0 items-center gap-4"> + <input + type="checkbox" + checked={selectedIds.includes(wf.id)} + onChange={() => toggleOne(wf.id)} + onClick={(e) => e.stopPropagation()} + className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-pointer accent-black" + /> + <span className="min-w-0 flex-1 truncate text-sm text-gray-800"> + {wf.title} + </span> + </div> </div> <div className="ml-auto w-28 shrink-0"> {(() => { diff --git a/frontend/src/app/components/workflows/builtinWorkflows.ts b/frontend/src/app/components/workflows/builtinWorkflows.ts index 319c132..9abe217 100644 --- a/frontend/src/app/components/workflows/builtinWorkflows.ts +++ b/frontend/src/app/components/workflows/builtinWorkflows.ts @@ -1,6 +1,6 @@ -import type { MikeWorkflow } from "../shared/types"; +import type { Workflow } from "../shared/types"; -export const BUILT_IN_WORKFLOWS: MikeWorkflow[] = [ +export const BUILT_IN_WORKFLOWS: Workflow[] = [ { id: "builtin-cp-checklist", user_id: null, diff --git a/frontend/src/app/contexts/ChatHistoryContext.tsx b/frontend/src/app/contexts/ChatHistoryContext.tsx index 103fe1f..83f6d9d 100644 --- a/frontend/src/app/contexts/ChatHistoryContext.tsx +++ b/frontend/src/app/contexts/ChatHistoryContext.tsx @@ -16,10 +16,10 @@ import { listChats, renameChat, } from "@/app/lib/mikeApi"; -import type { MikeChat, MikeMessage } from "@/app/components/shared/types"; +import type { Chat, Message } from "@/app/components/shared/types"; interface ChatHistoryContextType { - chats: MikeChat[] | null; + chats: Chat[] | null; hasMoreChats: boolean; currentChatId: string | null; setCurrentChatId: (chatId: string | null) => void; @@ -27,8 +27,8 @@ interface ChatHistoryContextType { loadMoreChats: () => void; saveChat: (projectId?: string) => Promise<string | null>; renameChat: (chatId: string, title: string) => Promise<void>; - newChatMessages: MikeMessage[] | null; - setNewChatMessages: (messages: MikeMessage[] | null) => void; + newChatMessages: Message[] | null; + setNewChatMessages: (messages: Message[] | null) => void; replaceChatId: ( oldChatId: string, newChatId: string, @@ -46,13 +46,13 @@ const CHAT_LIMIT_INCREMENT = 10; export function ChatHistoryProvider({ children }: { children: ReactNode }) { const { user } = useAuth(); - const [chats, setChats] = useState<MikeChat[] | null>(null); + const [chats, setChats] = useState<Chat[] | null>(null); const [chatLimit, setChatLimit] = useState(INITIAL_CHAT_LIMIT); const [hasMoreChats, setHasMoreChats] = useState(false); const [currentChatId, setCurrentChatId] = useState<string | null>(null); - const [newChatMessages, setNewChatMessages] = useState< - MikeMessage[] | null - >(null); + const [newChatMessages, setNewChatMessages] = useState<Message[] | null>( + null, + ); const loadChats = useCallback(async () => { if (!user) { @@ -122,7 +122,7 @@ export function ChatHistoryProvider({ children }: { children: ReactNode }) { projectId ? { project_id: projectId } : undefined, ); const now = new Date().toISOString(); - const newChat: MikeChat = { + const newChat: Chat = { id, project_id: projectId ?? null, user_id: user?.id ?? "", diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 5fbf318..f383967 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -52,7 +52,7 @@ :root { --radius: 0.625rem; --color-azure: 0, 136, 255; - --background: oklch(1 0 0); + --background: oklch(0.985 0 0); --foreground: oklch(0.145 0 0); --card: oklch(1 0 0); --card-foreground: oklch(0.145 0 0); @@ -442,6 +442,14 @@ color: inherit !important; } +.case-opinion-content span.docx-text-highlight, +.case-opinion-content .docx-text-highlight { + background-color: rgba(96, 165, 250, 0.55) !important; + border-radius: 2px; + padding: 0 1px; + color: inherit !important; +} + /* docx-preview tracked-change styling */ .docx-view-container ins { color: #16a34a; diff --git a/frontend/src/app/hooks/useAssistantChat.ts b/frontend/src/app/hooks/useAssistantChat.ts index 1a28ac1..33350d5 100644 --- a/frontend/src/app/hooks/useAssistantChat.ts +++ b/frontend/src/app/hooks/useAssistantChat.ts @@ -2,955 +2,1220 @@ import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; -import { streamChat, streamProjectChat } from "@/app/lib/mikeApi"; +import { + streamChat, + streamProjectChat, +} from "@/app/lib/mikeApi"; import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext"; import { useGenerateChatTitle } from "./useGenerateChatTitle"; import type { - AssistantEvent, - MikeCitationAnnotation, - MikeMessage, + AssistantEvent, + CitationAnnotation, + Message, } from "@/app/components/shared/types"; interface UseAssistantChatOptions { - initialMessages?: MikeMessage[]; - chatId?: string; - projectId?: string; + initialMessages?: Message[]; + chatId?: string; + projectId?: string; } function findLastContentIndex(events: AssistantEvent[]): number { - for (let i = events.length - 1; i >= 0; i--) { - if (events[i].type === "content") return i; - } - return -1; + for (let i = events.length - 1; i >= 0; i--) { + if (events[i].type === "content") return i; + } + return -1; +} + +function readableStreamError(value: unknown): string { + if (typeof value === "string" && value.trim()) return value.trim(); + return "Sorry, something went wrong."; +} + +function parseCourtlistenerEventCases(value: unknown) { + if (!Array.isArray(value)) return undefined; + return value + .map((item) => { + if (!item || typeof item !== "object" || Array.isArray(item)) { + return null; + } + const row = item as Record<string, unknown>; + return { + cluster_id: + typeof row.cluster_id === "number" ? row.cluster_id : 0, + case_name: + typeof row.case_name === "string" ? row.case_name : null, + citation: + typeof row.citation === "string" ? row.citation : null, + dateFiled: + typeof row.dateFiled === "string" ? row.dateFiled : null, + url: typeof row.url === "string" ? row.url : null, + }; + }) + .filter( + (item): item is NonNullable<typeof item> => + !!item && item.cluster_id > 0, + ); +} + +function parseCourtlistenerCaseSearches(value: unknown) { + if (!Array.isArray(value)) return undefined; + return value + .map((item) => { + if (!item || typeof item !== "object" || Array.isArray(item)) { + return null; + } + const row = item as Record<string, unknown>; + return { + cluster_id: + typeof row.cluster_id === "number" ? row.cluster_id : null, + query: typeof row.query === "string" ? row.query : "", + total_matches: + typeof row.total_matches === "number" ? row.total_matches : 0, + case_name: + typeof row.case_name === "string" ? row.case_name : null, + citation: + typeof row.citation === "string" ? row.citation : null, + error: typeof row.error === "string" ? row.error : undefined, + }; + }) + .filter((item): item is NonNullable<typeof item> => !!item); } export function useAssistantChat({ - initialMessages = [], - chatId: initialChatId, - projectId, + initialMessages = [], + chatId: initialChatId, + projectId, }: UseAssistantChatOptions = {}) { - const router = useRouter(); - const { - replaceChatId, - loadChats, - setCurrentChatId, - saveChat, - setNewChatMessages, - } = useChatHistoryContext(); - const { generate: generateTitle } = useGenerateChatTitle(); + const router = useRouter(); + const { + replaceChatId, + loadChats, + setCurrentChatId, + saveChat, + setNewChatMessages, + } = useChatHistoryContext(); + const { generate: generateTitle } = useGenerateChatTitle(); - const [messages, setMessages] = useState<MikeMessage[]>(initialMessages); - const [isResponseLoading, setIsResponseLoading] = useState(false); - const [isLoadingCitations, setIsLoadingCitations] = useState(false); - const [chatId, setChatId] = useState<string | undefined>(initialChatId); + const [messages, setMessages] = useState<Message[]>(initialMessages); + const [isResponseLoading, setIsResponseLoading] = useState(false); + const [isLoadingCitations, setIsLoadingCitations] = useState(false); + const [chatId, setChatId] = useState<string | undefined>(initialChatId); - const abortControllerRef = useRef<AbortController | null>(null); + const abortControllerRef = useRef<AbortController | null>(null); - const dripIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null); - const dripTargetRef = useRef<string>(""); - const dripDisplayLenRef = useRef<number>(0); - const eventsRef = useRef<AssistantEvent[]>([]); - const DRIP_CHARS_PER_TICK = 8; + const eventsRef = useRef<AssistantEvent[]>([]); - const stopDrip = () => { - if (dripIntervalRef.current !== null) { - clearInterval(dripIntervalRef.current); - dripIntervalRef.current = null; - } - }; - - const updateLastContentEvent = ( - prev: MikeMessage[], - text: string, - isStreaming?: boolean, - ): MikeMessage[] => { + /** + * Finalize any in-flight streaming content event so the next + * content_delta starts a fresh block. Called + * before any non-content event is appended, so interleaved content / + * reasoning / tool events stay in chronological order — without the + * later content block inheriting the earlier block's accumulated text. + */ + const finalizeStreamingContent = () => { + const events = eventsRef.current; + const last = events[events.length - 1]; + if (last?.type === "content" && last.isStreaming) { + eventsRef.current = [ + ...events.slice(0, -1), + { type: "content", text: last.text }, + ]; + const snapshot = [...eventsRef.current]; + setMessages((prev) => { const updated = [...prev]; - const last = updated[updated.length - 1]; - if (last?.role !== "assistant") return prev; - const events = last.events ?? []; - const idx = findLastContentIndex(events); - if (idx < 0) return prev; - const current = events[idx]; - if ( - current.type === "content" && - current.text === text && - !!current.isStreaming === !!isStreaming - ) { - return prev; + const lastMsg = updated[updated.length - 1]; + if (lastMsg?.role === "assistant") { + updated[updated.length - 1] = { + ...lastMsg, + events: snapshot, + }; } - const newEvents = [...events]; - newEvents[idx] = isStreaming - ? { type: "content", text, isStreaming: true } - : { type: "content", text }; - updated[updated.length - 1] = { ...last, events: newEvents }; return updated; - }; + }); + } + }; - const flushDrip = () => { - stopDrip(); - const target = dripTargetRef.current; - dripDisplayLenRef.current = target.length; - setMessages((prev) => updateLastContentEvent(prev, target)); - }; + // If the model transitions from reasoning into content/tool without a + // reasoning_block_end (or the events arrive out of order), the prior + // reasoning event would otherwise stay flagged isStreaming forever. + const finalizeStreamingReasoning = () => { + const events = eventsRef.current; + const last = events[events.length - 1]; + if (last?.type !== "reasoning" || !last.isStreaming) return; + eventsRef.current = [ + ...events.slice(0, -1), + { type: "reasoning", text: last.text }, + ]; + const snapshot = [...eventsRef.current]; + setMessages((prev) => { + const updated = [...prev]; + const lastMsg = updated[updated.length - 1]; + if (lastMsg?.role === "assistant") { + updated[updated.length - 1] = { + ...lastMsg, + events: snapshot, + }; + } + return updated; + }); + }; - /** - * Finalize any in-flight streaming content event and reset the drip - * counters so the next content_delta starts a fresh block. Called - * before any non-content event is appended, so interleaved content / - * reasoning / tool events stay in chronological order — without the - * later content block inheriting the earlier block's accumulated text. - */ - const finalizeStreamingContent = () => { - stopDrip(); - const events = eventsRef.current; - const last = events[events.length - 1]; - if (last?.type === "content" && last.isStreaming) { - const finalText = dripTargetRef.current; - eventsRef.current = [ - ...events.slice(0, -1), - { type: "content", text: finalText }, - ]; - const snapshot = [...eventsRef.current]; - setMessages((prev) => { + const cancel = () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + abortControllerRef.current = null; + setIsResponseLoading(false); + setIsLoadingCitations(false); + } + }; + + // Transient placeholder events (tool_call_start, thinking) fill the + // latency gap between real SSE events so the wrapper doesn't look stuck. + // Anytime a real event arrives, drop any streaming placeholder first. + const isStreamingPlaceholder = (e: AssistantEvent) => + (e.type === "tool_call_start" || e.type === "thinking") && !!e.isStreaming; + + const clearStreamingPlaceholders = () => { + const before = eventsRef.current; + const after = before.filter((e) => !isStreamingPlaceholder(e)); + if (after.length === before.length) return; + eventsRef.current = after; + const snapshot = [...after]; + setMessages((prev) => { + const updated = [...prev]; + const last = updated[updated.length - 1]; + if (last?.role === "assistant") { + updated[updated.length - 1] = { ...last, events: snapshot }; + } + return updated; + }); + }; + + const pushThinkingPlaceholder = () => { + const events = eventsRef.current; + const last = events[events.length - 1]; + // Don't stack placeholders back-to-back; one "Thinking…" line is plenty. + if (last && isStreamingPlaceholder(last)) return; + eventsRef.current = [ + ...events, + { type: "thinking" as const, isStreaming: true }, + ]; + const snapshot = [...eventsRef.current]; + setMessages((prev) => { + const updated = [...prev]; + const lastMsg = updated[updated.length - 1]; + if (lastMsg?.role === "assistant") { + updated[updated.length - 1] = { ...lastMsg, events: snapshot }; + } + return updated; + }); + }; + + const pushEvent = (event: AssistantEvent) => { + finalizeStreamingContent(); + finalizeStreamingReasoning(); + // A real event, or a more specific placeholder such as + // tool_call_start, should replace any generic "Thinking..." line. + const next = eventsRef.current.filter((e) => !isStreamingPlaceholder(e)); + eventsRef.current = [...next, event]; + const snapshot = [...eventsRef.current]; + setMessages((prev) => { + const updated = [...prev]; + const last = updated[updated.length - 1]; + if (last?.role === "assistant") { + updated[updated.length - 1] = { ...last, events: snapshot }; + } + return updated; + }); + }; + + const updateMatchingEvent = ( + predicate: (e: AssistantEvent) => boolean, + updater: (e: AssistantEvent) => AssistantEvent, + ) => { + const events = eventsRef.current; + const idx = [...events] + .map((_, i) => i) + .reverse() + .find((i) => predicate(events[i])); + if (idx === undefined) return false; + const newEvents = [...events]; + newEvents[idx] = updater(events[idx]); + eventsRef.current = newEvents; + const snapshot = [...newEvents]; + setMessages((prev) => { + const updated = [...prev]; + const last = updated[updated.length - 1]; + if (last?.role === "assistant") { + updated[updated.length - 1] = { ...last, events: snapshot }; + } + return updated; + }); + return true; + }; + + const handleChat = async ( + message: Message, + opts?: { + displayedDoc?: { filename: string; documentId: string } | null; + }, + ): Promise<string | null> => { + if (!message.content.trim()) return null; + + setIsResponseLoading(true); + + const lastMessage = messages[messages.length - 1]; + const isMessageAlreadyAdded = + lastMessage && + lastMessage.role === "user" && + lastMessage.content === message.content; + + const newMessages: Message[] = isMessageAlreadyAdded + ? messages + : [...messages, message]; + + setMessages([ + ...newMessages, + { role: "assistant", content: "", annotations: [], events: [] }, + ]); + + let streamedChatId: string | null = null; + + eventsRef.current = []; + + try { + const controller = new AbortController(); + abortControllerRef.current = controller; + + const apiMessages = newMessages.map((currentMessage) => ({ + role: currentMessage.role, + content: currentMessage.content, + files: currentMessage.files, + workflow: currentMessage.workflow, + })); + + const model = message.model; + + const displayedDoc = opts?.displayedDoc ?? null; + + // Pull the user's attachments from the just-submitted message. + // These are the files dragged into / picked from the chat input + // for this turn (separate from the running history of past + // attachments). Sent as a request-level field so the backend + // can call them out specifically in the system prompt. + const attachedDocs = ( + message.files?.filter((f) => !!f.document_id) ?? [] + ).map((f) => ({ + filename: f.filename, + document_id: f.document_id as string, + })); + + const response = await (projectId + ? streamProjectChat({ + projectId, + messages: apiMessages, + chat_id: chatId, + model, + displayed_doc: displayedDoc + ? { + filename: displayedDoc.filename, + document_id: displayedDoc.documentId, + } + : undefined, + attached_documents: + attachedDocs.length > 0 ? attachedDocs : undefined, + signal: controller.signal, + }) + : streamChat({ + messages: apiMessages, + chat_id: chatId, + model, + signal: controller.signal, + })); + + if (!response.ok) { + const errText = await response.text(); + throw new Error(`HTTP ${response.status}: ${errText}`); + } + + const reader = response.body?.getReader(); + if (!reader) throw new Error("No response body"); + + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || !trimmed.startsWith("data:")) continue; + + const dataStr = trimmed.slice(5).trim(); + if (dataStr === "[DONE]") continue; + + try { + const data = JSON.parse(dataStr); + + if (data.type === "chat_id") { + streamedChatId = data.chatId; + setChatId(data.chatId); + setCurrentChatId(data.chatId); + continue; + } + + if (data.type === "content_done") { + setIsLoadingCitations(true); + continue; + } + + if (data.type === "error") { + const message = readableStreamError(data.message); + clearStreamingPlaceholders(); + finalizeStreamingContent(); + finalizeStreamingReasoning(); + eventsRef.current = [ + ...eventsRef.current, + { type: "error", message }, + ]; + const snapshot = [...eventsRef.current]; + setMessages((prev) => { const updated = [...prev]; - const lastMsg = updated[updated.length - 1]; - if (lastMsg?.role === "assistant") { - updated[updated.length - 1] = { - ...lastMsg, - events: snapshot, - }; + const last = updated[updated.length - 1]; + if (last?.role === "assistant") { + updated[updated.length - 1] = { + ...last, + events: snapshot, + error: message, + }; } return updated; - }); - } - dripTargetRef.current = ""; - dripDisplayLenRef.current = 0; - }; - - // If the model transitions from reasoning into content/tool without a - // reasoning_block_end (or the events arrive out of order), the prior - // reasoning event would otherwise stay flagged isStreaming forever. - const finalizeStreamingReasoning = () => { - const events = eventsRef.current; - const last = events[events.length - 1]; - if (last?.type !== "reasoning" || !last.isStreaming) return; - eventsRef.current = [ - ...events.slice(0, -1), - { type: "reasoning", text: last.text }, - ]; - const snapshot = [...eventsRef.current]; - setMessages((prev) => { - const updated = [...prev]; - const lastMsg = updated[updated.length - 1]; - if (lastMsg?.role === "assistant") { - updated[updated.length - 1] = { - ...lastMsg, - events: snapshot, - }; - } - return updated; - }); - }; - - const startDrip = () => { - if (dripIntervalRef.current !== null) return; - dripIntervalRef.current = setInterval(() => { - const target = dripTargetRef.current; - const displayLen = dripDisplayLenRef.current; - if (displayLen >= target.length) { - stopDrip(); - return; + }); + setIsResponseLoading(false); + setIsLoadingCitations(false); + continue; } - const newLen = Math.min( - displayLen + DRIP_CHARS_PER_TICK, - target.length, - ); - dripDisplayLenRef.current = newLen; - const visibleText = target.slice(0, newLen); - const events = eventsRef.current; - const lastIdx = events.length - 1; - const last = events[lastIdx]; - if (last?.type === "content" && last.isStreaming) { - const next = events.slice(); - next[lastIdx] = { - type: "content", - text: visibleText, + if (data.type === "content_delta") { + const text = data.text as string; + + // Real content is streaming — retire any + // "Thinking…" / "Running…" placeholders, and + // finalize any in-flight reasoning block so it + // doesn't get stuck rendering as streaming. + clearStreamingPlaceholders(); + finalizeStreamingReasoning(); + + // Ensure a streaming content event exists. If + // the last event isn't already a streaming + // content block, start a fresh one so interleaved + // tool/reasoning events split content naturally. + const events = eventsRef.current; + const lastEvent = events[events.length - 1]; + if (lastEvent?.type !== "content" || !lastEvent.isStreaming) { + eventsRef.current = [ + ...events, + { + type: "content" as const, + text, isStreaming: true, + }, + ]; + const snapshot = [...eventsRef.current]; + setMessages((prev) => { + const updated = [...prev]; + const last = updated[updated.length - 1]; + if (last?.role === "assistant") { + updated[updated.length - 1] = { + ...last, + events: snapshot, + }; + } + return updated; + }); + } else { + const nextEvents = [...events]; + nextEvents[nextEvents.length - 1] = { + type: "content" as const, + text: `${lastEvent.text}${text}`, + isStreaming: true, }; - eventsRef.current = next; + eventsRef.current = nextEvents; + const snapshot = [...nextEvents]; + setMessages((prev) => { + const updated = [...prev]; + const last = updated[updated.length - 1]; + if (last?.role === "assistant") { + updated[updated.length - 1] = { + ...last, + events: snapshot, + }; + } + return updated; + }); + } + continue; } - setMessages((prev) => - updateLastContentEvent(prev, visibleText, true), + if (data.type === "reasoning_delta") { + const text = data.text as string; + let events = eventsRef.current; + const last = events[events.length - 1]; + if (last?.type === "reasoning" && last.isStreaming) { + eventsRef.current = [ + ...events.slice(0, -1), + { + type: "reasoning" as const, + text: last.text + text, + isStreaming: true, + }, + ]; + } else { + // New reasoning block — finalize any in-flight + // content event first so the next content_delta + // starts a fresh block at the correct position. + finalizeStreamingContent(); + clearStreamingPlaceholders(); + events = eventsRef.current; + eventsRef.current = [ + ...events, + { + type: "reasoning" as const, + text, + isStreaming: true, + }, + ]; + } + const snapshot = [...eventsRef.current]; + setMessages((prev) => { + const updated = [...prev]; + const last = updated[updated.length - 1]; + if (last?.role === "assistant") { + updated[updated.length - 1] = { + ...last, + events: snapshot, + }; + } + return updated; + }); + continue; + } + + if (data.type === "reasoning_block_end") { + const events = eventsRef.current; + const last = events[events.length - 1]; + if (last?.type === "reasoning" && last.isStreaming) { + eventsRef.current = [ + ...events.slice(0, -1), + { + type: "reasoning" as const, + text: last.text, + }, + ]; + } + const snapshot = [...eventsRef.current]; + setMessages((prev) => { + const updated = [...prev]; + const last = updated[updated.length - 1]; + if (last?.role === "assistant") { + updated[updated.length - 1] = { + ...last, + events: snapshot, + }; + } + return updated; + }); + pushThinkingPlaceholder(); + continue; + } + + if (data.type === "tool_call_start") { + // Transient placeholder so the client immediately + // shows activity after Claude ends a turn with + // tool_use. Replaced by the real tool event + // (doc_edited_start, doc_read_start, …) if one + // arrives; otherwise it lingers as a "Working…" + // indicator until the next iteration streams. + pushEvent({ + type: "tool_call_start", + name: (data.name as string) ?? "", + isStreaming: true, + }); + continue; + } + + if (data.type === "workflow_applied") { + pushEvent({ + type: "workflow_applied", + workflow_id: data.workflow_id as string, + title: data.title as string, + }); + continue; + } + + if (data.type === "case_citation") { + pushEvent({ + type: "case_citation", + cluster_id: + typeof data.cluster_id === "number" + ? (data.cluster_id as number) + : null, + case_name: + typeof data.case_name === "string" + ? (data.case_name as string) + : null, + citation: + typeof data.citation === "string" + ? (data.citation as string) + : null, + url: data.url as string, + pdfUrl: + typeof data.pdfUrl === "string" ? (data.pdfUrl as string) : null, + dateFiled: + typeof data.dateFiled === "string" + ? (data.dateFiled as string) + : null, + }); + continue; + } + + if (data.type === "case_opinions") { + pushEvent({ + type: "case_opinions", + cluster_id: + typeof data.cluster_id === "number" + ? (data.cluster_id as number) + : 0, + case: data.case as Extract< + AssistantEvent, + { type: "case_opinions" } + >["case"], + }); + continue; + } + + if (data.type === "courtlistener_search_case_law_start") { + pushEvent({ + type: "courtlistener_search_case_law", + query: (data.query as string) ?? "", + isStreaming: true, + }); + continue; + } + + if (data.type === "courtlistener_search_case_law") { + updateMatchingEvent( + (e) => + e.type === "courtlistener_search_case_law" && + e.query === (data.query as string) && + !!e.isStreaming, + () => ({ + type: "courtlistener_search_case_law", + query: (data.query as string) ?? "", + result_count: + typeof data.result_count === "number" + ? (data.result_count as number) + : 0, + error: + typeof data.error === "string" + ? (data.error as string) + : undefined, + isStreaming: false, + }), + ); + pushThinkingPlaceholder(); + continue; + } + + if (data.type === "courtlistener_get_cases_start") { + pushEvent({ + type: "courtlistener_get_cases", + cluster_ids: Array.isArray(data.cluster_ids) + ? (data.cluster_ids as unknown[]).filter( + (value: unknown): value is number => + typeof value === "number", + ) + : [], + isStreaming: true, + }); + continue; + } + + if (data.type === "courtlistener_get_cases") { + updateMatchingEvent( + (e) => + e.type === "courtlistener_get_cases" && + !!e.isStreaming, + () => ({ + type: "courtlistener_get_cases", + cluster_ids: Array.isArray(data.cluster_ids) + ? (data.cluster_ids as unknown[]).filter( + (value: unknown): value is number => + typeof value === "number", + ) + : [], + case_count: + typeof data.case_count === "number" + ? (data.case_count as number) + : 0, + opinion_count: + typeof data.opinion_count === "number" + ? (data.opinion_count as number) + : 0, + cases: parseCourtlistenerEventCases(data.cases), + error: + typeof data.error === "string" + ? (data.error as string) + : undefined, + isStreaming: false, + }), + ); + pushThinkingPlaceholder(); + continue; + } + + if (data.type === "courtlistener_find_in_case_start") { + const searches = parseCourtlistenerCaseSearches(data.searches); + pushEvent({ + type: "courtlistener_find_in_case", + cluster_id: searches?.length + ? null + : typeof data.cluster_id === "number" + ? (data.cluster_id as number) + : null, + query: searches?.length ? "" : ((data.query as string) ?? ""), + searches, + isStreaming: true, + }); + continue; + } + + if (data.type === "courtlistener_find_in_case") { + const searches = parseCourtlistenerCaseSearches(data.searches); + updateMatchingEvent( + (e) => + e.type === "courtlistener_find_in_case" && + (searches?.length + ? Array.isArray(e.searches) + : e.cluster_id === + (typeof data.cluster_id === "number" + ? (data.cluster_id as number) + : null) && e.query === (data.query as string)) && + !!e.isStreaming, + () => ({ + type: "courtlistener_find_in_case", + cluster_id: searches?.length + ? null + : typeof data.cluster_id === "number" + ? (data.cluster_id as number) + : null, + query: searches?.length ? "" : ((data.query as string) ?? ""), + total_matches: + typeof data.total_matches === "number" + ? (data.total_matches as number) + : 0, + searches, + case_name: + typeof data.case_name === "string" + ? (data.case_name as string) + : null, + citation: + typeof data.citation === "string" + ? (data.citation as string) + : null, + error: + typeof data.error === "string" + ? (data.error as string) + : undefined, + isStreaming: false, + }), + ); + pushThinkingPlaceholder(); + continue; + } + + if (data.type === "courtlistener_read_case_start") { + pushEvent({ + type: "courtlistener_read_case", + cluster_id: + typeof data.cluster_id === "number" + ? (data.cluster_id as number) + : null, + isStreaming: true, + }); + continue; + } + + if (data.type === "courtlistener_read_case") { + updateMatchingEvent( + (e) => + e.type === "courtlistener_read_case" && + e.cluster_id === + (typeof data.cluster_id === "number" + ? (data.cluster_id as number) + : null) && + !!e.isStreaming, + () => ({ + type: "courtlistener_read_case", + cluster_id: + typeof data.cluster_id === "number" + ? (data.cluster_id as number) + : null, + case_name: + typeof data.case_name === "string" + ? (data.case_name as string) + : null, + citation: + typeof data.citation === "string" + ? (data.citation as string) + : null, + opinion_count: + typeof data.opinion_count === "number" + ? (data.opinion_count as number) + : 0, + error: + typeof data.error === "string" + ? (data.error as string) + : undefined, + isStreaming: false, + }), + ); + pushThinkingPlaceholder(); + continue; + } + + if (data.type === "courtlistener_verify_citations_start") { + pushEvent({ + type: "courtlistener_verify_citations", + citation_count: + typeof data.citation_count === "number" + ? (data.citation_count as number) + : 0, + isStreaming: true, + }); + continue; + } + + if (data.type === "courtlistener_verify_citations") { + updateMatchingEvent( + (e) => + e.type === "courtlistener_verify_citations" && + !!e.isStreaming, + () => ({ + type: "courtlistener_verify_citations", + citation_count: + typeof data.citation_count === "number" + ? (data.citation_count as number) + : 0, + match_count: + typeof data.match_count === "number" + ? (data.match_count as number) + : 0, + error: + typeof data.error === "string" + ? (data.error as string) + : undefined, + isStreaming: false, + }), + ); + pushThinkingPlaceholder(); + continue; + } + + if (data.type === "doc_read_start") { + pushEvent({ + type: "doc_read", + filename: data.filename as string, + isStreaming: true, + }); + continue; + } + + if (data.type === "doc_read") { + updateMatchingEvent( + (e) => + e.type === "doc_read" && + e.filename === data.filename && + !!e.isStreaming, + (e) => ({ ...e, isStreaming: false }), + ); + pushThinkingPlaceholder(); + continue; + } + + if (data.type === "doc_find_start") { + pushEvent({ + type: "doc_find", + filename: data.filename as string, + query: (data.query as string) ?? "", + total_matches: 0, + isStreaming: true, + }); + continue; + } + + if (data.type === "doc_find") { + updateMatchingEvent( + (e) => + e.type === "doc_find" && + e.filename === data.filename && + e.query === (data.query as string) && + !!e.isStreaming, + (e) => ({ + ...e, + isStreaming: false, + total_matches: + typeof data.total_matches === "number" + ? (data.total_matches as number) + : ( + e as { + type: "doc_find"; + total_matches: number; + } + ).total_matches, + }), + ); + pushThinkingPlaceholder(); + continue; + } + + if (data.type === "doc_created_start") { + pushEvent({ + type: "doc_created", + filename: data.filename as string, + download_url: "", + isStreaming: true, + }); + continue; + } + + if (data.type === "doc_download") { + pushEvent({ + type: "doc_download", + filename: data.filename as string, + download_url: data.download_url as string, + }); + continue; + } + + if (data.type === "doc_created") { + updateMatchingEvent( + (e) => + e.type === "doc_created" && + e.filename === data.filename && + !!e.isStreaming, + (e) => { + const next: Extract<AssistantEvent, { type: "doc_created" }> = + { + type: "doc_created", + filename: (e as { filename: string }).filename, + download_url: data.download_url as string, + isStreaming: false, + }; + if (typeof data.document_id === "string") { + next.document_id = data.document_id as string; + } + if (typeof data.version_id === "string") { + next.version_id = data.version_id as string; + } + if (typeof data.version_number === "number") { + next.version_number = data.version_number as number; + } + return next; + }, + ); + pushThinkingPlaceholder(); + continue; + } + + if (data.type === "doc_replicate_start") { + pushEvent({ + type: "doc_replicated", + filename: data.filename as string, + count: + typeof data.count === "number" ? (data.count as number) : 1, + isStreaming: true, + }); + continue; + } + + if (data.type === "doc_replicated") { + updateMatchingEvent( + (e) => + e.type === "doc_replicated" && + e.filename === data.filename && + !!e.isStreaming, + () => ({ + type: "doc_replicated", + filename: data.filename as string, + count: + typeof data.count === "number" + ? (data.count as number) + : Array.isArray(data.copies) + ? (data.copies as unknown[]).length + : 1, + copies: Array.isArray(data.copies) + ? (data.copies as { + new_filename: string; + document_id: string; + version_id: string; + }[]) + : undefined, + error: + typeof data.error === "string" + ? (data.error as string) + : undefined, + isStreaming: false, + }), + ); + pushThinkingPlaceholder(); + continue; + } + + if (data.type === "doc_edited_start") { + pushEvent({ + type: "doc_edited", + filename: data.filename as string, + document_id: "", + version_id: "", + download_url: "", + annotations: [], + isStreaming: true, + }); + continue; + } + + if (data.type === "doc_edited") { + updateMatchingEvent( + (e) => + e.type === "doc_edited" && + e.filename === data.filename && + !!e.isStreaming, + () => ({ + type: "doc_edited", + filename: data.filename as string, + document_id: (data.document_id as string) ?? "", + version_id: (data.version_id as string) ?? "", + version_number: + typeof data.version_number === "number" + ? (data.version_number as number) + : null, + download_url: (data.download_url as string) ?? "", + annotations: Array.isArray(data.annotations) + ? (data.annotations as import("@/app/components/shared/types").EditAnnotation[]) + : [], + error: + typeof data.error === "string" + ? (data.error as string) + : undefined, + isStreaming: false, + }), + ); + pushThinkingPlaceholder(); + continue; + } + + if (data.type === "citations") { + const status = + data.status === "started" || + data.status === "partial" || + data.status === "final" + ? data.status + : "final"; + const incoming = (data.citations ?? + []) as CitationAnnotation[]; + if (status === "started" || status === "partial") { + setMessages((prev) => { + const updated = [...prev]; + const last = updated[updated.length - 1]; + if (last?.role === "assistant") { + updated[updated.length - 1] = { + ...last, + annotations: incoming, + citationStatus: status, + }; + } + return updated; + }); + continue; + } + // End-of-stream signal — scrub any lingering + // placeholders so they don't persist into the + // finalised message. First finalize content so adding + // annotations cannot re-render the markdown/citation view + // against a streaming block. + finalizeStreamingContent(); + clearStreamingPlaceholders(); + setMessages((prev) => { + const updated = [...prev]; + const last = updated[updated.length - 1]; + if (last?.role === "assistant") { + updated[updated.length - 1] = { + ...last, + annotations: incoming, + citationStatus: incoming.length ? "final" : undefined, + }; + } + return updated; + }); + continue; + } + } catch (e) { + console.warn( + "[useAssistantChat] failed to parse SSE line:", + trimmed, + e, ); - - if (newLen >= target.length) { - stopDrip(); - } - }, 16); - }; - - useEffect(() => { - return () => stopDrip(); - }, []); - - const cancel = () => { - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - abortControllerRef.current = null; - setIsResponseLoading(false); - setIsLoadingCitations(false); + } } - }; + } - // Transient placeholder events (tool_call_start, thinking) fill the - // latency gap between real SSE events so the wrapper doesn't look stuck. - // Anytime a real event arrives, drop any streaming placeholder first. - const isStreamingPlaceholder = (e: AssistantEvent) => - (e.type === "tool_call_start" || e.type === "thinking") && - !!e.isStreaming; + finalizeStreamingReasoning(); + setIsResponseLoading(false); + setIsLoadingCitations(false); - const clearStreamingPlaceholders = () => { - const before = eventsRef.current; - const after = before.filter((e) => !isStreamingPlaceholder(e)); - if (after.length === before.length) return; - eventsRef.current = after; - const snapshot = [...after]; - setMessages((prev) => { - const updated = [...prev]; - const last = updated[updated.length - 1]; - if (last?.role === "assistant") { - updated[updated.length - 1] = { ...last, events: snapshot }; - } - return updated; - }); - }; + const finalChatId = streamedChatId || chatId || null; + if (finalChatId && finalChatId !== chatId) { + if (chatId) { + replaceChatId( + chatId, + finalChatId, + message.content.trim().slice(0, 120) || "New Chat", + ); + } + setCurrentChatId(finalChatId); + const chatBasePath = projectId + ? `/projects/${projectId}/assistant/chat` + : `/assistant/chat`; + router.replace(`${chatBasePath}/${finalChatId}`); + } - const pushThinkingPlaceholder = () => { - const events = eventsRef.current; - const last = events[events.length - 1]; - // Don't stack placeholders back-to-back; one "Thinking…" line is plenty. - if (last && isStreamingPlaceholder(last)) return; - eventsRef.current = [ - ...events, - { type: "thinking" as const, isStreaming: true }, - ]; - const snapshot = [...eventsRef.current]; - setMessages((prev) => { - const updated = [...prev]; - const lastMsg = updated[updated.length - 1]; - if (lastMsg?.role === "assistant") { - updated[updated.length - 1] = { ...lastMsg, events: snapshot }; - } - return updated; - }); - }; + await loadChats(); - const pushEvent = (event: AssistantEvent) => { + const finalChatIdForTitle = streamedChatId || chatId || null; + if (finalChatIdForTitle && newMessages.length === 1) { + const titleParts = [message.content]; + if (message.workflow) + titleParts.push(`Workflow: ${message.workflow.title}`); + if (message.files?.length) + titleParts.push( + `Files: ${message.files.map((f) => f.filename).join(", ")}`, + ); + void generateTitle(finalChatIdForTitle, titleParts.join("\n")); + } + + return streamedChatId || null; + } catch (error: unknown) { + if (error instanceof Error && error.name === "AbortError") { finalizeStreamingContent(); - finalizeStreamingReasoning(); - // A real event, or a more specific placeholder such as - // tool_call_start, should replace any generic "Thinking..." line. - const next = eventsRef.current.filter( - (e) => !isStreamingPlaceholder(e), - ); - eventsRef.current = [...next, event]; - const snapshot = [...eventsRef.current]; setMessages((prev) => { + const last = prev[prev.length - 1]; + if (last?.role === "assistant") { const updated = [...prev]; - const last = updated[updated.length - 1]; - if (last?.role === "assistant") { - updated[updated.length - 1] = { ...last, events: snapshot }; - } - return updated; - }); - }; - - const updateMatchingEvent = ( - predicate: (e: AssistantEvent) => boolean, - updater: (e: AssistantEvent) => AssistantEvent, - ) => { - const events = eventsRef.current; - const idx = [...events] - .map((_, i) => i) - .reverse() - .find((i) => predicate(events[i])); - if (idx === undefined) return; - const newEvents = [...events]; - newEvents[idx] = updater(events[idx]); - eventsRef.current = newEvents; - const snapshot = [...newEvents]; - setMessages((prev) => { - const updated = [...prev]; - const last = updated[updated.length - 1]; - if (last?.role === "assistant") { - updated[updated.length - 1] = { ...last, events: snapshot }; - } - return updated; - }); - }; - - const handleChat = async ( - message: MikeMessage, - opts?: { - displayedDoc?: { filename: string; documentId: string } | null; - }, - ): Promise<string | null> => { - if (!message.content.trim()) return null; - - setIsResponseLoading(true); - - const lastMessage = messages[messages.length - 1]; - const isMessageAlreadyAdded = - lastMessage && - lastMessage.role === "user" && - lastMessage.content === message.content; - - const newMessages: MikeMessage[] = isMessageAlreadyAdded - ? messages - : [...messages, message]; - - setMessages([ - ...newMessages, - { role: "assistant", content: "", annotations: [], events: [] }, - ]); - - let streamedChatId: string | null = null; - - stopDrip(); - dripTargetRef.current = ""; - dripDisplayLenRef.current = 0; - eventsRef.current = []; - - try { - const controller = new AbortController(); - abortControllerRef.current = controller; - - const apiMessages = newMessages.map((currentMessage) => ({ - role: currentMessage.role, - content: currentMessage.content, - files: currentMessage.files, - workflow: currentMessage.workflow, - })); - - const model = message.model; - - const displayedDoc = opts?.displayedDoc ?? null; - - // Pull the user's attachments from the just-submitted message. - // These are the files dragged into / picked from the chat input - // for this turn (separate from the running history of past - // attachments). Sent as a request-level field so the backend - // can call them out specifically in the system prompt. - const attachedDocs = ( - message.files?.filter((f) => !!f.document_id) ?? [] - ).map((f) => ({ - filename: f.filename, - document_id: f.document_id as string, - })); - - const response = await (projectId - ? streamProjectChat({ - projectId, - messages: apiMessages, - chat_id: chatId, - model, - displayed_doc: displayedDoc - ? { - filename: displayedDoc.filename, - document_id: displayedDoc.documentId, - } - : undefined, - attached_documents: - attachedDocs.length > 0 ? attachedDocs : undefined, - signal: controller.signal, - }) - : streamChat({ - messages: apiMessages, - chat_id: chatId, - model, - signal: controller.signal, - })); - - if (!response.ok) { - const errText = await response.text(); - throw new Error(`HTTP ${response.status}: ${errText}`); - } - - const reader = response.body?.getReader(); - if (!reader) throw new Error("No response body"); - - const decoder = new TextDecoder(); - let buffer = ""; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() || ""; - - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed || !trimmed.startsWith("data:")) continue; - - const dataStr = trimmed.slice(5).trim(); - if (dataStr === "[DONE]") continue; - - try { - const data = JSON.parse(dataStr); - - if (data.type === "chat_id") { - streamedChatId = data.chatId; - setChatId(data.chatId); - setCurrentChatId(data.chatId); - continue; - } - - if (data.type === "content_done") { - setIsLoadingCitations(true); - continue; - } - - if (data.type === "content_delta") { - const text = data.text as string; - - // Real content is streaming — retire any - // "Thinking…" / "Running…" placeholders, and - // finalize any in-flight reasoning block so it - // doesn't get stuck rendering as streaming. - clearStreamingPlaceholders(); - finalizeStreamingReasoning(); - - // Ensure a streaming content event exists. If - // the last event isn't already a streaming - // content block, start a fresh one — and reset - // the drip so we don't inherit a previous - // block's accumulated text. - const events = eventsRef.current; - const lastEvent = events[events.length - 1]; - if ( - lastEvent?.type !== "content" || - !lastEvent.isStreaming - ) { - dripTargetRef.current = text; - dripDisplayLenRef.current = 0; - eventsRef.current = [ - ...events, - { - type: "content" as const, - text: "", - isStreaming: true, - }, - ]; - const snapshot = [...eventsRef.current]; - setMessages((prev) => { - const updated = [...prev]; - const last = updated[updated.length - 1]; - if (last?.role === "assistant") { - updated[updated.length - 1] = { - ...last, - events: snapshot, - }; - } - return updated; - }); - } else { - dripTargetRef.current += text; - } - - startDrip(); - continue; - } - - if (data.type === "reasoning_delta") { - const text = data.text as string; - let events = eventsRef.current; - const last = events[events.length - 1]; - if ( - last?.type === "reasoning" && - last.isStreaming - ) { - eventsRef.current = [ - ...events.slice(0, -1), - { - type: "reasoning" as const, - text: last.text + text, - isStreaming: true, - }, - ]; - } else { - // New reasoning block — finalize any in-flight - // content event first so the next content_delta - // starts a fresh block at the correct position. - finalizeStreamingContent(); - clearStreamingPlaceholders(); - events = eventsRef.current; - eventsRef.current = [ - ...events, - { - type: "reasoning" as const, - text, - isStreaming: true, - }, - ]; - } - const snapshot = [...eventsRef.current]; - setMessages((prev) => { - const updated = [...prev]; - const last = updated[updated.length - 1]; - if (last?.role === "assistant") { - updated[updated.length - 1] = { - ...last, - events: snapshot, - }; - } - return updated; - }); - continue; - } - - if (data.type === "reasoning_block_end") { - const events = eventsRef.current; - const last = events[events.length - 1]; - if ( - last?.type === "reasoning" && - last.isStreaming - ) { - eventsRef.current = [ - ...events.slice(0, -1), - { - type: "reasoning" as const, - text: last.text, - }, - ]; - } - const snapshot = [...eventsRef.current]; - setMessages((prev) => { - const updated = [...prev]; - const last = updated[updated.length - 1]; - if (last?.role === "assistant") { - updated[updated.length - 1] = { - ...last, - events: snapshot, - }; - } - return updated; - }); - pushThinkingPlaceholder(); - continue; - } - - if (data.type === "tool_call_start") { - // Transient placeholder so the client immediately - // shows activity after Claude ends a turn with - // tool_use. Replaced by the real tool event - // (doc_edited_start, doc_read_start, …) if one - // arrives; otherwise it lingers as a "Working…" - // indicator until the next iteration streams. - pushEvent({ - type: "tool_call_start", - name: (data.name as string) ?? "", - isStreaming: true, - }); - continue; - } - - if (data.type === "workflow_applied") { - pushEvent({ - type: "workflow_applied", - workflow_id: data.workflow_id as string, - title: data.title as string, - }); - continue; - } - - if (data.type === "doc_read_start") { - pushEvent({ - type: "doc_read", - filename: data.filename as string, - isStreaming: true, - }); - continue; - } - - if (data.type === "doc_read") { - updateMatchingEvent( - (e) => - e.type === "doc_read" && - e.filename === data.filename && - !!e.isStreaming, - (e) => ({ ...e, isStreaming: false }), - ); - pushThinkingPlaceholder(); - continue; - } - - if (data.type === "doc_find_start") { - pushEvent({ - type: "doc_find", - filename: data.filename as string, - query: (data.query as string) ?? "", - total_matches: 0, - isStreaming: true, - }); - continue; - } - - if (data.type === "doc_find") { - updateMatchingEvent( - (e) => - e.type === "doc_find" && - e.filename === data.filename && - e.query === (data.query as string) && - !!e.isStreaming, - (e) => ({ - ...e, - isStreaming: false, - total_matches: - typeof data.total_matches === "number" - ? (data.total_matches as number) - : ( - e as { - type: "doc_find"; - total_matches: number; - } - ).total_matches, - }), - ); - pushThinkingPlaceholder(); - continue; - } - - if (data.type === "doc_created_start") { - pushEvent({ - type: "doc_created", - filename: data.filename as string, - download_url: "", - isStreaming: true, - }); - continue; - } - - if (data.type === "doc_download") { - pushEvent({ - type: "doc_download", - filename: data.filename as string, - download_url: data.download_url as string, - }); - continue; - } - - if (data.type === "doc_created") { - updateMatchingEvent( - (e) => - e.type === "doc_created" && - e.filename === data.filename && - !!e.isStreaming, - (e) => { - const next: Extract< - AssistantEvent, - { type: "doc_created" } - > = { - type: "doc_created", - filename: (e as { filename: string }) - .filename, - download_url: - data.download_url as string, - isStreaming: false, - }; - if (typeof data.document_id === "string") { - next.document_id = - data.document_id as string; - } - if (typeof data.version_id === "string") { - next.version_id = - data.version_id as string; - } - if ( - typeof data.version_number === "number" - ) { - next.version_number = - data.version_number as number; - } - return next; - }, - ); - pushThinkingPlaceholder(); - continue; - } - - if (data.type === "doc_replicate_start") { - pushEvent({ - type: "doc_replicated", - filename: data.filename as string, - count: - typeof data.count === "number" - ? (data.count as number) - : 1, - isStreaming: true, - }); - continue; - } - - if (data.type === "doc_replicated") { - updateMatchingEvent( - (e) => - e.type === "doc_replicated" && - e.filename === data.filename && - !!e.isStreaming, - () => ({ - type: "doc_replicated", - filename: data.filename as string, - count: - typeof data.count === "number" - ? (data.count as number) - : Array.isArray(data.copies) - ? (data.copies as unknown[]) - .length - : 1, - copies: Array.isArray(data.copies) - ? (data.copies as { - new_filename: string; - document_id: string; - version_id: string; - }[]) - : undefined, - error: - typeof data.error === "string" - ? (data.error as string) - : undefined, - isStreaming: false, - }), - ); - pushThinkingPlaceholder(); - continue; - } - - if (data.type === "doc_edited_start") { - pushEvent({ - type: "doc_edited", - filename: data.filename as string, - document_id: "", - version_id: "", - download_url: "", - annotations: [], - isStreaming: true, - }); - continue; - } - - if (data.type === "doc_edited") { - updateMatchingEvent( - (e) => - e.type === "doc_edited" && - e.filename === data.filename && - !!e.isStreaming, - () => ({ - type: "doc_edited", - filename: data.filename as string, - document_id: - (data.document_id as string) ?? "", - version_id: - (data.version_id as string) ?? "", - version_number: - typeof data.version_number === "number" - ? (data.version_number as number) - : null, - download_url: - (data.download_url as string) ?? "", - annotations: Array.isArray(data.annotations) - ? (data.annotations as import("@/app/components/shared/types").MikeEditAnnotation[]) - : [], - error: - typeof data.error === "string" - ? (data.error as string) - : undefined, - isStreaming: false, - }), - ); - pushThinkingPlaceholder(); - continue; - } - - if (data.type === "citations") { - // End-of-stream signal — scrub any lingering - // placeholders so they don't persist into the - // finalised message. - clearStreamingPlaceholders(); - const incoming = (data.citations ?? - []) as MikeCitationAnnotation[]; - setMessages((prev) => { - const updated = [...prev]; - const last = updated[updated.length - 1]; - if (last?.role === "assistant") { - updated[updated.length - 1] = { - ...last, - annotations: incoming, - }; - } - return updated; - }); - continue; - } - } catch (e) { - console.warn( - "[useAssistantChat] failed to parse SSE line:", - trimmed, - e, - ); - } - } - } - - flushDrip(); - finalizeStreamingReasoning(); - setIsResponseLoading(false); - setIsLoadingCitations(false); - - const finalChatId = streamedChatId || chatId || null; - if (finalChatId && finalChatId !== chatId) { - if (chatId) { - replaceChatId( - chatId, - finalChatId, - message.content.trim().slice(0, 120) || "New Chat", - ); - } - setCurrentChatId(finalChatId); - const chatBasePath = projectId - ? `/projects/${projectId}/assistant/chat` - : `/assistant/chat`; - router.replace(`${chatBasePath}/${finalChatId}`); - } - - await loadChats(); - - const finalChatIdForTitle = streamedChatId || chatId || null; - if (finalChatIdForTitle && newMessages.length === 1) { - const titleParts = [message.content]; - if (message.workflow) - titleParts.push(`Workflow: ${message.workflow.title}`); - if (message.files?.length) - titleParts.push( - `Files: ${message.files.map((f) => f.filename).join(", ")}`, - ); - void generateTitle(finalChatIdForTitle, titleParts.join("\n")); - } - - return streamedChatId || null; - } catch (error: unknown) { - if (error instanceof Error && error.name === "AbortError") { - flushDrip(); - setMessages((prev) => { - const last = prev[prev.length - 1]; - if (last?.role === "assistant") { - const updated = [...prev]; - const events = last.events ?? []; - const idx = findLastContentIndex(events); - const cancelText = "Cancelled by user"; - if (idx >= 0) { - const newEvents = [...events]; - const existing = newEvents[idx] as { - type: "content"; - text: string; - }; - newEvents[idx] = { - type: "content", - text: existing.text - ? `${existing.text}\n\nCancelled by user` - : cancelText, - }; - updated[updated.length - 1] = { - ...last, - events: newEvents, - }; - } else { - updated[updated.length - 1] = { - ...last, - events: [ - ...events, - { type: "content", text: cancelText }, - ], - }; - } - return updated; - } - return [ - ...prev, - { - role: "assistant", - content: "", - events: [ - { type: "content", text: "Cancelled by user" }, - ], - }, - ]; - }); + const events = last.events ?? []; + const idx = findLastContentIndex(events); + const cancelText = "Cancelled by user"; + if (idx >= 0) { + const newEvents = [...events]; + const existing = newEvents[idx] as { + type: "content"; + text: string; + }; + newEvents[idx] = { + type: "content", + text: existing.text + ? `${existing.text}\n\nCancelled by user` + : cancelText, + }; + updated[updated.length - 1] = { + ...last, + events: newEvents, + }; } else { - stopDrip(); - const errorMessage = - error instanceof Error && error.message - ? error.message - : "Sorry, something went wrong."; - setMessages((prev) => { - const last = prev[prev.length - 1]; - if (last?.role === "assistant") { - const updated = [...prev]; - updated[updated.length - 1] = { - ...last, - error: errorMessage, - }; - return updated; - } - return [ - ...prev, - { - role: "assistant", - content: "", - error: errorMessage, - }, - ]; - }); + updated[updated.length - 1] = { + ...last, + events: [...events, { type: "content", text: cancelText }], + }; } + return updated; + } + return [ + ...prev, + { + role: "assistant", + content: "", + events: [{ type: "content", text: "Cancelled by user" }], + }, + ]; + }); + } else { + finalizeStreamingContent(); + const errorMessage = + error instanceof Error && error.message + ? error.message + : "Sorry, something went wrong."; + setMessages((prev) => { + const last = prev[prev.length - 1]; + if (last?.role === "assistant") { + const updated = [...prev]; + updated[updated.length - 1] = { + ...last, + error: errorMessage, + }; + return updated; + } + return [ + ...prev, + { + role: "assistant", + content: "", + error: errorMessage, + }, + ]; + }); + } - setIsResponseLoading(false); - setIsLoadingCitations(false); - return null; - } finally { - abortControllerRef.current = null; - } - }; + setIsResponseLoading(false); + setIsLoadingCitations(false); + return null; + } finally { + abortControllerRef.current = null; + } + }; - const handleNewChat = async ( - message: MikeMessage, - projectId?: string, - ): Promise<string | null> => { - if (!message.content.trim()) return null; + const handleNewChat = async ( + message: Message, + projectId?: string, + ): Promise<string | null> => { + if (!message.content.trim()) return null; - setMessages([message]); - setNewChatMessages([message]); + setMessages([message]); + setNewChatMessages([message]); - const newChatId = await saveChat(projectId); - if (newChatId) { - setChatId(newChatId); - setCurrentChatId(newChatId); - } + const newChatId = await saveChat(projectId); + if (newChatId) { + setChatId(newChatId); + setCurrentChatId(newChatId); + } - return newChatId; - }; + return newChatId; + }; - return { - messages, - isResponseLoading, - setIsResponseLoading, - isLoadingCitations, - handleChat, - handleNewChat, - setMessages, - cancel, - chatId, - }; + return { + messages, + isResponseLoading, + setIsResponseLoading, + isLoadingCitations, + handleChat, + handleNewChat, + setMessages, + cancel, + chatId, + }; } diff --git a/frontend/src/app/hooks/useFetchDocxBytes.ts b/frontend/src/app/hooks/useFetchDocxBytes.ts index 43cdf25..27dd6a0 100644 --- a/frontend/src/app/hooks/useFetchDocxBytes.ts +++ b/frontend/src/app/hooks/useFetchDocxBytes.ts @@ -47,14 +47,6 @@ export function useFetchDocxBytes( const [loading, setLoading] = useState(false); const [error, setError] = useState<string | null>(null); - console.log("[useFetchDocxBytes] init", { - documentId, - versionId, - refetchKey, - initialKey, - cacheHit: initialKey ? bytesCache.has(initialKey) : null, - }); - useEffect(() => { if (!documentId) { setBytes(null); diff --git a/frontend/src/app/lib/documentUploadValidation.ts b/frontend/src/app/lib/documentUploadValidation.ts new file mode 100644 index 0000000..5866a0e --- /dev/null +++ b/frontend/src/app/lib/documentUploadValidation.ts @@ -0,0 +1,27 @@ +export const SUPPORTED_DOCUMENT_ACCEPT = ".pdf,.docx,.doc"; +export const UNSUPPORTED_DOCUMENT_WARNING_MESSAGE = + "Unsupported file type. Only PDF, DOCX, and DOC files can be uploaded."; + +const SUPPORTED_DOCUMENT_EXTENSIONS = new Set(["pdf", "docx", "doc"]); + +export function isSupportedDocumentFile(file: File): boolean { + const extension = file.name.split(".").pop()?.toLowerCase(); + return !!extension && SUPPORTED_DOCUMENT_EXTENSIONS.has(extension); +} + +export function partitionSupportedDocumentFiles(files: File[]) { + const supported: File[] = []; + const unsupported: File[] = []; + + for (const file of files) { + if (isSupportedDocumentFile(file)) supported.push(file); + else unsupported.push(file); + } + + return { supported, unsupported }; +} + +export function formatUnsupportedDocumentWarning(files: File[]): string | null { + if (files.length === 0) return null; + return UNSUPPORTED_DOCUMENT_WARNING_MESSAGE; +} diff --git a/frontend/src/app/lib/mikeApi.ts b/frontend/src/app/lib/mikeApi.ts index 5b7e37e..88233a6 100644 --- a/frontend/src/app/lib/mikeApi.ts +++ b/frontend/src/app/lib/mikeApi.ts @@ -6,14 +6,14 @@ import { supabase } from "@/lib/supabase"; import type { AssistantEvent, - MikeChat, - MikeChatDetailOut, - MikeCitationAnnotation, - MikeDocument, - MikeFolder, - MikeMessage, - MikeProject, - MikeWorkflow, + Chat, + ChatDetailOut, + CitationAnnotation, + Document, + Folder, + Message, + Project, + Workflow, TabularReview, TabularReviewDetailOut, } from "@/app/components/shared/types"; @@ -26,11 +26,11 @@ interface ServerMessage { content: string | AssistantEvent[] | null; files?: { filename: string; document_id?: string }[] | null; workflow?: { id: string; title: string } | null; - annotations?: MikeCitationAnnotation[] | null; + annotations?: CitationAnnotation[] | null; created_at: string; } interface ServerChatDetailOut { - chat: MikeChat; + chat: Chat; messages: ServerMessage[]; } @@ -77,16 +77,16 @@ async function apiRequest<T>(path: string, init?: RequestInit): Promise<T> { // Projects // --------------------------------------------------------------------------- -export async function listProjects(): Promise<MikeProject[]> { - return apiRequest<MikeProject[]>("/projects"); +export async function listProjects(): Promise<Project[]> { + return apiRequest<Project[]>("/projects"); } export async function createProject( name: string, cm_number?: string, shared_with?: string[], -): Promise<MikeProject> { - return apiRequest<MikeProject>("/projects", { +): Promise<Project> { + return apiRequest<Project>("/projects", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, cm_number, shared_with }), @@ -104,6 +104,7 @@ export interface UserProfile { creditsResetDate: string; creditsRemaining: number; tier: string; + titleModel: string; tabularModel: string; apiKeyStatus: ApiKeyStatus; } @@ -115,6 +116,7 @@ export async function getUserProfile(): Promise<UserProfile> { export async function updateUserProfile(payload: { displayName?: string | null; organisation?: string | null; + titleModel?: string; tabularModel?: string; }): Promise<UserProfile> { return apiRequest<UserProfile>("/user/profile", { @@ -124,7 +126,12 @@ export async function updateUserProfile(payload: { }); } -export type ApiKeyProvider = "claude" | "gemini" | "openai"; +export type ApiKeyProvider = + | "claude" + | "gemini" + | "openai" + | "openrouter" + | "courtlistener"; export type ApiKeySource = "user" | "env" | null; export type ApiKeyState = Record< ApiKeyProvider, @@ -153,8 +160,8 @@ export async function saveApiKey( }); } -export async function getProject(projectId: string): Promise<MikeProject> { - return apiRequest<MikeProject>(`/projects/${projectId}`); +export async function getProject(projectId: string): Promise<Project> { + return apiRequest<Project>(`/projects/${projectId}`); } export async function updateProject( @@ -164,8 +171,8 @@ export async function updateProject( cm_number?: string; shared_with?: string[]; }, -): Promise<MikeProject> { - return apiRequest<MikeProject>(`/projects/${projectId}`, { +): Promise<Project> { + return apiRequest<Project>(`/projects/${projectId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), @@ -203,8 +210,8 @@ export async function createProjectFolder( projectId: string, name: string, parentFolderId?: string | null, -): Promise<MikeFolder> { - return apiRequest<MikeFolder>(`/projects/${projectId}/folders`, { +): Promise<Folder> { + return apiRequest<Folder>(`/projects/${projectId}/folders`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -218,8 +225,8 @@ export async function renameProjectFolder( projectId: string, folderId: string, name: string, -): Promise<MikeFolder> { - return apiRequest<MikeFolder>( +): Promise<Folder> { + return apiRequest<Folder>( `/projects/${projectId}/folders/${folderId}`, { method: "PATCH", @@ -242,8 +249,8 @@ export async function moveSubfolderToFolder( projectId: string, folderId: string, parentFolderId: string | null, -): Promise<MikeFolder> { - return apiRequest<MikeFolder>( +): Promise<Folder> { + return apiRequest<Folder>( `/projects/${projectId}/folders/${folderId}`, { method: "PATCH", @@ -257,8 +264,8 @@ export async function moveDocumentToFolder( projectId: string, documentId: string, folderId: string | null, -): Promise<MikeDocument> { - return apiRequest<MikeDocument>( +): Promise<Document> { + return apiRequest<Document>( `/projects/${projectId}/documents/${documentId}/folder`, { method: "PATCH", @@ -272,8 +279,8 @@ export async function renameProjectDocument( projectId: string, documentId: string, filename: string, -): Promise<MikeDocument> { - return apiRequest<MikeDocument>( +): Promise<Document> { + return apiRequest<Document>( `/projects/${projectId}/documents/${documentId}`, { method: "PATCH", @@ -286,24 +293,27 @@ export async function renameProjectDocument( export async function addDocumentToProject( projectId: string, documentId: string, -): Promise<MikeDocument> { - return apiRequest<MikeDocument>( +): Promise<Document> { + return apiRequest<Document>( `/projects/${projectId}/documents/${documentId}`, { method: "POST" }, ); } -export interface MikeDocumentVersion { +export interface DocumentVersion { id: string; version_number: number | null; source: string; created_at: string; - display_name: string | null; + filename: string | null; + file_type?: string | null; + size_bytes?: number | null; + page_count?: number | null; } export async function listDocumentVersions(documentId: string): Promise<{ current_version_id: string | null; - versions: MikeDocumentVersion[]; + versions: DocumentVersion[]; }> { return apiRequest(`/single-documents/${documentId}/versions`); } @@ -311,12 +321,12 @@ export async function listDocumentVersions(documentId: string): Promise<{ export async function uploadDocumentVersion( documentId: string, file: File, - displayName?: string, -): Promise<MikeDocumentVersion> { + filename?: string, +): Promise<DocumentVersion> { const authHeaders = await getAuthHeader(); const form = new FormData(); form.append("file", file); - if (displayName) form.append("display_name", displayName); + if (filename) form.append("filename", filename); const response = await fetch( `${API_BASE}/single-documents/${documentId}/versions`, { @@ -326,28 +336,58 @@ export async function uploadDocumentVersion( }, ); if (!response.ok) throw new Error(await response.text()); - return response.json() as Promise<MikeDocumentVersion>; + return response.json() as Promise<DocumentVersion>; +} + +export async function copyDocumentVersionFromDocument( + documentId: string, + sourceDocumentId: string, + filename?: string, +): Promise<DocumentVersion> { + return apiRequest<DocumentVersion>( + `/single-documents/${documentId}/versions/from-document`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + source_document_id: sourceDocumentId, + filename, + }), + }, + ); } export async function renameDocumentVersion( documentId: string, versionId: string, - displayName: string | null, -): Promise<MikeDocumentVersion> { - return apiRequest<MikeDocumentVersion>( + filename: string | null, +): Promise<DocumentVersion> { + return apiRequest<DocumentVersion>( `/single-documents/${documentId}/versions/${versionId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ display_name: displayName }), + body: JSON.stringify({ filename }), }, ); } +export async function deleteDocumentVersion( + documentId: string, + versionId: string, +): Promise<{ + deleted_version_id: string; + current_version_id: string | null; +}> { + return apiRequest(`/single-documents/${documentId}/versions/${versionId}`, { + method: "DELETE", + }); +} + export async function uploadProjectDocument( projectId: string, file: File, -): Promise<MikeDocument> { +): Promise<Document> { const authHeaders = await getAuthHeader(); const form = new FormData(); form.append("file", file); @@ -360,12 +400,12 @@ export async function uploadProjectDocument( }, ); if (!response.ok) throw new Error(await response.text()); - return response.json() as Promise<MikeDocument>; + return response.json() as Promise<Document>; } export async function uploadStandaloneDocument( file: File, -): Promise<MikeDocument> { +): Promise<Document> { const authHeaders = await getAuthHeader(); const form = new FormData(); form.append("file", file); @@ -375,11 +415,11 @@ export async function uploadStandaloneDocument( body: form, }); if (!response.ok) throw new Error(await response.text()); - return response.json() as Promise<MikeDocument>; + return response.json() as Promise<Document>; } -export async function listStandaloneDocuments(): Promise<MikeDocument[]> { - return apiRequest<MikeDocument[]>("/single-documents"); +export async function listStandaloneDocuments(): Promise<Document[]> { + return apiRequest<Document[]>("/single-documents"); } export async function deleteDocument(documentId: string): Promise<void> { @@ -428,20 +468,20 @@ export async function createChat(payload?: { }); } -export async function listChats(options?: { limit?: number }): Promise<MikeChat[]> { +export async function listChats(options?: { limit?: number }): Promise<Chat[]> { const params = new URLSearchParams(); if (options?.limit) params.set("limit", String(options.limit)); const query = params.toString(); - return apiRequest<MikeChat[]>(`/chat${query ? `?${query}` : ""}`); + return apiRequest<Chat[]>(`/chat${query ? `?${query}` : ""}`); } -export async function listProjectChats(projectId: string): Promise<MikeChat[]> { - return apiRequest<MikeChat[]>(`/projects/${projectId}/chats`); +export async function listProjectChats(projectId: string): Promise<Chat[]> { + return apiRequest<Chat[]>(`/projects/${projectId}/chats`); } -export async function getChat(chatId: string): Promise<MikeChatDetailOut> { +export async function getChat(chatId: string): Promise<ChatDetailOut> { const raw = await apiRequest<ServerChatDetailOut>(`/chat/${chatId}`); - const messages: MikeMessage[] = raw.messages.map((m) => { + const messages: Message[] = raw.messages.map((m) => { if (m.role === "user") { return { role: "user", @@ -490,6 +530,32 @@ export async function generateChatTitle( }); } +export type CaseLawOpinion = { + opinionId: number | null; + apiUrl?: string | null; + type: string | null; + author: string | null; + url: string | null; + text?: string | null; + html?: string | null; +}; + +export async function getCourtlistenerOpinions( + clusterId: number, +): Promise<CaseLawOpinion[]> { + const result = await apiRequest<{ opinions: CaseLawOpinion[] }>( + "/case-law/case-opinions", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + clusterId, + }), + }, + ); + return result.opinions; +} + export async function streamChat(payload: { messages: { role: string; @@ -627,7 +693,7 @@ export async function uploadReviewDocument( documentIds?: string[]; columnsConfig?: { index: number; name: string; prompt: string }[]; }, -): Promise<MikeDocument> { +): Promise<Document> { const uploaded = options?.projectId ? await uploadProjectDocument(options.projectId, file) : await uploadStandaloneDocument(file); @@ -789,16 +855,16 @@ export async function clearTabularCells( // Workflows // --------------------------------------------------------------------------- -type WorkflowType = MikeWorkflow["type"]; +type WorkflowType = Workflow["type"]; export async function listWorkflows( type: WorkflowType, -): Promise<MikeWorkflow[]> { - return apiRequest<MikeWorkflow[]>(`/workflows?type=${type}`); +): Promise<Workflow[]> { + return apiRequest<Workflow[]>(`/workflows?type=${type}`); } -export async function getWorkflow(workflowId: string): Promise<MikeWorkflow> { - return apiRequest<MikeWorkflow>(`/workflows/${workflowId}`); +export async function getWorkflow(workflowId: string): Promise<Workflow> { + return apiRequest<Workflow>(`/workflows/${workflowId}`); } export async function createWorkflow(payload: { @@ -807,8 +873,8 @@ export async function createWorkflow(payload: { prompt_md?: string; columns_config?: { index: number; name: string; prompt: string }[]; practice?: string | null; -}): Promise<MikeWorkflow> { - return apiRequest<MikeWorkflow>("/workflows", { +}): Promise<Workflow> { + return apiRequest<Workflow>("/workflows", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), @@ -823,8 +889,8 @@ export async function updateWorkflow( columns_config?: { index: number; name: string; prompt: string }[]; practice?: string | null; }, -): Promise<MikeWorkflow> { - return apiRequest<MikeWorkflow>(`/workflows/${workflowId}`, { +): Promise<Workflow> { + return apiRequest<Workflow>(`/workflows/${workflowId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), diff --git a/frontend/src/app/lib/modelAvailability.ts b/frontend/src/app/lib/modelAvailability.ts index fe41f46..fb0f09a 100644 --- a/frontend/src/app/lib/modelAvailability.ts +++ b/frontend/src/app/lib/modelAvailability.ts @@ -1,10 +1,10 @@ -import { MODELS, type ModelOption } from "../components/assistant/ModelToggle"; +import { SETTINGS_MODELS, type ModelOption } from "../components/assistant/ModelToggle"; import type { ApiKeyState } from "@/app/lib/mikeApi"; export type ModelProvider = "claude" | "gemini" | "openai"; export function getModelProvider(modelId: string): ModelProvider | null { - const model = MODELS.find((m) => m.id === modelId); + const model = SETTINGS_MODELS.find((m) => m.id === modelId); if (!model) return null; return modelGroupToProvider(model.group); } diff --git a/frontend/src/contexts/UserProfileContext.tsx b/frontend/src/contexts/UserProfileContext.tsx index cb166ca..0b4e8dd 100644 --- a/frontend/src/contexts/UserProfileContext.tsx +++ b/frontend/src/contexts/UserProfileContext.tsx @@ -25,6 +25,7 @@ interface UserProfile { creditsResetDate: string; creditsRemaining: number; tier: string; + titleModel: string; tabularModel: string; apiKeys: ApiKeyState; } @@ -35,7 +36,7 @@ interface UserProfileContextType { updateDisplayName: (name: string) => Promise<boolean>; updateOrganisation: (organisation: string) => Promise<boolean>; updateModelPreference: ( - field: "tabularModel", + field: "titleModel" | "tabularModel", value: string, ) => Promise<boolean>; updateApiKey: ( @@ -50,13 +51,21 @@ const UserProfileContext = createContext<UserProfileContextType | undefined>( undefined, ); -const API_KEY_PROVIDERS: ApiKeyProvider[] = ["claude", "gemini", "openai"]; +const API_KEY_PROVIDERS: ApiKeyProvider[] = [ + "claude", + "gemini", + "openai", + "openrouter", + "courtlistener", +]; function emptyApiKeys(): ApiKeyState { return { claude: { configured: false, source: null }, gemini: { configured: false, source: null }, openai: { configured: false, source: null }, + openrouter: { configured: false, source: null }, + courtlistener: { configured: false, source: null }, }; } @@ -100,6 +109,7 @@ export function UserProfileProvider({ children }: { children: ReactNode }) { creditsResetDate: futureResetDate.toISOString(), creditsRemaining: 999999, // temporarily unlimited tier: "Free", + titleModel: "gemini-3.1-flash-lite-preview", tabularModel: "gemini-3-flash-preview", apiKeys: emptyApiKeys(), }); @@ -154,12 +164,14 @@ export function UserProfileProvider({ children }: { children: ReactNode }) { ); const updateModelPreference = useCallback( - async (field: "tabularModel", value: string): Promise<boolean> => { + async ( + field: "titleModel" | "tabularModel", + value: string, + ): Promise<boolean> => { if (!user) return false; - if (field !== "tabularModel") return false; try { const updated = await updateUserProfile({ - tabularModel: value, + [field]: value, }); setProfile((prev) => prev ? { ...prev, ...toProfile(updated) } : null, diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index 74a7c35..6199490 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -43,7 +43,6 @@ export async function getUserFromRequest(request: NextRequest): Promise<{ return null; } - console.log(`[Auth] User authenticated: ${user.email}`); return { email: user.email, id: user.id @@ -53,4 +52,3 @@ export async function getUserFromRequest(request: NextRequest): Promise<{ return null; } } - diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts deleted file mode 100644 index 2645490..0000000 --- a/frontend/src/lib/storage.ts +++ /dev/null @@ -1,132 +0,0 @@ -/** - * Cloudflare R2 storage utilities for Mike document management. - * R2 is S3-compatible — uses @aws-sdk/client-s3. - * - * Required env vars: - * R2_ENDPOINT_URL — https://<account-id>.r2.cloudflarestorage.com - * R2_ACCESS_KEY_ID — R2 API token (Access Key ID) - * R2_SECRET_ACCESS_KEY — R2 API token (Secret Access Key) - * R2_BUCKET_NAME — bucket name (default: "mike") - */ - -import { - S3Client, - PutObjectCommand, - GetObjectCommand, - DeleteObjectCommand, -} from "@aws-sdk/client-s3"; -import { getSignedUrl as awsGetSignedUrl } from "@aws-sdk/s3-request-presigner"; - -function getClient(): S3Client { - return new S3Client({ - region: "auto", - endpoint: process.env.R2_ENDPOINT_URL!, - credentials: { - accessKeyId: process.env.R2_ACCESS_KEY_ID!, - secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!, - }, - }); -} - -const BUCKET = process.env.R2_BUCKET_NAME ?? "mike"; - -export const storageEnabled = Boolean( - process.env.R2_ENDPOINT_URL && - process.env.R2_ACCESS_KEY_ID && - process.env.R2_SECRET_ACCESS_KEY, -); - -// --------------------------------------------------------------------------- -// Upload -// --------------------------------------------------------------------------- - -export async function uploadFile( - key: string, - content: ArrayBuffer, - contentType: string, -): Promise<void> { - const client = getClient(); - await client.send( - new PutObjectCommand({ - Bucket: BUCKET, - Key: key, - Body: Buffer.from(content), - ContentType: contentType, - }), - ); -} - -// --------------------------------------------------------------------------- -// Download -// --------------------------------------------------------------------------- - -export async function downloadFile(key: string): Promise<ArrayBuffer | null> { - if (!storageEnabled) return null; - try { - const client = getClient(); - const response = await client.send( - new GetObjectCommand({ Bucket: BUCKET, Key: key }), - ); - if (!response.Body) return null; - const bytes = await response.Body.transformToByteArray(); - return bytes.buffer as ArrayBuffer; - } catch { - return null; - } -} - -// --------------------------------------------------------------------------- -// Delete -// --------------------------------------------------------------------------- - -export async function deleteFile(key: string): Promise<void> { - if (!storageEnabled) return; - const client = getClient(); - await client.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: key })); -} - -// --------------------------------------------------------------------------- -// Signed URL (pre-signed for temporary direct access) -// --------------------------------------------------------------------------- - -export async function getSignedUrl( - key: string, - expiresIn = 3600, -): Promise<string | null> { - if (!storageEnabled) return null; - try { - const client = getClient(); - const command = new GetObjectCommand({ Bucket: BUCKET, Key: key }); - return await awsGetSignedUrl(client, command, { expiresIn }); - } catch { - return null; - } -} - -// --------------------------------------------------------------------------- -// Storage key helpers -// --------------------------------------------------------------------------- - -export function storageKey( - userId: string, - docId: string, - filename: string, -): string { - return `documents/${userId}/${docId}/${filename}`; -} - -export function pdfStorageKey( - userId: string, - docId: string, - stem: string, -): string { - return `documents/${userId}/${docId}/${stem}.pdf`; -} - -export function generatedDocKey( - userId: string, - docId: string, - filename: string, -): string { - return `generated/${userId}/${docId}/${filename}`; -}