Add courtlistener intergration, liquid glass redesign, UI improvements, version control, various fixes

This commit is contained in:
willchen96 2026-06-06 15:48:47 +08:00
parent d39f5806e5
commit 44e868eb42
106 changed files with 16350 additions and 7753 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

@ -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 ?? "";
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -61,6 +61,7 @@ export default function AssistantChatPage() {
return (
<ChatView
chatId={id}
messages={messages}
isResponseLoading={isResponseLoading}
handleChat={handleChat}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

@ -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()}
>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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">
&ldquo;{displayQuote}&rdquo;
{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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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"
}`}
>
&ldquo;{quote.quote.replace(/"/g, "'")}&rdquo;
{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>
);
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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[];
}

View file

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

View file

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

View file

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

View file

@ -85,8 +85,6 @@ export function TREditColumnMenu({
setSaving(false);
}
}
console.log(tags);
async function handleDelete() {
setDeleting(true);
try {

View file

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

View file

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

View file

@ -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={[

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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">
{(() => {

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

Some files were not shown because too many files have changed in this diff Show more