mirror of
https://github.com/willchen96/mike.git
synced 2026-06-08 20:25:13 +02:00
Add courtlistener intergration, liquid glass redesign, UI improvements, version control, various fixes
This commit is contained in:
parent
d39f5806e5
commit
44e868eb42
106 changed files with 16350 additions and 7753 deletions
38
README.md
38
README.md
|
|
@ -9,7 +9,7 @@ Website: [mikeoss.com](https://mikeoss.com)
|
|||
- `frontend/` - Next.js application
|
||||
- `backend/` - Express API, Supabase access, document processing, and database schema
|
||||
- `backend/schema.sql` - Supabase schema for fresh databases
|
||||
- `backend/migrations/` - incremental database updates for existing deployments
|
||||
- `backend/oss-migrations/` - OSS-specific migrations that should be applied to existing open-source deployments
|
||||
|
||||
## Prerequisites
|
||||
|
||||
|
|
@ -19,6 +19,7 @@ Website: [mikeoss.com](https://mikeoss.com)
|
|||
- A Supabase project
|
||||
- A Cloudflare R2 bucket, MinIO bucket, or another S3-compatible bucket
|
||||
- At least one supported model provider API key: Anthropic, Google Gemini, or OpenAI
|
||||
- Optional: a CourtListener API token for case law lookup and citation verification
|
||||
- LibreOffice installed locally if you need DOC/DOCX to PDF conversion
|
||||
|
||||
## Database Setup
|
||||
|
|
@ -30,9 +31,9 @@ For a new Supabase database, open the Supabase SQL editor and run:
|
|||
-- backend/schema.sql
|
||||
```
|
||||
|
||||
The schema file is based on `supabase-migration.sql` and folds in the later files in `backend/migrations/`.
|
||||
The schema file is for fresh deployments and already includes the latest database shape.
|
||||
|
||||
For an existing database, do not run the full schema file over production data. Apply the incremental files in `backend/migrations/` instead.
|
||||
For an existing database, do not run the full schema file over production data. Apply the relevant incremental files in `backend/oss-migrations/` instead; these capture schema changes for open-source deployments.
|
||||
|
||||
## Environment
|
||||
|
||||
|
|
@ -62,6 +63,12 @@ ANTHROPIC_API_KEY=your-anthropic-key
|
|||
OPENAI_API_KEY=your-openai-key
|
||||
RESEND_API_KEY=your-resend-key
|
||||
USER_API_KEYS_ENCRYPTION_SECRET=your-long-random-secret
|
||||
|
||||
# Optional: enables CourtListener case law and citation tools.
|
||||
COURTLISTENER_API_TOKEN=your-courtlistener-token
|
||||
|
||||
# Optional: use locally imported CourtListener bulk data for faster case reads.
|
||||
COURTLISTENER_BULK_DATA_ENABLED=false
|
||||
```
|
||||
|
||||
Create `frontend/.env.local`:
|
||||
|
|
@ -74,7 +81,23 @@ NEXT_PUBLIC_API_BASE_URL=http://localhost:3001
|
|||
|
||||
Supabase values come from the project dashboard. Use the project URL for `SUPABASE_URL` / `NEXT_PUBLIC_SUPABASE_URL`, the service role key for the backend `SUPABASE_SECRET_KEY`, and the anon/public key for `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY`. If your Supabase project shows multiple key formats, use the legacy JWT-style anon and service role keys expected by the Supabase client libraries.
|
||||
|
||||
Provider keys are only needed for the models and email features you plan to use. Model provider keys can be configured in `backend/.env` for the whole instance, or per user in **Account > Models & API Keys**. If a provider key is present in `backend/.env`, that provider is available by default and the matching browser API key field is read-only.
|
||||
Provider keys are only needed for the models, legal research, and email features you plan to use. Model provider keys and the CourtListener token can be configured in `backend/.env` for the whole instance, or per user in **Account > Models & API Keys**. If a provider key is present in `backend/.env`, that provider is available by default and the matching browser API key field is read-only.
|
||||
|
||||
## CourtListener Integration
|
||||
|
||||
Mike can use CourtListener for US case law citation verification, case fetching, targeted opinion search, and case-law panels in assistant responses.
|
||||
|
||||
To enable live CourtListener access, set `COURTLISTENER_API_TOKEN` in `backend/.env` and restart the backend. Users can also add their own CourtListener token from **Account > Models & API Keys** when the instance does not provide one globally.
|
||||
|
||||
Fresh databases created from `backend/schema.sql` already include the CourtListener support tables. Existing OSS deployments should apply the matching migration in `backend/oss-migrations/` before enabling the feature.
|
||||
|
||||
Bulk data is optional. When `COURTLISTENER_BULK_DATA_ENABLED=true`, Mike first tries local Supabase/R2 data before falling back to CourtListener's API:
|
||||
|
||||
- citation metadata is read from `public.courtlistener_citation_index`
|
||||
- case cluster metadata is read from `public.courtlistener_opinion_cluster_index`
|
||||
- cached opinion JSON is read from the R2 prefix `courtlistener/opinions/by-cluster/{clusterId}/{opinionId}.json`
|
||||
|
||||
If you do not import bulk data, leave `COURTLISTENER_BULK_DATA_ENABLED=false`; live CourtListener tools still work with a valid token, subject to CourtListener rate limits.
|
||||
|
||||
## Install
|
||||
|
||||
|
|
@ -105,7 +128,8 @@ Open `http://localhost:3000`.
|
|||
|
||||
1. Sign up in the app.
|
||||
2. If you did not set provider keys in `backend/.env`, open **Account > Models & API Keys** and add an Anthropic, Gemini, or OpenAI API key.
|
||||
3. Create or open a project and start chatting with documents.
|
||||
3. To use legal research tools, add a CourtListener token in `backend/.env` or **Account > Models & API Keys**.
|
||||
4. Create or open a project and start chatting with documents.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
|
@ -113,6 +137,10 @@ Open `http://localhost:3000`.
|
|||
|
||||
**The model picker shows a missing-key warning.** Add a key for that provider in **Account > Models & API Keys**, or configure the provider key in `backend/.env` and restart the backend.
|
||||
|
||||
**CourtListener tools say the API token is missing.** Set `COURTLISTENER_API_TOKEN` in `backend/.env`, or add a CourtListener token in **Account > Models & API Keys** for the signed-in user. Restart the backend after changing `.env`.
|
||||
|
||||
**CourtListener bulk lookup is not returning local results.** Confirm `COURTLISTENER_BULK_DATA_ENABLED=true`, the two CourtListener tables have been populated, and opinion JSON exists in R2 under `courtlistener/opinions/by-cluster/`. If bulk data is unavailable, Mike falls back to the live API when a token is configured.
|
||||
|
||||
**DOC or DOCX conversion fails.** Install LibreOffice locally and restart the backend so document conversion commands are available on the process path.
|
||||
|
||||
## Useful Checks
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
162
backend/oss-migrations/20260606_oss_schema_diff.sql
Normal file
162
backend/oss-migrations/20260606_oss_schema_diff.sql
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
1008
backend/src/lib/courtlistener.ts
Normal file
1008
backend/src/lib/courtlistener.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
197
backend/src/lib/legalSourcesTools/courtlistenerTools.ts
Normal file
197
backend/src/lib/legalSourcesTools/courtlistenerTools.ts
Normal 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.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 ?? "";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
19
backend/src/lib/llm/rawStreamLog.ts
Normal file
19
backend/src/lib/llm/rawStreamLog.ts
Normal 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 });
|
||||
}
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
84
backend/src/routes/caseLaw.ts
Normal file
84
backend/src/routes/caseLaw.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
|
|
@ -6,8 +6,11 @@ import {
|
|||
buildMessages,
|
||||
enrichWithPriorEvents,
|
||||
buildWorkflowStore,
|
||||
AssistantStreamError,
|
||||
extractAnnotations,
|
||||
isAbortError,
|
||||
runLLMStream,
|
||||
stripTransientAssistantEvents,
|
||||
type ChatMessage,
|
||||
} from "../lib/chatTools";
|
||||
import { completeText } from "../lib/llm";
|
||||
|
|
@ -22,6 +25,14 @@ const devLog = (...args: Parameters<typeof console.log>) => {
|
|||
if (isDev) console.log(...args);
|
||||
};
|
||||
|
||||
const TITLE_FALLBACK = "Misc. Query";
|
||||
|
||||
function normalizeGeneratedTitle(raw: string): string {
|
||||
const title = raw.trim().replace(/^["'`]+|["'`.,:;!?]+$/g, "").trim();
|
||||
if (!title) return TITLE_FALLBACK;
|
||||
return title.slice(0, 80);
|
||||
}
|
||||
|
||||
type AccessibleChat = {
|
||||
id: string;
|
||||
title: string | null;
|
||||
|
|
@ -225,11 +236,12 @@ chatRouter.get("/:chatId", requireAuth, async (req, res) => {
|
|||
res.json({ chat, messages: hydrated });
|
||||
});
|
||||
|
||||
// Stored message annotations/events capture the `status` at the time the
|
||||
// assistant produced the edit (always "pending"). If the user later accepts
|
||||
// or rejects, `document_edits.status` is updated but the stored message
|
||||
// annotation is not. On chat load we merge the current DB status in so
|
||||
// EditCards render with the real state.
|
||||
// Stored doc_edited events capture the `status` at the time the assistant
|
||||
// produced the edit (always "pending"). If the user later accepts or rejects,
|
||||
// `document_edits.status` is updated but the stored event is not. On chat load
|
||||
// we merge the current DB status in so EditCards render with the real state.
|
||||
// Legacy rows may also have duplicate edit_data in top-level annotations, so
|
||||
// keep patching that path until old data no longer matters.
|
||||
async function hydrateEditStatuses(
|
||||
messages: Record<string, unknown>[],
|
||||
db: ReturnType<typeof createServerSupabase>,
|
||||
|
|
@ -401,11 +413,11 @@ chatRouter.post("/:chatId/generate-title", requireAuth, async (req, res) => {
|
|||
);
|
||||
const titleText = await completeText({
|
||||
model: title_model,
|
||||
user: `Generate a concise title (3–6 words) for a chat in an AI Legal Platform that starts with this message. The title should describe the topic or document — do NOT include words like "Legal Assistant", "AI", "Chat", or any similar prefix. Return only the title, no quotes or punctuation.\n\nMessage: ${message.slice(0, 500)}`,
|
||||
user: `Generate a concise title (3–6 words) for a chat in an AI Legal Platform that starts with this message. The title should describe the topic or document — do NOT include words like "Legal Assistant", "AI", "Chat", or any similar prefix. If there is not enough information to generate a title, return exactly "${TITLE_FALLBACK}". Return only the title, no quotes or punctuation.\n\nMessage: ${message.slice(0, 500)}`,
|
||||
maxTokens: 64,
|
||||
apiKeys: api_keys,
|
||||
});
|
||||
const title = titleText.trim() || message.slice(0, 60);
|
||||
const title = normalizeGeneratedTitle(titleText);
|
||||
|
||||
await db
|
||||
.from("chats")
|
||||
|
|
@ -555,13 +567,18 @@ chatRouter.post("/", requireAuth, async (req, res) => {
|
|||
res.flushHeaders();
|
||||
|
||||
const write = (line: string) => res.write(line);
|
||||
const streamAbort = new AbortController();
|
||||
let streamFinished = false;
|
||||
res.on("close", () => {
|
||||
if (!streamFinished) streamAbort.abort();
|
||||
});
|
||||
|
||||
const apiKeys = await getUserApiKeys(userId, db);
|
||||
|
||||
try {
|
||||
write(`data: ${JSON.stringify({ type: "chat_id", chatId })}\n\n`);
|
||||
|
||||
const { fullText, events } = await runLLMStream({
|
||||
const { fullText, events, annotations } = await runLLMStream({
|
||||
apiMessages,
|
||||
docStore,
|
||||
docIndex,
|
||||
|
|
@ -571,6 +588,7 @@ chatRouter.post("/", requireAuth, async (req, res) => {
|
|||
workflowStore,
|
||||
model,
|
||||
apiKeys,
|
||||
signal: streamAbort.signal,
|
||||
projectId: resolvedProjectId,
|
||||
});
|
||||
|
||||
|
|
@ -579,11 +597,11 @@ chatRouter.post("/", requireAuth, async (req, res) => {
|
|||
eventCount: events?.length ?? 0,
|
||||
});
|
||||
|
||||
const annotations = extractAnnotations(fullText, docIndex, events);
|
||||
const persistedEvents = stripTransientAssistantEvents(events);
|
||||
await db.from("chat_messages").insert({
|
||||
chat_id: chatId,
|
||||
role: "assistant",
|
||||
content: events.length ? events : null,
|
||||
content: persistedEvents.length ? persistedEvents : null,
|
||||
annotations: annotations.length ? annotations : null,
|
||||
});
|
||||
|
||||
|
|
@ -594,16 +612,45 @@ chatRouter.post("/", requireAuth, async (req, res) => {
|
|||
.eq("id", chatId);
|
||||
}
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) {
|
||||
devLog("[chat/stream] client aborted stream", { chatId });
|
||||
return;
|
||||
}
|
||||
console.error("[chat/stream] error:", err);
|
||||
const message =
|
||||
err instanceof Error && err.message ? err.message : "Stream error";
|
||||
const errorEvents = err instanceof AssistantStreamError
|
||||
? stripTransientAssistantEvents(err.events)
|
||||
: [{ type: "error" as const, message }];
|
||||
const errorFullText =
|
||||
err instanceof AssistantStreamError ? err.fullText : "";
|
||||
try {
|
||||
const annotations = extractAnnotations(
|
||||
errorFullText,
|
||||
docIndex,
|
||||
errorEvents,
|
||||
);
|
||||
const { error: saveError } = await db.from("chat_messages").insert({
|
||||
chat_id: chatId,
|
||||
role: "assistant",
|
||||
content: errorEvents.length ? errorEvents : null,
|
||||
annotations: annotations.length ? annotations : null,
|
||||
});
|
||||
if (saveError)
|
||||
console.error("[chat/stream] failed to save error", saveError);
|
||||
} catch (saveErr) {
|
||||
console.error("[chat/stream] failed to save error", saveErr);
|
||||
}
|
||||
try {
|
||||
write(
|
||||
`data: ${JSON.stringify({ type: "error", message: "Stream error" })}\n\n`,
|
||||
`data: ${JSON.stringify({ type: "error", message })}\n\n`,
|
||||
);
|
||||
write("data: [DONE]\n\n");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
} finally {
|
||||
streamFinished = true;
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=="],
|
||||
|
|
|
|||
17
frontend/package-lock.json
generated
17
frontend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
222
frontend/src/app/(pages)/account/api-keys/page.tsx
Normal file
222
frontend/src/app/(pages)/account/api-keys/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ export default function AssistantChatPage() {
|
|||
|
||||
return (
|
||||
<ChatView
|
||||
chatId={id}
|
||||
messages={messages}
|
||||
isResponseLoading={isResponseLoading}
|
||||
handleChat={handleChat}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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"
|
||||
? {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
623
frontend/src/app/components/assistant/CaseLawPanel.tsx
Normal file
623
frontend/src/app/components/assistant/CaseLawPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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()}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
769
frontend/src/app/components/projects/DocumentSidePanel.tsx
Normal file
769
frontend/src/app/components/projects/DocumentSidePanel.tsx
Normal 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()
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
104
frontend/src/app/components/shared/ConfirmPopup.tsx
Normal file
104
frontend/src/app/components/shared/ConfirmPopup.tsx
Normal 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`;
|
||||
}
|
||||
|
|
@ -7,14 +7,19 @@ import { applyOptimisticResolution } from "../assistant/EditCard";
|
|||
import { DocView } from "./DocView";
|
||||
import { DocxView } from "./DocxView";
|
||||
import {
|
||||
displayCitationQuote,
|
||||
RelevantQuotes,
|
||||
type RelevantQuoteItem,
|
||||
} from "./RelevantQuotes";
|
||||
import {
|
||||
expandCitationToEntries,
|
||||
formatCitationPage,
|
||||
getDocumentCitationQuotes,
|
||||
} from "./types";
|
||||
import type {
|
||||
CitationQuote,
|
||||
MikeCitationAnnotation,
|
||||
MikeEditAnnotation,
|
||||
CitationAnnotation,
|
||||
DocumentCitationAnnotation,
|
||||
EditAnnotation,
|
||||
} from "./types";
|
||||
|
||||
function isDocxFilename(name: string): boolean {
|
||||
|
|
@ -24,16 +29,16 @@ function isDocxFilename(name: string): boolean {
|
|||
|
||||
/**
|
||||
* Discriminated-union describing what the panel is showing above the viewer.
|
||||
* - "document": no header card, no label — just the viewer.
|
||||
* - "citation": "Citation Quote" card with the quoted text and page ref.
|
||||
* - "edit": "Tracked Change" card with the diff + Accept/Reject.
|
||||
* - "document": title row + viewer.
|
||||
* - "citation": title row + relevant quote + viewer.
|
||||
* - "edit": title row + tracked change + viewer.
|
||||
*/
|
||||
export type DocPanelMode =
|
||||
| { kind: "document" }
|
||||
| { kind: "citation"; citation: MikeCitationAnnotation }
|
||||
| { kind: "citation"; citation: CitationAnnotation }
|
||||
| {
|
||||
kind: "edit";
|
||||
edit: MikeEditAnnotation;
|
||||
edit: EditAnnotation;
|
||||
/**
|
||||
* True while an accept/reject request for this exact edit is in
|
||||
* flight. Scoped per-edit (not per-document) so sibling edits on
|
||||
|
|
@ -98,11 +103,42 @@ export function DocPanel({
|
|||
// re-fetch every time they toggle. Tracked-change rendering still
|
||||
// only lives in DocxView, which is fine because edits are DOCX-only.
|
||||
const useDocxView = isDocxFilename(filename);
|
||||
const citationQuoteId =
|
||||
mode.kind === "citation" ? `document:${mode.citation.ref}:0` : null;
|
||||
const [activeCitationQuoteId, setActiveCitationQuoteId] = useState<
|
||||
string | null
|
||||
>(citationQuoteId);
|
||||
const [quoteFocusKey, setQuoteFocusKey] = useState(0);
|
||||
|
||||
const quotes: CitationQuote[] | undefined = useMemo(() => {
|
||||
if (mode.kind !== "citation") return undefined;
|
||||
return expandCitationToEntries(mode.citation);
|
||||
}, [mode]);
|
||||
if (!activeCitationQuoteId) return [];
|
||||
const selectedIndex = Number(activeCitationQuoteId.split(":").at(-1));
|
||||
if (!Number.isFinite(selectedIndex)) return [];
|
||||
const selectedQuote =
|
||||
getDocumentCitationQuotes(mode.citation)[selectedIndex];
|
||||
if (!selectedQuote) return [];
|
||||
const documentCitation = mode.citation as DocumentCitationAnnotation;
|
||||
return expandCitationToEntries({
|
||||
...documentCitation,
|
||||
page: selectedQuote.page,
|
||||
quote: selectedQuote.quote,
|
||||
quotes: [selectedQuote],
|
||||
});
|
||||
}, [activeCitationQuoteId, citationQuoteId, mode]);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveCitationQuoteId(citationQuoteId);
|
||||
}, [citationQuoteId]);
|
||||
|
||||
const handleCitationQuoteSelect = useCallback(
|
||||
(quoteId: string) => {
|
||||
const shouldSelect = activeCitationQuoteId !== quoteId;
|
||||
setActiveCitationQuoteId(shouldSelect ? quoteId : null);
|
||||
if (shouldSelect) setQuoteFocusKey((current) => current + 1);
|
||||
},
|
||||
[activeCitationQuoteId],
|
||||
);
|
||||
|
||||
const highlightEdit = useMemo(() => {
|
||||
if (mode.kind !== "edit") return null;
|
||||
|
|
@ -116,64 +152,50 @@ export function DocPanel({
|
|||
}, [mode]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col px-3 pb-3">
|
||||
{mode.kind === "citation" ? (
|
||||
<CitationHeader
|
||||
<div className="flex h-full flex-col">
|
||||
<DocumentTitleRow
|
||||
documentId={documentId}
|
||||
filename={filename}
|
||||
versionId={versionId}
|
||||
versionNumber={versionNumber}
|
||||
isReloading={isReloading}
|
||||
/>
|
||||
|
||||
{mode.kind === "citation" && (
|
||||
<RelevantQuoteSection
|
||||
citation={mode.citation}
|
||||
documentId={documentId}
|
||||
versionId={versionId}
|
||||
filename={filename}
|
||||
isReloading={isReloading}
|
||||
activeQuoteId={activeCitationQuoteId}
|
||||
onQuoteSelect={handleCitationQuoteSelect}
|
||||
/>
|
||||
) : mode.kind === "edit" ? (
|
||||
<TrackedChangeHeader
|
||||
mode={mode}
|
||||
documentId={documentId}
|
||||
versionId={versionId}
|
||||
filename={filename}
|
||||
isReloading={isReloading}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-end gap-2 py-2">
|
||||
<div className="mr-auto flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-sm text-gray-700">
|
||||
{filename}
|
||||
</span>
|
||||
{versionNumber && versionNumber > 0 && (
|
||||
<span className="shrink-0 inline-flex items-center rounded-md border border-gray-200 bg-white px-1.5 py-0.5 text-[10px] font-medium text-gray-600">
|
||||
V{versionNumber}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<DownloadButton
|
||||
documentId={documentId}
|
||||
versionId={versionId}
|
||||
filename={filename}
|
||||
isReloading={isReloading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{useDocxView ? (
|
||||
<DocxView
|
||||
documentId={documentId}
|
||||
versionId={versionId ?? undefined}
|
||||
quotes={quotes}
|
||||
highlightEdit={highlightEdit}
|
||||
warning={warning ?? null}
|
||||
onWarningDismiss={onWarningDismiss}
|
||||
initialScrollTop={initialScrollTop ?? null}
|
||||
onScrollChange={onScrollChange}
|
||||
/>
|
||||
) : (
|
||||
<DocView
|
||||
doc={{
|
||||
document_id: documentId,
|
||||
version_id: versionId,
|
||||
}}
|
||||
quotes={quotes}
|
||||
/>
|
||||
)}
|
||||
{mode.kind === "edit" && <TrackedChangeHeader mode={mode} />}
|
||||
|
||||
<div className="flex flex-1 min-h-0 flex-col px-3 py-3">
|
||||
{useDocxView ? (
|
||||
<DocxView
|
||||
documentId={documentId}
|
||||
versionId={versionId ?? undefined}
|
||||
quotes={quotes}
|
||||
quoteFocusKey={quoteFocusKey}
|
||||
highlightEdit={highlightEdit}
|
||||
warning={warning ?? null}
|
||||
onWarningDismiss={onWarningDismiss}
|
||||
initialScrollTop={initialScrollTop ?? null}
|
||||
onScrollChange={onScrollChange}
|
||||
/>
|
||||
) : (
|
||||
<DocView
|
||||
doc={{
|
||||
document_id: documentId,
|
||||
version_id: versionId,
|
||||
}}
|
||||
quotes={quotes}
|
||||
quoteFocusKey={quoteFocusKey}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -182,68 +204,106 @@ export function DocPanel({
|
|||
// Header variants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return <p className="text-xs font-medium text-gray-700">{children}</p>;
|
||||
}
|
||||
|
||||
function CitationHeader({
|
||||
citation,
|
||||
function DocumentTitleRow({
|
||||
documentId,
|
||||
versionId,
|
||||
filename,
|
||||
versionId,
|
||||
versionNumber,
|
||||
isReloading,
|
||||
}: {
|
||||
citation: MikeCitationAnnotation;
|
||||
documentId: string;
|
||||
versionId: string | null;
|
||||
filename: string;
|
||||
versionId: string | null;
|
||||
versionNumber: number | null;
|
||||
isReloading: boolean;
|
||||
}) {
|
||||
const displayQuote = displayCitationQuote(citation);
|
||||
const pagesLabel = formatCitationPage(citation);
|
||||
return (
|
||||
<div className="pt-2 pb-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<SectionLabel>Citation</SectionLabel>
|
||||
<div className="ml-auto shrink-0">
|
||||
<DownloadButton
|
||||
documentId={documentId}
|
||||
versionId={versionId}
|
||||
filename={filename}
|
||||
isReloading={isReloading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full rounded-md bg-gray-50 border border-gray-200 px-2 py-2">
|
||||
<p className="text-sm font-serif text-gray-600">
|
||||
“{displayQuote}”
|
||||
{pagesLabel && (
|
||||
<span className="ml-1 text-gray-400">
|
||||
({pagesLabel})
|
||||
<div className="flex items-start gap-3 px-3 pt-4 pb-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h2
|
||||
className="min-w-0 break-words font-serif text-xl text-gray-900"
|
||||
title={filename}
|
||||
>
|
||||
{filename}
|
||||
</h2>
|
||||
{versionNumber && versionNumber > 0 && (
|
||||
<span className="shrink-0 inline-flex items-center rounded-md border border-gray-200 bg-white px-1.5 py-0.5 text-[10px] font-medium text-gray-600">
|
||||
V{versionNumber}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<DownloadButton
|
||||
documentId={documentId}
|
||||
versionId={versionId}
|
||||
filename={filename}
|
||||
isReloading={isReloading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return <p className="text-xs font-medium text-gray-700">{children}</p>;
|
||||
}
|
||||
|
||||
function RelevantQuoteSection({
|
||||
citation,
|
||||
filename,
|
||||
activeQuoteId,
|
||||
onQuoteSelect,
|
||||
}: {
|
||||
citation: CitationAnnotation;
|
||||
filename: string;
|
||||
activeQuoteId: string | null;
|
||||
onQuoteSelect: (quoteId: string) => void;
|
||||
}) {
|
||||
const citationQuotes = getDocumentCitationQuotes(citation);
|
||||
const pagesLabel = formatCitationPage(citation);
|
||||
const citationText = [filename, pagesLabel].filter(Boolean).join(", ");
|
||||
const relevantQuotes: RelevantQuoteItem[] = citationQuotes.map(
|
||||
(quote, index) => {
|
||||
const pageLabel = `Page ${quote.page}`;
|
||||
return {
|
||||
id: `document:${citation.ref}:${index}`,
|
||||
quote: quote.quote.replaceAll("[[PAGE_BREAK]]", "..."),
|
||||
inlineDetail: pageLabel,
|
||||
citationText: [filename, pageLabel].filter(Boolean).join(", "),
|
||||
};
|
||||
},
|
||||
);
|
||||
const currentIndex = Math.max(
|
||||
0,
|
||||
relevantQuotes.findIndex((quote) => quote.id === activeQuoteId),
|
||||
);
|
||||
|
||||
return (
|
||||
<RelevantQuotes
|
||||
quotes={relevantQuotes}
|
||||
activeQuoteId={activeQuoteId}
|
||||
currentIndex={currentIndex}
|
||||
citationRef={citation.ref}
|
||||
citationText={citationText}
|
||||
onSelect={(quote) => onQuoteSelect(quote.id)}
|
||||
onIndexChange={(index) => {
|
||||
const quote = relevantQuotes[index];
|
||||
if (quote) onQuoteSelect(quote.id);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TrackedChangeHeader({
|
||||
mode,
|
||||
documentId,
|
||||
versionId,
|
||||
filename,
|
||||
isReloading,
|
||||
}: {
|
||||
mode: Extract<DocPanelMode, { kind: "edit" }>;
|
||||
documentId: string;
|
||||
versionId: string | null;
|
||||
filename: string;
|
||||
isReloading: boolean;
|
||||
}) {
|
||||
const { edit, isEditReloading, onResolveStart, onResolved, onError } = mode;
|
||||
return (
|
||||
<div className="pt-2 pb-3">
|
||||
<div className="px-3 pb-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<SectionLabel>Tracked Change</SectionLabel>
|
||||
<div className="ml-auto flex items-center gap-2 shrink-0">
|
||||
|
|
@ -254,12 +314,6 @@ function TrackedChangeHeader({
|
|||
onResolved={onResolved}
|
||||
onError={onError}
|
||||
/>
|
||||
<DownloadButton
|
||||
documentId={documentId}
|
||||
versionId={versionId}
|
||||
filename={filename}
|
||||
isReloading={isReloading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{edit.reason && (
|
||||
|
|
@ -294,7 +348,7 @@ function EditResolveButtons({
|
|||
onResolved,
|
||||
onError,
|
||||
}: {
|
||||
edit: MikeEditAnnotation;
|
||||
edit: EditAnnotation;
|
||||
/**
|
||||
* True while an accept/reject for any edit on this document is in
|
||||
* flight (triggered from here, the inline EditCard, the bulk bar, or
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
199
frontend/src/app/components/shared/Modal.tsx
Normal file
199
frontend/src/app/components/shared/Modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
442
frontend/src/app/components/shared/PageHeader.tsx
Normal file
442
frontend/src/app/components/shared/PageHeader.tsx
Normal 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>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
297
frontend/src/app/components/shared/RelevantQuotes.tsx
Normal file
297
frontend/src/app/components/shared/RelevantQuotes.tsx
Normal 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"
|
||||
}`}
|
||||
>
|
||||
“{quote.quote.replace(/"/g, "'")}”
|
||||
{quote.inlineDetail && (
|
||||
<span
|
||||
className={`text-sm ${
|
||||
isActive ? "text-blue-900" : "text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{" "}
|
||||
({quote.inlineDetail})
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{quote.detail && (
|
||||
<p
|
||||
className={`font-serif text-xs ${
|
||||
isActive ? "text-blue-900" : "text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{quote.detail}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
108
frontend/src/app/components/shared/WarningPopup.tsx
Normal file
108
frontend/src/app/components/shared/WarningPopup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -85,8 +85,6 @@ export function TREditColumnMenu({
|
|||
setSaving(false);
|
||||
}
|
||||
}
|
||||
console.log(tags);
|
||||
|
||||
async function handleDelete() {
|
||||
setDeleting(true);
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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={[
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
{(() => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 ?? "",
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue