feat: workflow, UI and document support updates

This commit is contained in:
willchen96 2026-07-08 18:27:28 +08:00
parent a5fe6d6e04
commit 82dcaefc43
139 changed files with 12554 additions and 2233 deletions

View file

@ -22,10 +22,9 @@ Thanks for helping improve Mike. Please keep contributions small, focused, and e
## System Workflows
System workflows live in `workflows/`. Update `metadata.json` for structured
metadata like author, language, version, practice area, or jurisdictions,
update `SKILL.md` for workflow instructions, and use `table-config.json` for
tabular review columns.
System workflows live in `mike-workflows/system/`. Put structured metadata in
the YAML frontmatter at the top of `SKILL.md`, put workflow instructions in the
body of `SKILL.md`, and use `table-config.yaml` for tabular review columns.
After changing system workflows, regenerate the app files:

View file

@ -25,6 +25,7 @@
"multer": "^1.4.5-lts.2",
"pdfjs-dist": "^4.10.38",
"resend": "^4.5.1",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"zod": "^3.25.76",
},
"devDependencies": {
@ -769,6 +770,8 @@
"ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
"xlsx": ["xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", { "bin": { "xlsx": "./bin/xlsx.njs" } }],
"xml": ["xml@1.0.1", "", {}, "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw=="],
"xml-js": ["xml-js@1.6.11", "", { "dependencies": { "sax": "^1.2.4" }, "bin": "bin/cli.js" }, "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g=="],

View file

@ -15,7 +15,6 @@ create table if not exists public.workflow_open_source_submissions (
submitted_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
reviewed_at timestamptz,
reviewed_by_user_id text,
review_notes text,
constraint workflow_open_source_submissions_status_check
check (status in ('pending', 'approved', 'rejected')),

View file

@ -0,0 +1,41 @@
-- Mirror auth.users.email into user_profiles so backend sharing checks can
-- resolve one email without scanning Supabase Auth users.
alter table public.user_profiles
add column if not exists email text;
update public.user_profiles up
set email = lower(au.email)
from auth.users au
where up.user_id = au.id
and au.email is not null
and (
up.email is null
or up.email <> lower(au.email)
);
create unique index if not exists user_profiles_email_lower_unique
on public.user_profiles (lower(email))
where email is not null and btrim(email) <> '';
create index if not exists idx_user_profiles_email
on public.user_profiles(email);
create or replace function public.handle_new_user()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
begin
insert into public.user_profiles (user_id, email)
values (new.id, lower(new.email))
on conflict (user_id) do update
set email = excluded.email,
updated_at = now();
return new;
exception when others then
-- Never block signup if the profile insert fails.
return new;
end;
$$;

View file

@ -0,0 +1,84 @@
-- Add optional practice metadata to projects and expose it in the overview RPC.
alter table public.projects
add column if not exists practice text;
drop function if exists public.get_projects_overview(text, text);
create or replace function public.get_projects_overview(
p_user_id text,
p_user_email text default null
)
returns table (
id uuid,
user_id text,
name text,
cm_number text,
practice text,
shared_with jsonb,
created_at timestamptz,
updated_at timestamptz,
is_owner boolean,
owner_display_name text,
owner_email text,
document_count integer,
chat_count integer,
review_count integer
)
language sql
stable
as $$
with visible_projects as (
select p.*
from public.projects p
where p.user_id = p_user_id
or (
coalesce(p_user_email, '') <> ''
and p.user_id <> p_user_id
and p.shared_with @> jsonb_build_array(p_user_email)
)
),
document_counts as (
select d.project_id, count(*)::integer as document_count
from public.documents d
where d.project_id in (select vp.id from visible_projects vp)
group by d.project_id
),
chat_counts as (
select c.project_id, count(*)::integer as chat_count
from public.chats c
where c.project_id in (select vp.id from visible_projects vp)
group by c.project_id
),
review_counts as (
select tr.project_id, count(*)::integer as review_count
from public.tabular_reviews tr
where tr.project_id in (select vp.id from visible_projects vp)
group by tr.project_id
)
select
vp.id,
vp.user_id,
vp.name,
vp.cm_number,
vp.practice,
vp.shared_with,
vp.created_at,
vp.updated_at,
vp.user_id = p_user_id as is_owner,
nullif(trim(up.display_name), '') as owner_display_name,
null::text as owner_email,
coalesce(dc.document_count, 0) as document_count,
coalesce(cc.chat_count, 0) as chat_count,
coalesce(rc.review_count, 0) as review_count
from visible_projects vp
left join public.user_profiles up
on up.user_id::text = vp.user_id
left join document_counts dc
on dc.project_id = vp.id
left join chat_counts cc
on cc.project_id = vp.id
left join review_counts rc
on rc.project_id = vp.id
order by vp.created_at desc;
$$;

View file

@ -0,0 +1,19 @@
do $$
begin
if exists (
select 1
from information_schema.columns
where table_schema = 'public'
and table_name = 'chat_messages'
and column_name = 'annotations'
) and not exists (
select 1
from information_schema.columns
where table_schema = 'public'
and table_name = 'chat_messages'
and column_name = 'citations'
) then
alter table public.chat_messages
rename column annotations to citations;
end if;
end $$;

View file

@ -4,7 +4,6 @@
"private": true,
"scripts": {
"dev": "tsx watch src/index.ts",
"prebuild": "node ../scripts/build-workflows.js",
"build": "tsc",
"start": "node dist/index.js"
},

View file

@ -539,7 +539,7 @@ export async function buildWorkflowStore(
// Seed system workflows first.
for (const wf of SYSTEM_ASSISTANT_WORKFLOWS) {
store.set(wf.id, { title: wf.title, prompt_md: wf.prompt_md });
store.set(wf.id, { title: wf.title, skill_md: wf.skill_md });
}
// Then overlay user-owned assistant workflows.
@ -550,7 +550,7 @@ export async function buildWorkflowStore(
.eq("type", "assistant");
for (const wf of workflows ?? []) {
if (wf.prompt_md) {
store.set(wf.id, { title: wf.title, prompt_md: wf.prompt_md });
store.set(wf.id, { title: wf.title, skill_md: wf.prompt_md });
}
}
@ -573,7 +573,7 @@ export async function buildWorkflowStore(
if (wf.prompt_md) {
store.set(wf.id, {
title: wf.title,
prompt_md: wf.prompt_md,
skill_md: wf.prompt_md,
});
}
}

View file

@ -32,6 +32,7 @@ Citation rules:
- Keep quotes short, ideally 25 words or fewer, and tightly matched to the claim.
- "page" means the sequential [Page N] marker in the provided text, not printed page numbers inside the document. Non-spreadsheet unpaginated files may have no [Page N] markers; omit "page" (or use 1) when none is present.
- For spreadsheet sources (content shown as "## Sheet: <name>" markdown tables with a "Row" column and column-letter headers), cite by cell instead of page: set "sheet" to the sheet name and "cell" to the A1 address or range you are quoting (e.g. "B7" or "B7:C9", combining the column-letter header with the "Row" number). Put the plain cell value in "quote" with no "Row"/column-letter labels or "|" separators. Omit "page" for spreadsheet citations.
- A cell tagged "⟨merged A1:C1⟩" spans that whole range: its value belongs to the anchor cell and the other covered cells are shown blank. When citing anything in a merged range, set "cell" to the full range from the tag (e.g. "A1:C1"), not a covered cell like "B1". Do not include the "⟨merged ...⟩" tag text in "quote".
- For a continuous quote crossing two pages, set "page" to "N-M" and include [[PAGE_BREAK]] at the page break. Otherwise, use separate quote objects.
- For legacy compatibility, you may also include top-level "page" and "quote" matching the first quote.
- Omit the <CITATIONS> block when there are no citations.

View file

@ -777,7 +777,7 @@ export async function runToolCalls(
toolResults.push({
role: "tool",
tool_call_id: tc.id,
content: wf ? wf.prompt_md : `Workflow '${wfId}' not found.`,
content: wf ? wf.skill_md : `Workflow '${wfId}' not found.`,
});
} else if (tc.function.name === "read_table_cells" && tabularStore) {
const colIndices = args.col_indices as number[] | undefined;

View file

@ -23,7 +23,7 @@ export type DocStore = Map<
{ storage_path: string; file_type: string; filename: string }
>;
export type WorkflowStore = Map<string, { title: string; prompt_md: string }>;
export type WorkflowStore = Map<string, { title: string; skill_md: string }>;
export type DocIndex = Record<
string,

View file

@ -33,6 +33,16 @@ function renderSheet(sheetName: string, ws: XLSX.WorkSheet): string | null {
if (!ref) return null;
const range = XLSX.utils.decode_range(ref);
// Map each merged range's top-left (anchor) address to its encoded range so we
// can tag the anchor inline (e.g. `Amount ⟨merged B2:C2⟩`). The covered cells
// stay blank, so the model never reads a covered address (e.g. B1 inside
// A1:C1) as its own value; the tag tells it the anchor spans that range, and
// to cite the whole range for anything in it.
const mergeAnchors = new Map<string, string>();
for (const m of ws["!merges"] ?? []) {
mergeAnchors.set(XLSX.utils.encode_cell(m.s), XLSX.utils.encode_range(m));
}
// Build a trimmed grid: capture formatted text for every cell in the used
// range, then drop trailing empty columns and fully empty rows so we don't
// emit oceans of blank cells.
@ -44,7 +54,13 @@ function renderSheet(sheetName: string, ws: XLSX.WorkSheet): string | null {
let rowHasContent = false;
for (let c = range.s.c; c <= range.e.c; c++) {
const addr = XLSX.utils.encode_cell({ r, c });
const text = sanitizeCellText(cellDisplayText(ws[addr]));
let text = sanitizeCellText(cellDisplayText(ws[addr]));
const mergeRange = mergeAnchors.get(addr);
if (mergeRange) {
text = text
? `${text} ⟨merged ${mergeRange}`
: `⟨merged ${mergeRange}`;
}
cells[c - range.s.c] = text;
if (text) {
rowHasContent = true;
@ -72,13 +88,6 @@ function renderSheet(sheetName: string, ws: XLSX.WorkSheet): string | null {
const lines = [`## Sheet: ${sheetName}`, "", headerRow, separator, ...bodyRows];
// Note merged ranges once so the model understands spanned headers/labels.
const merges = ws["!merges"];
if (merges && merges.length > 0) {
const encoded = merges.map((m) => XLSX.utils.encode_range(m));
lines.push("", `Merged ranges: ${encoded.join(", ")}`);
}
return lines.join("\n");
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,113 @@
import type { SupabaseClient } from "@supabase/supabase-js";
type Db = SupabaseClient<any, "public", any>;
export type ProfileUserInfo = {
id: string;
email: string;
display_name: string | null;
};
export function normalizeEmail(value: unknown) {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
export function normalizeDisplayName(value: unknown) {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
export async function loadProfileUsersByEmail(db: Db) {
const { data, error } = await db
.from("user_profiles")
.select("user_id, email, display_name")
.not("email", "is", null);
if (error) throw error;
const userByEmail = new Map<string, ProfileUserInfo>();
const userById = new Map<string, ProfileUserInfo>();
for (const row of data ?? []) {
const email = normalizeEmail(row.email);
if (!email) continue;
const info = {
id: row.user_id as string,
email,
display_name: normalizeDisplayName(row.display_name),
};
userByEmail.set(email, info);
userById.set(info.id, info);
}
return { userByEmail, userById };
}
export async function findProfileUserByEmail(db: Db, email: string) {
const normalized = normalizeEmail(email);
if (!normalized) return null;
const { data, error } = await db
.from("user_profiles")
.select("user_id, email, display_name")
.eq("email", normalized)
.maybeSingle();
if (error) throw error;
if (!data) return null;
return {
id: data.user_id as string,
email: normalized,
display_name: normalizeDisplayName(data.display_name),
};
}
export async function findMissingUserEmails(db: Db, emails: string[]) {
const normalizedEmails = [...new Set(emails.map(normalizeEmail).filter(Boolean))];
if (normalizedEmails.length === 0) return [];
const { data, error } = await db
.from("user_profiles")
.select("email")
.in("email", normalizedEmails);
if (error) throw error;
const found = new Set(
(data ?? [])
.map((row) => normalizeEmail(row.email))
.filter(Boolean),
);
return normalizedEmails.filter((email) => !found.has(email));
}
export async function syncProfileEmail(
db: Db,
userId: string,
email: string | null | undefined,
) {
const normalizedEmail = normalizeEmail(email);
if (!userId || !normalizedEmail) return null;
const { data: existing, error: loadError } = await db
.from("user_profiles")
.select("email")
.eq("user_id", userId)
.maybeSingle();
if (loadError) return loadError;
if (!existing) {
const { error } = await db.from("user_profiles").insert({
user_id: userId,
email: normalizedEmail,
});
return error;
}
if (normalizeEmail(existing.email) === normalizedEmail) return null;
const { error } = await db
.from("user_profiles")
.update({
email: normalizedEmail,
updated_at: new Date().toISOString(),
})
.eq("user_id", userId);
return error;
}

View file

@ -32,12 +32,25 @@ type WorkflowRecord = {
[key: string]: unknown;
};
type WorkflowType = "assistant" | "tabular";
type WorkflowContributor = {
name: string;
organisation: string | null;
role: string | null;
linkedin: string | null;
};
type WorkflowMetadata = {
title: string;
description: string | null;
type: WorkflowType;
contributors: WorkflowContributor[];
language: string;
version: string | null;
practice: string | null;
jurisdictions: string[] | null;
};
type OpenSourceSubmissionStatus = "pending" | "approved" | "rejected";
type OpenSourceSubmissionRow = {
@ -52,7 +65,6 @@ type OpenSourceSubmissionRow = {
submitted_at: string;
updated_at: string;
reviewed_at?: string | null;
reviewed_by_user_id?: string | null;
review_notes?: string | null;
};
@ -120,9 +132,42 @@ function withSystemWorkflowAccess(workflow: SystemWorkflow) {
});
}
function withDatabaseWorkflow<T extends object>(workflow: T) {
function workflowTypeFrom(value: unknown): WorkflowType {
return value === "tabular" ? "tabular" : "assistant";
}
function metadataFromWorkflowRecord(workflow: WorkflowRecord): WorkflowMetadata {
return {
...workflow,
title: workflow.title ?? "",
description: null,
type: workflowTypeFrom(workflow.type),
contributors:
normalizeContributors(workflow.contributors) ?? [
DEFAULT_WORKFLOW_CONTRIBUTOR,
],
language: workflow.language ?? DEFAULT_WORKFLOW_LANGUAGE,
version: workflow.version ?? null,
practice: workflow.practice ?? DEFAULT_WORKFLOW_PRACTICE,
jurisdictions: workflow.jurisdictions ?? DEFAULT_WORKFLOW_JURISDICTIONS,
};
}
function withDatabaseWorkflow(workflow: WorkflowRecord) {
const {
title: _title,
type: _type,
contributors: _contributors,
language: _language,
version: _version,
practice: _practice,
jurisdictions: _jurisdictions,
prompt_md,
...rest
} = workflow;
return {
...rest,
metadata: metadataFromWorkflowRecord(workflow),
skill_md: prompt_md ?? null,
is_system: false,
};
}
@ -179,7 +224,7 @@ async function resolveWorkflowAccess(
.eq("id", workflowId)
.single();
if (!workflow) return null;
const workflowRecord = withDatabaseWorkflow(workflow as WorkflowRecord);
const workflowRecord = workflow as WorkflowRecord;
if (workflowRecord.user_id === userId) {
return { workflow: workflowRecord, allowEdit: true, isOwner: true };
}
@ -234,16 +279,13 @@ function buildOpenSourceSnapshot(
) {
return {
workflow_id: workflow.id,
title: workflow.title ?? "",
type: workflow.type ?? "",
prompt_md: workflow.prompt_md ?? null,
metadata: {
...metadataFromWorkflowRecord(workflow),
contributors,
},
skill_md: workflow.prompt_md ?? null,
columns_config: workflow.columns_config ?? null,
contributors,
contributor_mode: contributorMode,
language: workflow.language ?? DEFAULT_WORKFLOW_LANGUAGE,
version: workflow.version ?? null,
practice: workflow.practice ?? DEFAULT_WORKFLOW_PRACTICE,
jurisdictions: workflow.jurisdictions ?? DEFAULT_WORKFLOW_JURISDICTIONS,
created_at: workflow.created_at ?? null,
};
}
@ -280,11 +322,11 @@ workflowsRouter.get("/", requireAuth, asyncRoute(async (req, res) => {
}
const systemWorkflows = SYSTEM_WORKFLOWS.filter(
(workflow) => !workflowType || workflow.type === workflowType,
(workflow) => !workflowType || workflow.metadata.type === workflowType,
).map(withSystemWorkflowAccess);
const databaseWorkflows = ((data ?? []) as WorkflowRecord[]).filter(
(workflow) => !SYSTEM_WORKFLOW_IDS.has(workflow.id),
);
).map(withDatabaseWorkflow);
res.json([...systemWorkflows, ...databaseWorkflows]);
}));
@ -293,40 +335,36 @@ workflowsRouter.get("/", requireAuth, asyncRoute(async (req, res) => {
workflowsRouter.post("/", requireAuth, asyncRoute(async (req, res) => {
const userId = res.locals.userId as string;
const {
title,
type,
prompt_md,
metadata,
skill_md,
columns_config,
language,
practice,
jurisdictions,
} = req.body as {
title: string;
type: string;
prompt_md?: string;
metadata?: Partial<WorkflowMetadata>;
skill_md?: string;
columns_config?: unknown;
language?: unknown;
practice?: string | null;
jurisdictions?: unknown;
};
const title = metadata?.title;
const type = metadata?.type;
if (!title?.trim())
return void res.status(400).json({ detail: "title is required" });
if (!["assistant", "tabular"].includes(type))
return void res.status(400).json({ detail: "metadata.title is required" });
if (type !== "assistant" && type !== "tabular")
return void res
.status(400)
.json({ detail: "type must be 'assistant' or 'tabular'" });
.json({ detail: "metadata.type must be 'assistant' or 'tabular'" });
const db = createServerSupabase();
devLog("[workflows/create] request", {
userId,
title: title.trim(),
type,
hasPrompt: typeof prompt_md === "string" && prompt_md.length > 0,
hasSkill: typeof skill_md === "string" && skill_md.length > 0,
columnCount: Array.isArray(columns_config) ? columns_config.length : null,
language: normalizeOptionalString(language) ?? DEFAULT_WORKFLOW_LANGUAGE,
practice: practice ?? null,
language:
normalizeOptionalString(metadata?.language) ?? DEFAULT_WORKFLOW_LANGUAGE,
practice: metadata?.practice ?? null,
jurisdictions:
normalizeJurisdictions(jurisdictions) ?? DEFAULT_WORKFLOW_JURISDICTIONS,
normalizeJurisdictions(metadata?.jurisdictions) ??
DEFAULT_WORKFLOW_JURISDICTIONS,
});
const { data, error } = await db
.from("workflows")
@ -334,13 +372,15 @@ workflowsRouter.post("/", requireAuth, asyncRoute(async (req, res) => {
user_id: userId,
title: title.trim(),
type,
prompt_md: prompt_md ?? null,
prompt_md: skill_md ?? null,
columns_config: columns_config ?? null,
language: normalizeOptionalString(language) ?? DEFAULT_WORKFLOW_LANGUAGE,
language:
normalizeOptionalString(metadata?.language) ?? DEFAULT_WORKFLOW_LANGUAGE,
practice:
normalizeOptionalString(practice) ?? DEFAULT_WORKFLOW_PRACTICE,
normalizeOptionalString(metadata?.practice) ?? DEFAULT_WORKFLOW_PRACTICE,
jurisdictions:
normalizeJurisdictions(jurisdictions) ?? DEFAULT_WORKFLOW_JURISDICTIONS,
normalizeJurisdictions(metadata?.jurisdictions) ??
DEFAULT_WORKFLOW_JURISDICTIONS,
})
.select("*")
.single();
@ -362,7 +402,7 @@ workflowsRouter.post("/", requireAuth, asyncRoute(async (req, res) => {
title: data?.title,
type: data?.type,
});
res.status(201).json(withDatabaseWorkflow(data));
res.status(201).json(withDatabaseWorkflow(data as WorkflowRecord));
}));
async function handleWorkflowUpdate(req: Request, res: Response) {
@ -370,15 +410,17 @@ async function handleWorkflowUpdate(req: Request, res: Response) {
const userEmail = res.locals.userEmail as string | undefined;
const { workflowId } = req.params;
const updates: Record<string, unknown> = {};
if (req.body.title != null) updates.title = req.body.title;
if (req.body.prompt_md != null) updates.prompt_md = req.body.prompt_md;
const metadata = req.body.metadata as Partial<WorkflowMetadata> | undefined;
if (metadata?.title != null) updates.title = metadata.title;
if (req.body.skill_md != null) updates.prompt_md = req.body.skill_md;
if (req.body.columns_config != null)
updates.columns_config = req.body.columns_config;
if ("language" in req.body)
updates.language = normalizeOptionalString(req.body.language);
if ("practice" in req.body) updates.practice = req.body.practice ?? null;
if ("jurisdictions" in req.body)
updates.jurisdictions = normalizeJurisdictions(req.body.jurisdictions);
if (metadata && "language" in metadata)
updates.language = normalizeOptionalString(metadata.language);
if (metadata && "practice" in metadata)
updates.practice = metadata.practice ?? null;
if (metadata && "jurisdictions" in metadata)
updates.jurisdictions = normalizeJurisdictions(metadata.jurisdictions);
const db = createServerSupabase();
const access = await resolveWorkflowAccess(workflowId, userId, userEmail, db);
@ -398,7 +440,7 @@ async function handleWorkflowUpdate(req: Request, res: Response) {
.status(404)
.json({ detail: "Workflow not found or not editable" });
res.json(
withWorkflowAccess(withDatabaseWorkflow(data), {
withWorkflowAccess(withDatabaseWorkflow(data as WorkflowRecord), {
allowEdit: access.allowEdit,
isOwner: access.isOwner,
}),
@ -506,7 +548,7 @@ workflowsRouter.post("/:workflowId/open-source", requireAuth, asyncRoute(async (
.json({ detail: "Workflow not found or not open-sourceable" });
}
const workflowRecord = withDatabaseWorkflow(workflow as WorkflowRecord);
const workflowRecord = workflow as WorkflowRecord;
const validationError = validateOpenSourceWorkflow(workflowRecord);
if (validationError) {
return void res.status(400).json({ detail: validationError });
@ -620,7 +662,7 @@ workflowsRouter.get("/:workflowId", requireAuth, asyncRoute(async (req, res) =>
: null;
res.json(
withOpenSourceSubmission(
withWorkflowAccess(access.workflow, {
withWorkflowAccess(withDatabaseWorkflow(access.workflow), {
allowEdit: access.allowEdit,
isOwner: access.isOwner,
}),

View file

@ -7,6 +7,8 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.1025.0",
"@aws-sdk/s3-request-presigner": "^3.1025.0",
"@fortune-sheet/core": "^1.0.4",
"@fortune-sheet/react": "^1.0.4",
"@opennextjs/cloudflare": "^1.19.9",
"@openrouter/sdk": "^0.3.11",
"@radix-ui/react-dropdown-menu": "^2.1.16",
@ -15,6 +17,8 @@
"@supabase/auth-helpers-nextjs": "^0.10.0",
"@supabase/auth-js": "^2.101.1",
"@supabase/supabase-js": "^2.81.1",
"@tiptap/core": "^3.22.3",
"@tiptap/extension-table": "^3.22.3",
"@tiptap/pm": "^3.22.3",
"@tiptap/react": "^3.22.3",
"@tiptap/starter-kit": "^3.22.3",
@ -28,6 +32,7 @@
"exceljs": "^4.4.0",
"katex": "^0.16.27",
"lucide-react": "^0.553.0",
"luckyexcel": "^1.0.1",
"mammoth": "^1.11.0",
"marked": "^17.0.1",
"next": "^16.2.6",
@ -329,6 +334,14 @@
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
"@formulajs/formulajs": ["@formulajs/formulajs@2.9.3", "", { "dependencies": { "bessel": "^1.0.2", "jstat": "^1.9.2" }, "bin": { "implementation-stats": "bin/implementation-stats" } }, "sha512-WpgiuJaBl/Hcda9Ti8a7mlnw/vUZkJrtjABvojz6P6mo6d8EscudyA7iAt/kTLsgi0c8zfmzQd/Yjb4ASFkw+g=="],
"@fortune-sheet/core": ["@fortune-sheet/core@1.0.4", "", { "dependencies": { "@fortune-sheet/formula-parser": "^0.2.13", "dayjs": "^1.11.0", "immer": "^9.0.12", "lodash": "^4.17.21", "numeral": "^2.0.6", "uuid": "^8.3.2" } }, "sha512-CDnUVebfvtT++CymNRw0qnY4sz6zYO3KZazlH+nG6otkRkZ05Bvt7NXqVhmKKSSxIvJR4R6h50abu/dVGBpjpw=="],
"@fortune-sheet/formula-parser": ["@fortune-sheet/formula-parser@0.2.13", "", { "dependencies": { "@formulajs/formulajs": "^2.9.3", "tiny-emitter": "^2.1.0" } }, "sha512-za2ZVQ5ZfMSPCtL8MdqhCthPip7AoeU4MPyd5UDo4RCl9/arrv1Jz0y755mACVVi4suSZxrzWoJGDkLmofoSmw=="],
"@fortune-sheet/react": ["@fortune-sheet/react@1.0.4", "", { "dependencies": { "@fortune-sheet/core": "^1.0.4", "@types/regenerator-runtime": "^0.13.6", "immer": "^9.0.12", "lodash": "^4.17.21", "regenerator-runtime": "^0.14.1" }, "peerDependencies": { "react": ">= 18.2", "react-dom": ">= 18.2" } }, "sha512-v+BU5mmp2hhUe4gRF+vOJ3j8zZkzHU0kzhTZzp+Nn00wA/RArMr+CO+SAluKlCPAsTZgPwmgOEK685WPqJ5XNw=="],
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
@ -745,6 +758,8 @@
"@tiptap/extension-strike": ["@tiptap/extension-strike@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4" } }, "sha512-aRHWQj42HiailXSC9LkKYM3jWMcSeGwOjbqM4PiuxQZmHVDRFmeHkfJItOdn2cSHaO0vuEVK+TvrWUWsBFi3pg=="],
"@tiptap/extension-table": ["@tiptap/extension-table@3.27.3", "", { "peerDependencies": { "@tiptap/core": "3.27.3", "@tiptap/pm": "3.27.3" } }, "sha512-P7iQ0SkkDWZwnXzQDOVFLXktXlX67VQ3s2YxoJYKTHlc8rPZ4UCaSogFq0G2APyBlizlY3LbMNrDeZAVFOD8wQ=="],
"@tiptap/extension-text": ["@tiptap/extension-text@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4" } }, "sha512-mM69uUW5cSxIhyEpWXi/YcfyupcJMDLCPEfYi62awH0iOP/LRoCv/nHjJq4Hyj/KxRJbe8HKwIUnqaCUf7m5Pg=="],
"@tiptap/extension-underline": ["@tiptap/extension-underline@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4" } }, "sha512-08kGdbhIrA6h10GWXqOkqIveaBj5tmxclK208/nUIAlonI9hPd739vu7fmVtpnmqCnSSNpoRtU4u6Gj5at0ZpA=="],
@ -819,6 +834,8 @@
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@types/regenerator-runtime": ["@types/regenerator-runtime@0.13.8", "", {}, "sha512-jjKoBekfYDH331060tZhosdJVDnXIXx+T8Iw2h2T4HEds6Ddb2lr0JxD15+XPKlXwRHRNgZoY+4Fb2ykoqzHBg=="],
"@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=="],
@ -973,6 +990,8 @@
"bcp-47-match": ["bcp-47-match@2.0.3", "", {}, "sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ=="],
"bessel": ["bessel@1.0.2", "", {}, "sha512-Al3nHGQGqDYqqinXhQzmwmcRToe/3WyBv4N8aZc5Pef8xw2neZlR9VPi84Sa23JtgWcucu18HxVZrnI0fn2etw=="],
"big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="],
"binary": ["binary@0.3.0", "", { "dependencies": { "buffers": "~0.1.1", "chainsaw": "~0.1.0" } }, "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg=="],
@ -1429,7 +1448,7 @@
"immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="],
"immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="],
"immer": ["immer@9.0.21", "", {}, "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
@ -1541,6 +1560,8 @@
"json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="],
"jstat": ["jstat@1.9.6", "", {}, "sha512-rPBkJbK2TnA8pzs93QcDDPlKcrtZWuuCo2dVR0TFLOJSxhqfWOVCSp8aV3/oSbn+4uY4yw1URtLpHQedtmXfug=="],
"jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="],
"jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="],
@ -1593,6 +1614,8 @@
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
"lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="],
"lodash.difference": ["lodash.difference@4.5.0", "", {}, "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA=="],
@ -1631,6 +1654,8 @@
"lucide-react": ["lucide-react@0.553.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-BRgX5zrWmNy/lkVAe0dXBgd7XQdZ3HTf+Hwe3c9WK6dqgnj9h+hxV+MDncM88xDWlCq27+TKvHGE70ViODNILw=="],
"luckyexcel": ["luckyexcel@1.0.1", "", { "dependencies": { "jszip": "^3.5.0" } }, "sha512-hvbJmCXNp/vST/huA6sieDn32Ib8bd80L9aIu5ZGxniJvZle7VlpHZrl6weLGaEnX99+t7cPAoYGqrqbfZp/AQ=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"mammoth": ["mammoth@1.11.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.6", "argparse": "~1.0.3", "base64-js": "^1.5.1", "bluebird": "~3.4.0", "dingbat-to-unicode": "^1.0.1", "jszip": "^3.7.1", "lop": "^0.4.2", "path-is-absolute": "^1.0.0", "underscore": "^1.13.1", "xmlbuilder": "^10.0.0" }, "bin": { "mammoth": "bin/mammoth" } }, "sha512-BcEqqY/BOwIcI1iR5tqyVlqc3KIaMRa4egSoK83YAVrBf6+yqdAAbtUcFDCWX8Zef8/fgNZ6rl4VUv+vVX8ddQ=="],
@ -1795,6 +1820,8 @@
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
"numeral": ["numeral@2.0.6", "", {}, "sha512-qaKRmtYPZ5qdw4jWJD6bxEf1FJEqllJrwxCLIm0sQU/A7v2/czigzOb+C2uSiFsa9lBUzeH7M1oK+Q+OLxL3kA=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
@ -1951,6 +1978,8 @@
"refractor": ["refractor@5.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/prismjs": "^1.0.0", "hastscript": "^9.0.0", "parse-entities": "^4.0.0" } }, "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw=="],
"regenerator-runtime": ["regenerator-runtime@0.14.1", "", {}, "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="],
"regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
"rehype": ["rehype@13.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "rehype-parse": "^9.0.0", "rehype-stringify": "^10.0.0", "unified": "^11.0.0" } }, "sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A=="],
@ -2131,6 +2160,8 @@
"terser": ["terser@5.16.9", "", { "dependencies": { "@jridgewell/source-map": "^0.3.2", "acorn": "^8.5.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-HPa/FdTB9XGI2H1/keLFZHxl6WNvAI4YalHGtDQTlMnJcoqSab1UwL4l1hGEhs6/GmLHBZIg/YgB++jcbzoOEg=="],
"tiny-emitter": ["tiny-emitter@2.1.0", "", {}, "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
@ -2511,6 +2542,8 @@
"readdir-glob/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="],
"recharts/immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="],
"rehype-attr/unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="],
"rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],

View file

@ -4,7 +4,6 @@
"private": true,
"scripts": {
"dev": "next dev",
"prebuild": "node ../scripts/build-workflows.js",
"build": "next build",
"start": "next start",
"lint": "eslint",

View file

@ -1,4 +1,4 @@
import { cn } from "@/lib/utils";
import { cn } from "@/app/lib/utils";
import { accountGlassSectionClassName } from "./accountStyles";
export function AccountSection({

View file

@ -1,4 +1,4 @@
import { cn } from "@/lib/utils";
import { cn } from "@/app/lib/utils";
import { Loader2 } from "lucide-react";
type AccountToggleSize = "sm" | "md";

View file

@ -1,4 +1,4 @@
import { cn } from "@/lib/utils";
import { cn } from "@/app/lib/utils";
export const accountGlassInputClassName = cn(
"rounded-lg px-3 text-gray-900 placeholder:text-gray-400",

View file

@ -2,8 +2,8 @@
import { useEffect, useState } from "react";
import { Eye, EyeOff } from "lucide-react";
import { Input } from "@/components/ui/input";
import { useUserProfile } from "@/contexts/UserProfileContext";
import { Input } from "@/app/components/ui/input";
import { useUserProfile } from "@/app/contexts/UserProfileContext";
import {
MfaVerificationPopup,
needsMfaVerification,

View file

@ -9,7 +9,7 @@ import {
Plus,
RefreshCw,
} from "lucide-react";
import { Input } from "@/components/ui/input";
import { Input } from "@/app/components/ui/input";
import { Modal } from "@/app/components/modals/Modal";
import { NewMcpModal } from "@/app/components/account/NewMcpModal";
import {

View file

@ -2,7 +2,7 @@
import { useEffect, useRef, useState } from "react";
import { Check } from "lucide-react";
import { useUserProfile } from "@/contexts/UserProfileContext";
import { useUserProfile } from "@/app/contexts/UserProfileContext";
import { AccountSection } from "../AccountSection";
export default function FeaturesPage() {

View file

@ -3,7 +3,7 @@
import { useEffect } from "react";
import { usePathname, useRouter } from "next/navigation";
import { Loader2 } from "lucide-react";
import { useAuth } from "@/contexts/AuthContext";
import { useAuth } from "@/app/contexts/AuthContext";
import { accountTabButtonClassName } from "./accountStyles";
interface TabDef {

View file

@ -9,8 +9,8 @@ import {
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useUserProfile } from "@/contexts/UserProfileContext";
} from "@/app/components/ui/dropdown-menu";
import { useUserProfile } from "@/app/contexts/UserProfileContext";
import type { ApiKeyState } from "@/app/lib/mikeApi";
import {
MODELS,

View file

@ -3,10 +3,10 @@
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { LogOut, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useAuth } from "@/contexts/AuthContext";
import { useUserProfile } from "@/contexts/UserProfileContext";
import { Button } from "@/app/components/ui/button";
import { Input } from "@/app/components/ui/input";
import { useAuth } from "@/app/contexts/AuthContext";
import { useUserProfile } from "@/app/contexts/UserProfileContext";
import { ConfirmPopup } from "@/app/components/popups/ConfirmPopup";
import {
MfaVerificationPopup,

View file

@ -2,7 +2,7 @@
import { useState } from "react";
import { Download, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Button } from "@/app/components/ui/button";
import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext";
import { ConfirmPopup } from "@/app/components/popups/ConfirmPopup";
import {

View file

@ -8,9 +8,9 @@ import {
type KeyboardEvent,
} from "react";
import { Copy, Loader2 } from "lucide-react";
import { supabase } from "@/lib/supabase";
import { Button } from "@/components/ui/button";
import { useUserProfile } from "@/contexts/UserProfileContext";
import { supabase } from "@/app/lib/supabase";
import { Button } from "@/app/components/ui/button";
import { useUserProfile } from "@/app/contexts/UserProfileContext";
import { isMfaRequiredError } from "@/app/lib/mikeApi";
import { Modal } from "@/app/components/modals/Modal";
import {

View file

@ -3,7 +3,7 @@
import { useCallback, useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { PanelLeft } from "lucide-react";
import { useAuth } from "@/contexts/AuthContext";
import { useAuth } from "@/app/contexts/AuthContext";
import { ChatHistoryProvider } from "@/app/contexts/ChatHistoryContext";
import { SidebarContext } from "@/app/contexts/SidebarContext";
import { PageChromeContext } from "@/app/contexts/PageChromeContext";

View file

@ -43,9 +43,9 @@ import { PdfView } from "@/app/components/shared/views/PdfView";
import { SpreadsheetView } from "@/app/components/shared/views/SpreadsheetView";
import { OwnerOnlyPopup } from "@/app/components/popups/OwnerOnlyPopup";
import { DocxView } from "@/app/components/shared/views/DocxView";
import { MikeIcon } from "@/components/chat/mike-icon";
import { useAuth } from "@/contexts/AuthContext";
import { useUserProfile } from "@/contexts/UserProfileContext";
import { MikeIcon } from "@/app/components/chat/mike-icon";
import { useAuth } from "@/app/contexts/AuthContext";
import { useUserProfile } from "@/app/contexts/UserProfileContext";
import { useSidebar } from "@/app/contexts/SidebarContext";
import { PageHeader } from "@/app/components/shared/PageHeader";
import { HeaderActionsMenu } from "@/app/components/shared/HeaderActionsMenu";
@ -1116,7 +1116,6 @@ export default function ProjectAssistantChatPage({ params }: Props) {
)
}
rounded={false}
bordered={false}
/>
) : isSpreadsheetFilename(activeTab.filename) ? (
<SpreadsheetView
@ -1124,7 +1123,6 @@ export default function ProjectAssistantChatPage({ params }: Props) {
documentId={activeTab.documentId}
versionId={activeTab.versionId}
rounded={false}
bordered={false}
/>
) : (
<PdfView
@ -1132,7 +1130,6 @@ export default function ProjectAssistantChatPage({ params }: Props) {
doc={{ document_id: activeTab.documentId }}
quotes={activeQuotes ?? undefined}
rounded={false}
bordered={false}
/>
)
) : (

View file

@ -10,7 +10,7 @@ import {
useProjectWorkspace,
} from "@/app/components/projects/ProjectWorkspace";
import type { Chat } from "@/app/components/shared/types";
import { useAuth } from "@/contexts/AuthContext";
import { useAuth } from "@/app/contexts/AuthContext";
interface Props {
params: Promise<{ id: string }>;

View file

@ -14,7 +14,7 @@ import {
useProjectWorkspace,
} from "@/app/components/projects/ProjectWorkspace";
import type { TabularReview } from "@/app/components/shared/types";
import { useAuth } from "@/contexts/AuthContext";
import { useAuth } from "@/app/contexts/AuthContext";
interface Props {
params: Promise<{ id: string }>;

View file

@ -19,7 +19,7 @@ import { TableToolbar } from "@/app/components/shared/TableToolbar";
import { NewTRModal } from "@/app/components/tabular/NewTRModal";
import { TabularReviewDetailsModal } from "@/app/components/tabular/TabularReviewDetailsModal";
import { OwnerOnlyPopup } from "@/app/components/popups/OwnerOnlyPopup";
import { useAuth } from "@/contexts/AuthContext";
import { useAuth } from "@/app/contexts/AuthContext";
import { PageHeader } from "@/app/components/shared/PageHeader";
import {
GLASS_DROPDOWN,

View file

@ -0,0 +1,333 @@
"use client";
import { Check, ChevronDown, Eye, EyeOff, Loader2 } from "lucide-react";
import { Input } from "@/app/components/ui/input";
import { Modal } from "@/app/components/modals/Modal";
import type { McpConnectorSummary } from "@/app/lib/mikeApi";
import {
accountGlassIconButtonClassName,
accountGlassInputClassName,
} from "@/app/(pages)/account/accountStyles";
export type NewMcpDraft = {
name: string;
serverUrl: string;
bearerToken: string;
customHeaders: string;
};
export type NewMcpStep = "form" | "working" | "auth" | "success";
interface NewMcpModalProps {
open: boolean;
draft: NewMcpDraft;
step: NewMcpStep;
result: McpConnectorSummary | null;
error: string | null;
authMessage: string | null;
showToken: boolean;
showAdvanced: boolean;
onDraftChange: (draft: NewMcpDraft) => void;
onShowTokenChange: (show: boolean) => void;
onShowAdvancedChange: (show: boolean) => void;
onClose: () => void;
onSubmit: () => Promise<void>;
onOpenConnector: (connectorId: string) => void;
}
export function NewMcpModal({
open,
draft,
step,
result,
error,
authMessage,
showToken,
showAdvanced,
onDraftChange,
onShowTokenChange,
onShowAdvancedChange,
onClose,
onSubmit,
onOpenConnector,
}: NewMcpModalProps) {
const canSubmit =
draft.name.trim().length > 0 &&
draft.serverUrl.trim().length > 0 &&
step !== "working" &&
step !== "auth";
return (
<Modal
open={open}
onClose={onClose}
breadcrumbs={[
"Connectors",
step === "success"
? "Connector added"
: step === "auth"
? "Authenticate connector"
: "New MCP connector",
]}
size="lg"
primaryAction={
step === "success" && result
? {
label: "View connector",
onClick: () => onOpenConnector(result.id),
}
: {
label:
step === "working"
? "Connecting..."
: step === "auth"
? "Authorizing..."
: "Connect",
icon:
step === "working" || step === "auth" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : undefined,
onClick: () => void onSubmit(),
disabled: !canSubmit,
}
}
cancelAction={
step === "working" || step === "auth"
? false
: {
label: step === "success" ? "Done" : "Cancel",
onClick: onClose,
}
}
footerStatus={
error ? (
<div className="rounded-xl border border-white/70 bg-white/75 px-3 py-2 text-sm text-red-600 shadow-[0_12px_32px_rgba(15,23,42,0.10),inset_0_1px_0_rgba(255,255,255,0.75)] backdrop-blur-xl">
{error}
</div>
) : null
}
>
{step === "success" && result ? (
<NewMcpSuccess connector={result} />
) : step === "auth" ? (
<NewMcpAuth
message={
authMessage ??
"Complete authorization in the popup to finish connecting this MCP server."
}
/>
) : (
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto pb-4">
<p className="text-sm text-gray-500">
The assistant will have access to this MCP server and
its enabled tools.
</p>
<NewMcpForm
draft={draft}
showToken={showToken}
showAdvanced={showAdvanced}
disabled={step === "working"}
onDraftChange={onDraftChange}
onShowTokenChange={onShowTokenChange}
onShowAdvancedChange={onShowAdvancedChange}
/>
</div>
)}
</Modal>
);
}
function NewMcpForm({
draft,
showToken,
showAdvanced,
disabled,
onDraftChange,
onShowTokenChange,
onShowAdvancedChange,
}: {
draft: NewMcpDraft;
showToken: boolean;
showAdvanced: boolean;
disabled: boolean;
onDraftChange: (draft: NewMcpDraft) => void;
onShowTokenChange: (show: boolean) => void;
onShowAdvancedChange: (show: boolean) => void;
}) {
return (
<div className="grid gap-3 pt-1">
<label className="grid gap-2 sm:grid-cols-[96px_minmax(0,1fr)] sm:items-center">
<span className="text-xs font-medium text-gray-500">
Label
</span>
<Input
value={draft.name}
onChange={(event) =>
onDraftChange({ ...draft, name: event.target.value })
}
placeholder="Connector label"
className={`h-8 text-sm ${accountGlassInputClassName}`}
disabled={disabled}
/>
</label>
<label className="grid gap-2 sm:grid-cols-[96px_minmax(0,1fr)] sm:items-center">
<span className="text-xs font-medium text-gray-500">
URL endpoint
</span>
<Input
value={draft.serverUrl}
onChange={(event) =>
onDraftChange({
...draft,
serverUrl: event.target.value,
})
}
placeholder="https://mcp.example.com/mcp"
className={`h-8 text-sm ${accountGlassInputClassName}`}
disabled={disabled}
/>
</label>
<div className="grid gap-2 sm:grid-cols-[96px_minmax(0,1fr)] sm:items-start">
<span className="pt-2 text-xs font-medium text-gray-500">
Bearer token
</span>
<div className="min-w-0">
<div className="relative">
<Input
value={draft.bearerToken}
onChange={(event) =>
onDraftChange({
...draft,
bearerToken: event.target.value,
})
}
type={showToken ? "text" : "password"}
placeholder="Bearer token"
className={`h-8 pr-10 text-sm ${accountGlassInputClassName}`}
autoComplete="off"
spellCheck={false}
disabled={disabled}
/>
{draft.bearerToken && (
<button
type="button"
className={`absolute inset-y-1 right-1.5 flex items-center ${accountGlassIconButtonClassName}`}
onClick={() => onShowTokenChange(!showToken)}
aria-label={
showToken ? "Hide token" : "Show token"
}
disabled={disabled}
>
{showToken ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
)}
</div>
<p className="mt-1 text-right text-xs text-gray-500">
Tokens are stored encrypted.
</p>
</div>
</div>
<div className="grid gap-2">
<button
type="button"
onClick={() => onShowAdvancedChange(!showAdvanced)}
className="inline-flex items-center gap-1 justify-self-start text-xs font-medium text-gray-500 transition-colors hover:text-gray-900"
disabled={disabled}
>
Advanced
<ChevronDown
className={`h-3.5 w-3.5 transition-transform ${
showAdvanced ? "" : "-rotate-90"
}`}
/>
</button>
{showAdvanced && (
<label className="grid gap-2 sm:grid-cols-[96px_minmax(0,1fr)] sm:items-start">
<span className="text-xs font-medium text-gray-500">
Custom headers
</span>
<div className="min-w-0">
<textarea
value={draft.customHeaders}
onChange={(event) =>
onDraftChange({
...draft,
customHeaders: event.target.value,
})
}
placeholder='{"X-API-Key":"secret"}'
className={`min-h-20 w-full resize-y rounded-lg px-3 py-2 text-sm outline-none ${accountGlassInputClassName}`}
autoComplete="off"
spellCheck={false}
disabled={disabled}
/>
<p className="mt-1 text-right text-xs text-gray-500">
Secrets are stored encrypted.
</p>
</div>
</label>
)}
</div>
</div>
);
}
function NewMcpSuccess({ connector }: { connector: McpConnectorSummary }) {
return (
<div className="flex h-full min-h-0 flex-1 flex-col gap-4 pb-4">
<div className="flex items-start gap-3 rounded-xl border border-green-100/80 bg-green-50/80 px-3 py-3 text-green-800 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-xl">
<Check className="mt-0.5 h-4 w-4 shrink-0 text-green-600" />
<p className="min-w-0 truncate text-sm font-medium">
{connector.name} is connected.{" "}
<span className="font-normal text-green-700">
{connector.tools.length} tools discovered.
</span>
</p>
</div>
<div className="min-h-0 flex-1 overflow-hidden rounded-lg border border-gray-100 bg-white/60">
<div className="max-h-full overflow-y-auto divide-y divide-gray-100">
{connector.tools.map((tool) => (
<div
key={tool.openaiToolName}
className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 px-3 py-2"
>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-gray-800">
{tool.title ?? tool.openaiToolName}
</p>
{tool.description && (
<p className="truncate text-xs text-gray-500">
{tool.description}
</p>
)}
</div>
<span className="text-xs text-gray-400">
{tool.enabled ? "Enabled" : "Disabled"}
</span>
</div>
))}
</div>
</div>
</div>
);
}
function NewMcpAuth({ message }: { message: string }) {
return (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 pb-4 text-center">
<div className="flex h-10 w-10 items-center justify-center rounded-xl border border-white/70 bg-white/75 text-gray-700 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-xl">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
<div className="max-w-sm space-y-1">
<h3 className="text-sm font-medium text-gray-900">
Authentication required
</h3>
<p className="text-sm text-gray-500">{message}</p>
</div>
</div>
);
}

View file

@ -1,116 +1,40 @@
"use client";
import { useRef, useState } from "react";
import { PlusIcon, Upload, LayoutGridIcon, Loader2Icon } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { uploadStandaloneDocument } from "@/app/lib/mikeApi";
import type { Document } from "../shared/types";
import { PlusIcon } from "lucide-react";
interface Props {
onSelectDoc: (doc: Document) => void;
onBrowseAll: () => void;
selectedDocIds?: string[];
hideLabel?: boolean;
}
export function AddDocButton({
onSelectDoc,
onBrowseAll,
selectedDocIds = [],
hideLabel = false,
}: Props) {
const [isOpen, setIsOpen] = useState(false);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (!files.length) return;
setUploading(true);
try {
const uploaded = await Promise.all(
files.map((f) => uploadStandaloneDocument(f)),
);
uploaded.forEach((doc) => onSelectDoc(doc));
} catch (err) {
console.error("Upload failed:", err);
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
};
return (
<>
<input
ref={fileInputRef}
type="file"
accept=".pdf,.docx,.doc,.xlsx,.xlsm,.xls,.pptx,.ppt"
multiple
className="hidden"
onChange={handleUpload}
/>
<DropdownMenu onOpenChange={setIsOpen}>
<DropdownMenuTrigger asChild>
<button
className={`flex items-center gap-1 px-2 h-8 rounded-lg text-sm transition-colors cursor-pointer ${
selectedDocIds.length > 0
? "text-black hover:bg-gray-100"
: "text-gray-400 hover:text-gray-700 hover:bg-gray-100"
} ${isOpen ? "bg-gray-100" : ""}`}
title="Add documents"
aria-label="Add documents"
>
{selectedDocIds.length > 0 ? (
<span className="font-medium tabular-nums">{selectedDocIds.length}</span>
) : (
<PlusIcon
className={`h-4 w-4 shrink-0 transition-transform duration-300 ${isOpen ? "rotate-[135deg]" : ""}`}
/>
)}
<span className={hideLabel ? "hidden" : "hidden sm:inline"}>
{selectedDocIds.length === 1
? "Document"
: "Documents"}
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-44 z-50"
side="bottom"
align="start"
>
<DropdownMenuItem
className="cursor-pointer"
disabled={uploading}
onSelect={(e) => {
e.preventDefault();
fileInputRef.current?.click();
}}
>
{uploading ? (
<Loader2Icon className="h-4 w-4 mr-2 animate-spin text-gray-400" />
) : (
<Upload className="h-4 w-4 mr-2 text-gray-500" />
)}
<span className="text-sm">
{uploading ? "Uploading…" : "Upload files"}
</span>
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
onClick={onBrowseAll}
>
<LayoutGridIcon className="h-4 w-4 mr-2 text-gray-500" />
<span className="text-sm">Browse all</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
<button
type="button"
onClick={onBrowseAll}
className={`flex items-center gap-1 px-2 h-8 rounded-lg text-sm transition-colors cursor-pointer ${
selectedDocIds.length > 0
? "text-black hover:bg-gray-100"
: "text-gray-400 hover:text-gray-700 hover:bg-gray-100"
}`}
title="Add documents"
aria-label="Add documents"
>
{selectedDocIds.length > 0 ? (
<span className="font-medium tabular-nums">
{selectedDocIds.length}
</span>
) : (
<PlusIcon className="h-4 w-4 shrink-0" />
)}
<span className={hideLabel ? "hidden" : "hidden sm:inline"}>
{selectedDocIds.length === 1 ? "Document" : "Documents"}
</span>
</button>
);
}

View file

@ -9,7 +9,7 @@ import {
Upload,
X,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { cn } from "@/app/lib/utils";
import type { AssistantEvent, Document } from "../shared/types";
import { FileTypeIcon } from "../shared/FileTypeIcon";
import {

View file

@ -17,7 +17,7 @@ import {
CaseLawPanel,
type CaseTab,
} from "./CaseLawPanel";
import { cn } from "@/lib/utils";
import { cn } from "@/app/lib/utils";
// ---------------------------------------------------------------------------
// Tab data

View file

@ -13,7 +13,7 @@ import {
Download,
ExternalLink,
} from "lucide-react";
import { MikeIcon } from "@/components/chat/mike-icon";
import { MikeIcon } from "@/app/components/chat/mike-icon";
import type { CaseCitationQuote } from "../shared/types";
import {
clearDocxQuoteHighlights,
@ -27,7 +27,7 @@ import {
getCourtlistenerOpinions,
type CaseLawOpinion,
} from "@/app/lib/mikeApi";
import { cn } from "@/lib/utils";
import { cn } from "@/app/lib/utils";
export type CaseTab = {
kind: "case";

View file

@ -23,14 +23,14 @@ import { AssistantWorkflowModal } from "./AssistantWorkflowModal";
import { ApiKeyMissingPopup } from "../popups/ApiKeyMissingPopup";
import { ModelToggle } from "./ModelToggle";
import { useSelectedModel } from "@/app/hooks/useSelectedModel";
import { useUserProfile } from "@/contexts/UserProfileContext";
import { useUserProfile } from "@/app/contexts/UserProfileContext";
import {
getModelProvider,
isModelAvailable,
type ModelProvider,
} from "@/app/lib/modelAvailability";
import type { Document, Message } from "../shared/types";
import { cn } from "@/lib/utils";
import { cn } from "@/app/lib/utils";
export interface ChatInputHandle {
addDoc: (doc: Document) => void;
@ -96,13 +96,6 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
return () => observer.disconnect();
}, []);
const handleAddDocFromProject = useCallback((doc: Document) => {
setAttachedDocs((prev) => {
if (prev.some((d) => d.id === doc.id)) return prev;
return [...prev, doc];
});
}, []);
const handleAddDocsFromSelector = useCallback(
(selectedDocs: Document[]) => {
setAttachedDocs((prev) => {
@ -244,7 +237,6 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
<div className="flex items-center gap-1">
{!hideAddDocButton && (
<AddDocButton
onSelectDoc={handleAddDocFromProject}
onBrowseAll={() => setDocSelectorOpen(true)}
selectedDocIds={attachedDocs.map(
(d) => d.id,
@ -338,7 +330,10 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
open={workflowModalOpen}
onClose={() => setWorkflowModalOpen(false)}
onSelect={(wf) => {
setSelectedWorkflow({ id: wf.id, title: wf.title });
setSelectedWorkflow({
id: wf.id,
title: wf.metadata.title,
});
setWorkflowModalOpen(false);
}}
projectName={projectName}

View file

@ -0,0 +1,303 @@
"use client";
import { useEffect, useState, type ReactNode } from "react";
import { Minus, RectangleHorizontal, Rows3 } from "lucide-react";
import { CiteButton } from "@/app/components/ui/cite-button";
export type CitationQuoteHeaderItem = {
id: string;
quote: string;
eyebrow?: string | null;
inlineDetail?: string | null;
detail?: string | null;
citationText?: string | null;
};
const QUOTE_GLASS_SURFACE =
"rounded-2xl bg-white/58 shadow-[0_5px_15px_rgba(15,23,42,0.095),inset_0_1px_0_rgba(255,255,255,0.88),inset_0_-8px_16px_rgba(255,255,255,0.16)] backdrop-blur-2xl";
const QUOTE_CARD_SURFACE = "rounded-2xl bg-gray-100";
interface Props {
quotes: CitationQuoteHeaderItem[];
error?: string | null;
isLoading?: boolean;
activeQuoteId?: string | null;
currentIndex?: number;
citationRef?: number;
citationText?: string;
onSelect?: (quote: CitationQuoteHeaderItem, index: number) => void;
onIndexChange?: (index: number) => void;
}
export function CitationQuotesHeader({
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={QUOTE_GLASS_SURFACE}>
<div className="flex h-10 items-center justify-between px-2">
<div className="flex items-center gap-2">
<p className="flex items-center gap-1.5 text-xs font-medium text-gray-700">
<span>Citation</span>
{typeof citationRef === "number" && (
<span className="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-gray-200 px-1 text-[9px] font-medium text-gray-600">
{citationRef}
</span>
)}
</p>
</div>
<div className="flex items-center gap-2">
{hasMultipleQuotes && (
<div className="flex items-center gap-1">
<span className="mr-0.5 text-xs font-medium text-gray-500">
Quotes
</span>
{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>
)}
{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 px-3 py-2.5 ${QUOTE_CARD_SURFACE}`}>
<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={`px-3 py-2.5 ${QUOTE_CARD_SURFACE}`}>
<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: CitationQuoteHeaderItem;
isActive: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={`w-full rounded-xl px-3 py-2.5 text-left transition-colors ${
isActive ? "bg-blue-100/70" : "bg-gray-100 hover:bg-gray-200/70"
}`}
>
<div className="flex flex-col gap-1.5">
{quote.eyebrow && (
<p
className={`font-serif text-xs ${
isActive ? "text-blue-900" : "text-gray-500"
}`}
>
{quote.eyebrow}
</p>
)}
<p
className={`font-serif text-sm leading-6 ${
isActive ? "text-blue-950" : "text-gray-700"
}`}
>
&ldquo;{quote.quote.replace(/"/g, "'")}&rdquo;
{quote.inlineDetail && (
<span
className={`text-sm ${
isActive ? "text-blue-900" : "text-gray-500"
}`}
>
{" "}
({quote.inlineDetail})
</span>
)}
</p>
{quote.detail && (
<p
className={`font-serif text-xs ${
isActive ? "text-blue-900" : "text-gray-500"
}`}
>
{quote.detail}
</p>
)}
</div>
</button>
);
}

View file

@ -0,0 +1,402 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Download, Loader2 } from "lucide-react";
import { supabase } from "@/app/lib/supabase";
import { PdfView } from "../shared/views/PdfView";
import { DocxView } from "../shared/views/DocxView";
import { SpreadsheetView } from "../shared/views/SpreadsheetView";
import {
CitationQuotesHeader,
type CitationQuoteHeaderItem,
} from "./CitationQuotesHeader";
import { TrackedChangeHeader } from "./TrackedChangeHeader";
import {
cleanCitationQuoteText,
expandCitationToEntries,
formatCitationPage,
formatCitationQuotePage,
getDocumentCitationQuotes,
isSpreadsheetFilename,
} from "../shared/types";
import type {
CitationQuote,
Citation,
DocumentCitation,
EditAnnotation,
} from "../shared/types";
function isDocxFilename(name: string): boolean {
const ext = name.split(".").pop()?.toLowerCase();
return ext === "docx" || ext === "doc";
}
/**
* Discriminated-union describing what the panel is showing above the viewer.
* - "document": title row + viewer.
* - "citation": title row + relevant quote + viewer.
* - "edit": title row + tracked change + viewer.
*/
export type DocPanelMode =
| { kind: "document" }
| { kind: "citation"; citation: Citation }
| {
kind: "edit";
edit: EditAnnotation;
changeNumber?: number;
/**
* True while an accept/reject request for this exact edit is in
* flight. Scoped per-edit (not per-document) so sibling edits on
* the same doc stay clickable.
*/
isEditReloading?: boolean;
onResolveStart?: (args: {
editId: string;
documentId: string;
verb: "accept" | "reject";
}) => void;
onResolved?: (args: {
editId: string;
documentId: string;
status: "accepted" | "rejected";
versionId: string | null;
downloadUrl: string | null;
}) => void;
onError?: (args: {
editId: string;
documentId: string;
versionId: string | null;
message: string;
}) => void;
};
interface Props {
documentId: string;
filename: string;
versionId: string | null;
versionNumber: number | null;
mode: DocPanelMode;
/** Spinner on the Download button while an accept/reject is in flight. */
isReloading?: boolean;
warning?: string | null;
onWarningDismiss?: () => void;
initialScrollTop?: number | null;
onScrollChange?: (scrollTop: number) => void;
}
/**
* Unified side-panel body for the assistant. Renders a single document
* with optionally a citation quote OR a tracked change highlighted above
* the viewer. No selector UI caller picks the one thing to show; if the
* user wants a different citation/edit, the panel gets a new tab.
*/
export function DocPanel({
documentId,
filename,
versionId,
versionNumber,
mode,
isReloading = false,
warning,
onWarningDismiss,
initialScrollTop,
onScrollChange,
}: Props) {
// Pick the viewer from the filename only, not from mode. Switching
// headers (citation ↔ edit ↔ document) for the same document must
// not unmount and remount the body — otherwise the user sees a full
// 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 useSheetView = isSpreadsheetFilename(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 [editFocusKey, setEditFocusKey] = useState(0);
const quotes: CitationQuote[] | undefined = useMemo(() => {
if (mode.kind !== "citation") return undefined;
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 DocumentCitation;
return expandCitationToEntries({
...documentCitation,
page: selectedQuote.page,
quote: selectedQuote.quote,
quotes: [selectedQuote],
});
}, [activeCitationQuoteId, citationQuoteId, mode]);
// Cell locator(s) for the selected quote, used to highlight the cited cell
// when the document is a spreadsheet.
const highlightCells = useMemo(() => {
if (mode.kind !== "citation") return undefined;
if (!activeCitationQuoteId) return [];
const selectedIndex = Number(activeCitationQuoteId.split(":").at(-1));
if (!Number.isFinite(selectedIndex)) return [];
const selectedQuote =
getDocumentCitationQuotes(mode.citation)[selectedIndex];
if (!selectedQuote || (!selectedQuote.cell && !selectedQuote.sheet))
return [];
return [{ sheet: selectedQuote.sheet, cell: selectedQuote.cell }];
}, [activeCitationQuoteId, 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;
return {
key: `${mode.edit.edit_id}:${editFocusKey}`,
inserted_text: mode.edit.inserted_text,
deleted_text: mode.edit.deleted_text,
ins_w_id: mode.edit.ins_w_id ?? null,
del_w_id: mode.edit.del_w_id ?? null,
};
}, [editFocusKey, mode]);
return (
<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}
filename={filename}
activeQuoteId={activeCitationQuoteId}
onQuoteSelect={handleCitationQuoteSelect}
/>
)}
{mode.kind === "edit" && (
<TrackedChangeHeader
edit={mode.edit}
changeNumber={mode.changeNumber}
isEditReloading={mode.isEditReloading}
onResolveStart={mode.onResolveStart}
onResolved={mode.onResolved}
onError={mode.onError}
onHighlight={() => setEditFocusKey((current) => current + 1)}
/>
)}
<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}
/>
) : useSheetView ? (
<SpreadsheetView
documentId={documentId}
versionId={versionId}
highlightCells={highlightCells}
/>
) : (
<PdfView
doc={{
document_id: documentId,
version_id: versionId,
}}
quotes={quotes}
quoteFocusKey={quoteFocusKey}
/>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Header variants
// ---------------------------------------------------------------------------
function DocumentTitleRow({
documentId,
filename,
versionId,
versionNumber,
isReloading,
}: {
documentId: string;
filename: string;
versionId: string | null;
versionNumber: number | null;
isReloading: boolean;
}) {
return (
<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>
)}
</div>
</div>
<div className="shrink-0">
<DownloadButton
documentId={documentId}
versionId={versionId}
filename={filename}
isReloading={isReloading}
/>
</div>
</div>
);
}
function RelevantQuoteSection({
citation,
filename,
activeQuoteId,
onQuoteSelect,
}: {
citation: Citation;
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: CitationQuoteHeaderItem[] = citationQuotes.map(
(quote, index) => {
const pageLabel = formatCitationQuotePage(
citation,
quote.page,
quote,
);
return {
id: `document:${citation.ref}:${index}`,
quote: cleanCitationQuoteText(citation, quote.quote),
inlineDetail: pageLabel || null,
citationText: [filename, pageLabel].filter(Boolean).join(", "),
};
},
);
const currentIndex = Math.max(
0,
relevantQuotes.findIndex((quote) => quote.id === activeQuoteId),
);
return (
<CitationQuotesHeader
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);
}}
/>
);
}
// ---------------------------------------------------------------------------
// Download button
// ---------------------------------------------------------------------------
function DownloadButton({
documentId,
versionId,
filename,
isReloading,
}: {
documentId: string;
versionId: string | null;
filename: string;
isReloading?: boolean;
}) {
const [busy, setBusy] = useState(false);
const handleClick = async () => {
if (busy || isReloading) return;
setBusy(true);
try {
const {
data: { session },
} = await supabase.auth.getSession();
const token = session?.access_token;
const apiBase =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3001";
const qs = versionId
? `?version_id=${encodeURIComponent(versionId)}`
: "";
const resp = await fetch(
`${apiBase}/single-documents/${documentId}/docx${qs}`,
{
headers: token ? { Authorization: `Bearer ${token}` } : {},
},
);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const blob = await resp.blob();
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = blobUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
} finally {
setBusy(false);
}
};
const spinning = busy || isReloading;
return (
<button
onClick={handleClick}
disabled={spinning}
className="inline-flex items-center gap-1 rounded-lg border border-gray-200 px-2 py-1.5 text-xs font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-800 disabled:opacity-50 disabled:cursor-not-allowed"
>
{spinning ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Download className="h-3.5 w-3.5" />
)}
Download
</button>
);
}

View file

@ -1,8 +1,8 @@
"use client";
import { useState } from "react";
import { supabase } from "@/lib/supabase";
import { PillButton } from "@/components/ui/pill-button";
import { supabase } from "@/app/lib/supabase";
import { PillButton } from "@/app/components/ui/pill-button";
import type { EditAnnotation } from "../shared/types";
function normalizeText(s: string) {

View file

@ -1,9 +1,9 @@
"use client";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { useAuth } from "@/contexts/AuthContext";
import { useUserProfile } from "@/contexts/UserProfileContext";
import { MikeIcon } from "@/components/chat/mike-icon";
import { useAuth } from "@/app/contexts/AuthContext";
import { useUserProfile } from "@/app/contexts/UserProfileContext";
import { MikeIcon } from "@/app/components/chat/mike-icon";
import { ChatInput } from "./ChatInput";
import { SelectAssistantProjectModal } from "./SelectAssistantProjectModal";
import type { Message } from "../shared/types";

View file

@ -9,7 +9,7 @@ import {
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
} from "@/app/components/ui/dropdown-menu";
import { isModelAvailable } from "@/app/lib/modelAvailability";
import type { ApiKeyState } from "@/app/lib/mikeApi";

View file

@ -0,0 +1,80 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { ChevronDown } from "lucide-react";
export function PreResponseWrapper({
children,
stepCount,
shouldMinimize,
isStreaming,
compact = false,
forceOpen = false,
}: {
children: React.ReactNode;
stepCount: number;
shouldMinimize: boolean;
isStreaming: boolean;
/** Tighter typography + child gap for narrow side panels (e.g. TR chat). */
compact?: boolean;
forceOpen?: boolean;
}) {
const [userToggled, setUserToggled] = useState(false);
const [isOpen, setIsOpen] = useState(!shouldMinimize);
// Once content has streamed in (shouldMinimize=true even once), stay
// minimized even if a later render briefly evaluates shouldMinimize=false.
// Without this latch, the wrapper visibly pops open when isStreaming
// flips off at the end of the response.
const hasMinimizedRef = useRef(shouldMinimize);
useEffect(() => {
if (forceOpen) {
setIsOpen(true);
return;
}
if (shouldMinimize) hasMinimizedRef.current = true;
if (userToggled) return;
setIsOpen(!shouldMinimize && !hasMinimizedRef.current);
}, [forceOpen, shouldMinimize, userToggled]);
const stepWord = `step${stepCount === 1 ? "" : "s"}`;
const label = isStreaming
? "Working"
: `Completed in ${stepCount} ${stepWord}`;
const buttonTextClass = compact ? "text-xs" : "text-sm";
const childrenGapClass = compact ? "gap-2.5" : "gap-4";
return (
<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={() => {
setUserToggled(true);
setIsOpen((v) => !v);
}}
className={`w-full flex items-center justify-between font-serif text-gray-500 hover:text-gray-700 transition-colors ${buttonTextClass}`}
>
<span className="flex items-baseline min-w-0">
<span className="truncate">{label}</span>
{isStreaming && (
<span className="inline-flex ml-1 shrink-0 items-baseline">
<span className="w-0.5 h-0.5 rounded-full bg-gray-400 mr-0.5 animate-[bounce_1.4s_infinite_0s]" />
<span className="w-0.5 h-0.5 rounded-full bg-gray-400 mr-0.5 animate-[bounce_1.4s_infinite_0.2s]" />
<span className="w-0.5 h-0.5 rounded-full bg-gray-400 animate-[bounce_1.4s_infinite_0.4s]" />
</span>
)}
</span>
<ChevronDown
size={12}
className={`relative top-px shrink-0 ml-2 transition-transform duration-200 ${isOpen ? "" : "-rotate-90"}`}
/>
</button>
{isOpen && (
<div className={`mt-3 flex flex-col ${childrenGapClass}`}>
{children}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,229 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { supabase } from "@/app/lib/supabase";
import { PillButton } from "@/app/components/ui/pill-button";
import { applyOptimisticResolution } from "./EditCard";
import type { EditAnnotation } from "../shared/types";
const PANEL_GLASS_SURFACE =
"rounded-2xl bg-white/58 shadow-[0_5px_15px_rgba(15,23,42,0.095),inset_0_1px_0_rgba(255,255,255,0.88),inset_0_-8px_16px_rgba(255,255,255,0.16)] backdrop-blur-2xl";
const PANEL_CARD_SURFACE = "rounded-lg bg-gray-100";
type ResolveArgs = {
editId: string;
documentId: string;
verb: "accept" | "reject";
};
type ResolvedArgs = {
editId: string;
documentId: string;
status: "accepted" | "rejected";
versionId: string | null;
downloadUrl: string | null;
};
type ErrorArgs = {
editId: string;
documentId: string;
versionId: string | null;
message: string;
};
export function TrackedChangeHeader({
edit,
changeNumber,
isEditReloading,
onResolveStart,
onResolved,
onError,
onHighlight,
}: {
edit: EditAnnotation;
changeNumber?: number;
isEditReloading?: boolean;
onResolveStart?: (args: ResolveArgs) => void;
onResolved?: (args: ResolvedArgs) => void;
onError?: (args: ErrorArgs) => void;
onHighlight?: () => void;
}) {
return (
<div className="px-3 pb-3">
<div className={`${PANEL_GLASS_SURFACE} px-2 py-2`}>
<div className="mb-1 flex items-center gap-2">
<p className="text-xs font-medium text-gray-700">
Tracked Change
{changeNumber !== undefined ? ` ${changeNumber}` : ""}
</p>
<div className="ml-auto flex shrink-0 items-center gap-2">
<EditResolveButtons
edit={edit}
isReloading={isEditReloading}
onResolveStart={onResolveStart}
onResolved={onResolved}
onError={onError}
/>
</div>
</div>
{edit.reason && (
<p className="mb-3 text-xs text-gray-500">{edit.reason}</p>
)}
<div
className={`w-full px-3 py-2.5 text-left transition-colors ${PANEL_CARD_SURFACE} ${
onHighlight ? "cursor-pointer hover:bg-gray-200/70" : ""
}`}
role={onHighlight ? "button" : undefined}
tabIndex={onHighlight ? 0 : undefined}
onClick={onHighlight}
onKeyDown={(event) => {
if (!onHighlight) return;
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
onHighlight();
}}
>
<div className="font-serif text-sm leading-relaxed">
{edit.inserted_text && (
<span className="text-green-700">
{edit.inserted_text}
</span>
)}
{edit.deleted_text && (
<span className="text-red-600 line-through">
{edit.deleted_text}
</span>
)}
</div>
</div>
</div>
</div>
);
}
function EditResolveButtons({
edit,
isReloading,
onResolveStart,
onResolved,
onError,
}: {
edit: EditAnnotation;
isReloading?: boolean;
onResolveStart?: (args: ResolveArgs) => void;
onResolved?: (args: ResolvedArgs) => void;
onError?: (args: ErrorArgs) => void;
}) {
const [busy, setBusy] = useState(false);
const [status, setStatus] = useState<"pending" | "accepted" | "rejected">(
edit.status,
);
useEffect(() => {
if (busy) return;
setStatus(edit.status);
}, [edit.status, edit.edit_id, busy]);
const resolved = status !== "pending";
const handle = useCallback(
async (verb: "accept" | "reject") => {
if (busy || resolved) return;
setBusy(true);
onResolveStart?.({
editId: edit.edit_id,
documentId: edit.document_id,
verb,
});
let revert: (() => void) | null = null;
try {
revert = applyOptimisticResolution(edit, verb);
} catch (e) {
console.error(
"[TrackedChangeHeader] optimistic update threw",
e,
);
}
try {
const {
data: { session },
} = await supabase.auth.getSession();
const token = session?.access_token;
const apiBase =
process.env.NEXT_PUBLIC_API_BASE_URL ??
"http://localhost:3001";
const resp = await fetch(
`${apiBase}/single-documents/${edit.document_id}/edits/${edit.edit_id}/${verb}`,
{
method: "POST",
headers: token
? { Authorization: `Bearer ${token}` }
: undefined,
},
);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = (await resp.json()) as {
ok: boolean;
status?: "accepted" | "rejected";
version_id: string | null;
download_url: string | null;
};
const nextStatus =
data.status ??
(verb === "accept" ? "accepted" : "rejected");
setStatus(nextStatus);
onResolved?.({
editId: edit.edit_id,
documentId: edit.document_id,
status: nextStatus,
versionId: data.version_id,
downloadUrl: data.download_url,
});
} catch (e) {
console.error("[TrackedChangeHeader] resolve failed", e);
try {
revert?.();
} catch (revertErr) {
console.error(
"[TrackedChangeHeader] revert threw",
revertErr,
);
}
onError?.({
editId: edit.edit_id,
documentId: edit.document_id,
versionId: edit.version_id ?? null,
message:
verb === "accept"
? "Couldn't save accept — please retry."
: "Couldn't save reject — please retry.",
});
} finally {
setBusy(false);
}
},
[busy, resolved, edit, onResolveStart, onResolved, onError],
);
const inFlight = busy || !!isReloading;
return (
<div className="flex items-center gap-2">
<PillButton
tone="black"
size="sm"
onClick={() => handle("accept")}
disabled={inFlight || resolved}
>
{status === "accepted" ? "Accepted" : "Accept"}
</PillButton>
<PillButton
tone="white"
size="sm"
onClick={() => handle("reject")}
disabled={inFlight || resolved}
>
{status === "rejected" ? "Rejected" : "Reject"}
</PillButton>
</div>
);
}

View file

@ -0,0 +1,206 @@
import { Loader2, Scale } from "lucide-react";
import { FileTypeIcon } from "../../shared/FileTypeIcon";
import { displayCitationQuote, formatCitationPage } from "../../shared/types";
import type { Citation } from "../../shared/types";
import { RESPONSE_GLASS_ANNOTATION, RESPONSE_GLASS_SURFACE } from "./messageStyles";
type CitationSourceRow = {
key: string;
label: string;
source: Citation;
entries: { annotation: Citation; index: number }[];
};
function citationSourceKey(annotation: Citation): string {
if (annotation.kind === "case") {
return `case:${annotation.cluster_id}`;
}
return `document:${annotation.document_id}`;
}
function citationSourceLabel(annotation: Citation): string {
if (annotation.kind === "case") {
const caseName = annotation.case_name?.trim();
const citation = annotation.citation?.trim();
if (caseName && citation) return `${caseName}, ${citation}`;
return caseName || citation || `Case ${annotation.cluster_id}`;
}
return annotation.filename;
}
export function citationTooltip(annotation: Citation): string {
const locator = formatCitationPage(annotation);
const quote = displayCitationQuote(annotation);
return locator ? `${locator}: "${quote}"` : `"${quote}"`;
}
function CitationSourceIcon({
annotation,
}: {
annotation: Citation;
}) {
if (annotation.kind === "case") {
return <Scale className="h-3.5 w-3.5 text-slate-600" />;
}
return (
<FileTypeIcon fileType={annotation.filename} className="h-3.5 w-3.5" />
);
}
function buildCitationSourceRows(
citations: Citation[],
): CitationSourceRow[] {
const rows = new Map<string, CitationSourceRow>();
citations.forEach((annotation, index) => {
const key = citationSourceKey(annotation);
const existing = rows.get(key);
if (existing) {
existing.entries.push({ annotation, index });
return;
}
rows.set(key, {
key,
label: citationSourceLabel(annotation),
source: annotation,
entries: [{ annotation, index }],
});
});
return Array.from(rows.values());
}
function escapeHtmlText(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function ensureTerminalPeriod(value: string): string {
return /[.!?]$/.test(value.trim()) ? value.trim() : `${value.trim()}.`;
}
export function buildCitationAppendix(citations: Citation[]) {
if (citations.length === 0) return { html: "", text: "" };
let previousSourceKey: string | null = null;
const entries = citations.map((annotation) => {
const sourceKey = citationSourceKey(annotation);
const label =
sourceKey === previousSourceKey
? "Id."
: citationSourceLabel(annotation);
previousSourceKey = sourceKey;
return {
number: annotation.ref,
label,
quote: displayCitationQuote(annotation).trim(),
};
});
const textLines = [
"",
"Citations",
...entries.map((entry) => {
const quote = entry.quote ? ` "${entry.quote}"` : "";
return `${entry.number} ${ensureTerminalPeriod(entry.label)}${quote}`;
}),
];
const html = [
`<section class="copied-citations">`,
`<h3>Citations</h3>`,
...entries.map((entry) => {
const label = escapeHtmlText(ensureTerminalPeriod(entry.label));
const quote = entry.quote
? ` &quot;${escapeHtmlText(entry.quote)}&quot;`
: "";
return `<p><sup>${entry.number}</sup> ${label}${quote}</p>`;
}),
`</section>`,
].join("");
return { html, text: textLines.join("\n") };
}
export function CitationsBlock({
citations,
onCitationClick,
onOpenSource,
canOpenSource,
showWhenEmpty = false,
isLoading = false,
}: {
citations: Citation[];
onCitationClick?: (citation: Citation) => void;
onOpenSource?: (citation: Citation) => void;
canOpenSource?: (citation: Citation) => boolean;
showWhenEmpty?: boolean;
isLoading?: boolean;
}) {
const rows = buildCitationSourceRows(citations);
if (rows.length === 0 && !showWhenEmpty) return null;
return (
<div className="mt-2 mb-3">
<div className={`overflow-hidden ${RESPONSE_GLASS_SURFACE}`}>
<div className="flex items-center justify-between gap-3 bg-white/25 px-3 py-2">
<h3 className="text-base font-serif text-gray-900">
Citations
</h3>
{isLoading && (
<Loader2 className="h-3.5 w-3.5 animate-spin text-gray-400" />
)}
</div>
<div>
{rows.map((row) => {
const sourceIsClickable =
!!onOpenSource &&
(canOpenSource?.(row.source) ?? true);
return (
<div
key={row.key}
className="flex items-center gap-3 px-3 py-3"
>
<button
type="button"
onClick={() => onOpenSource?.(row.source)}
disabled={!sourceIsClickable}
className="flex min-w-0 flex-1 items-center gap-2 rounded-lg text-left text-sm font-serif text-gray-700 transition-colors enabled:hover:text-gray-950 disabled:cursor-default"
>
<CitationSourceIcon
annotation={row.source}
/>
<span className="truncate">
{row.label}
</span>
</button>
<div className="flex shrink-0 flex-wrap justify-end gap-1">
{row.entries.map(
({ annotation, index }) => (
<button
key={`${row.key}:${index}`}
type="button"
onClick={() =>
onCitationClick?.(
annotation,
)
}
className={
RESPONSE_GLASS_ANNOTATION
}
title={citationTooltip(
annotation,
)}
>
{annotation.ref}
</button>
),
)}
</div>
</div>
);
})}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,289 @@
import { useState, type ReactNode } from "react";
import { ChevronDown, Loader2 } from "lucide-react";
import { PillButton } from "@/app/components/ui/pill-button";
import { supabase } from "@/app/lib/supabase";
import type { EditAnnotation } from "../../shared/types";
import { applyOptimisticResolution } from "../EditCard";
/**
* Card rendered above the per-edit EditCards when a message produced
* multiple tracked-change proposals. Lets the user resolve every pending
* edit in one click by firing the per-edit accept/reject endpoint for each
* pending annotation and forwarding each response to `onResolved` so the
* parent can bump the viewer version, persist override URLs, etc.
*
* This intentionally doesn't apply the optimistic DOM mutation that
* EditCard does bulk operations touch many edits at once and the real
* re-render from the latest version will reconcile within a second or so.
*/
function BulkEditActions({
pending,
onViewClick,
onResolveStart,
onResolved,
onError,
}: {
pending: {
annotation: EditAnnotation;
filename: string;
}[];
onViewClick?: (ann: EditAnnotation, filename: string) => void;
onResolveStart?: (args: {
editId: string;
documentId: string;
verb: "accept" | "reject";
}) => void;
onResolved?: (args: {
editId: string;
documentId: string;
status: "accepted" | "rejected";
versionId: string | null;
downloadUrl: string | null;
}) => void;
onError?: (args: {
editId: string;
documentId: string;
versionId: string | null;
message: string;
}) => void;
}) {
const [busy, setBusy] = useState<"accept" | "reject" | null>(null);
const [progress, setProgress] = useState<{
done: number;
total: number;
} | null>(null);
if (pending.length === 0) return null;
const handleAll = async (verb: "accept" | "reject") => {
if (busy) return;
setBusy(verb);
setProgress({ done: 0, total: pending.length });
try {
const {
data: { session },
} = await supabase.auth.getSession();
const token = session?.access_token;
const apiBase =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3001";
// Sequential so the per-document version counter advances in a
// predictable order and the viewer doesn't race between bumps.
let done = 0;
for (const { annotation } of pending) {
onResolveStart?.({
editId: annotation.edit_id,
documentId: annotation.document_id,
verb,
});
// Optimistically mutate the DOM so the viewer reflects the
// resolution immediately. Revert if the backend call fails.
let revert: (() => void) | null = null;
try {
revert = applyOptimisticResolution(annotation, verb);
} catch (e) {
console.error(
"[BulkEditActions] optimistic update threw",
e,
);
}
try {
const resp = await fetch(
`${apiBase}/single-documents/${annotation.document_id}/edits/${annotation.edit_id}/${verb}`,
{
method: "POST",
headers: token
? { Authorization: `Bearer ${token}` }
: undefined,
},
);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = (await resp.json()) as {
ok: boolean;
status?: "accepted" | "rejected";
version_id: string | null;
download_url: string | null;
};
const nextStatus =
data.status ??
(verb === "accept" ? "accepted" : "rejected");
onResolved?.({
editId: annotation.edit_id,
documentId: annotation.document_id,
status: nextStatus,
versionId: data.version_id,
downloadUrl: data.download_url,
});
} catch (e) {
console.error("[BulkEditActions] resolve failed", e);
try {
revert?.();
} catch (revertErr) {
console.error(
"[BulkEditActions] revert threw",
revertErr,
);
}
onError?.({
editId: annotation.edit_id,
documentId: annotation.document_id,
versionId: annotation.version_id ?? null,
message:
verb === "accept"
? "Couldn't save one or more accepts."
: "Couldn't save one or more rejects.",
});
}
done++;
setProgress({ done, total: pending.length });
}
} finally {
setBusy(null);
setProgress(null);
}
};
// Optional: show a tiny "View first" action so bulk doesn't lose the
// in-viewer scroll-to behaviour entirely.
const first = pending[0];
return (
<div className="flex items-center gap-2">
<PillButton
tone="black"
size="sm"
onClick={() => handleAll("accept")}
disabled={!!busy}
>
{busy === "accept" && (
<Loader2 className="h-3 w-3 animate-spin" />
)}
Accept all
</PillButton>
<PillButton
tone="white"
size="sm"
onClick={() => handleAll("reject")}
disabled={!!busy}
>
{busy === "reject" && (
<Loader2 className="h-3 w-3 animate-spin" />
)}
Reject all
</PillButton>
{progress && (
<span className="text-xs font-serif text-gray-500">
{progress.done}/{progress.total}
</span>
)}
{onViewClick && first && (
<PillButton
tone="blue"
size="sm"
onClick={() =>
onViewClick(first.annotation, first.filename)
}
disabled={!!busy}
className="ml-auto"
>
View
</PillButton>
)}
</div>
);
}
/**
* Wraps the bulk accept/reject card and the per-edit EditCards in a single
* minimisable container. The bulk actions and summary stay visible in the
* header; the individual cards collapse via the chevron toggle.
*/
export function EditCardsSection({
pending,
filenameByDocId,
cards,
resolvedCount,
onViewClick,
onResolveStart,
onResolved,
onError,
}: {
pending: {
annotation: EditAnnotation;
filename: string;
}[];
filenameByDocId: Map<string, string>;
cards: ReactNode[];
resolvedCount: number;
onViewClick?: (ann: EditAnnotation, filename: string) => void;
onResolveStart?: (args: {
editId: string;
documentId: string;
verb: "accept" | "reject";
}) => void;
onResolved?: (args: {
editId: string;
documentId: string;
status: "accepted" | "rejected";
versionId: string | null;
downloadUrl: string | null;
}) => void;
onError?: (args: {
editId: string;
documentId: string;
versionId: string | null;
message: string;
}) => void;
}) {
const [isOpen, setIsOpen] = useState(true);
if (cards.length === 0) return null;
const docCount = filenameByDocId.size;
const summary =
pending.length > 0
? docCount > 1
? `${pending.length} tracked changes across ${docCount} documents`
: `${pending.length} tracked ${pending.length === 1 ? "change" : "changes"}`
: docCount > 1
? `${resolvedCount} resolved tracked changes across ${docCount} documents`
: `${resolvedCount} resolved tracked ${resolvedCount === 1 ? "change" : "changes"}`;
return (
<div className="rounded-xl bg-white 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 overflow-hidden">
{/* Row 1: summary + chevron */}
<div className="flex items-center gap-2 px-3 pt-3">
<p className="flex-1 min-w-0 text-sm font-serif text-gray-700 truncate">
{summary}
</p>
<button
onClick={() => setIsOpen((v) => !v)}
aria-label={isOpen ? "Collapse edits" : "Expand edits"}
className="shrink-0 rounded p-1 text-gray-500 hover:bg-gray-100 hover:text-gray-800 transition-colors"
>
<ChevronDown
className={`h-4 w-4 transition-transform duration-200 ${isOpen ? "" : "-rotate-90"}`}
/>
</button>
</div>
{/* Row 2: bulk action buttons */}
{pending.length > 0 && (
<div className="px-3 pt-3">
<BulkEditActions
pending={pending}
onViewClick={onViewClick}
onResolveStart={onResolveStart}
onResolved={onResolved}
onError={onError}
/>
</div>
)}
{/* Row 3: collapsible cards list */}
{isOpen && (
<div className="flex flex-col gap-2 px-3 pb-3 pt-3">
{cards}
</div>
)}
{!isOpen && <div className="pb-3" />}
</div>
);
}

View file

@ -0,0 +1,701 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { ChevronDown, Download, Loader2 } from "lucide-react";
import { supabase } from "@/app/lib/supabase";
import type { AssistantEvent } from "../../shared/types";
import { FileTypeIcon } from "../../shared/FileTypeIcon";
import { RESPONSE_GLASS_SURFACE, withoutMarkdownNode } from "./messageStyles";
const THINKING_PHRASES = [
"Thinking...",
"Pondering...",
"Analyzing...",
"Reviewing...",
"Reasoning...",
];
const REASONING_COLLAPSED_MAX_LINES = 6;
const REASONING_COLLAPSED_MAX_HEIGHT_REM = 9;
// ---------------------------------------------------------------------------
// Event block primitives
// ---------------------------------------------------------------------------
function EventConnector() {
return (
<div className="absolute w-[1px] bg-gray-300 top-[14px] left-[3px] translate-x-[-50%] h-[calc(100%+10px)]" />
);
}
export function EventBlock({
showConnector,
isStreaming,
dotColor = "green",
children,
}: {
showConnector?: boolean;
isStreaming?: boolean;
dotColor?: "green" | "gray" | "red";
children: ReactNode;
}) {
const dotColorClass =
dotColor === "green"
? "bg-green-400 shadow-[0_1px_3px_rgba(15,23,42,0.15),inset_0_1px_0_rgba(255,255,255,0.5)]"
: dotColor === "red"
? "bg-red-400 shadow-[0_1px_3px_rgba(15,23,42,0.15),inset_0_1px_0_rgba(255,255,255,0.5)]"
: "bg-gray-300 shadow-[0_1px_3px_rgba(15,23,42,0.15),inset_0_1px_0_rgba(255,255,255,0.35)]";
return (
<div className="flex items-start text-sm font-serif text-gray-500 relative">
{showConnector && <EventConnector />}
{isStreaming ? (
<div className="mt-2 w-1.5 h-1.5 shrink-0 rounded-full border border-gray-400 border-t-transparent animate-spin" />
) : (
<div
className={`mt-2 w-1.5 h-1.5 shrink-0 rounded-full ${dotColorClass}`}
/>
)}
<div className="ml-2 min-w-0 flex-1 whitespace-normal break-words">
{children}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
export function ReasoningBlock({
text,
isStreaming,
showConnector,
}: {
text: string;
isStreaming: boolean;
showConnector?: boolean;
}) {
const [isContentOpen, setIsContentOpen] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const [userToggledContent, setUserToggledContent] = useState(false);
const [isOverflowing, setIsOverflowing] = useState(false);
const [hasMeasured, setHasMeasured] = useState(false);
const [thinkingIndex, setThinkingIndex] = useState(0);
const contentRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!isStreaming) return;
const interval = setInterval(() => {
setThinkingIndex((i) => (i + 1) % THINKING_PHRASES.length);
}, 2000);
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 (!userToggledContent) setIsContentOpen(isStreaming);
if (!nextOverflowing) setIsExpanded(false);
}, [isStreaming, text, userToggledContent]);
const showContent = isContentOpen || isStreaming || !hasMeasured;
const isCollapsed = isContentOpen && isOverflowing && !isExpanded;
return (
<EventBlock
showConnector={showConnector}
isStreaming={isStreaming}
dotColor="gray"
>
<button
onClick={() => {
if (isStreaming) return;
setUserToggledContent(true);
setIsContentOpen((v) => !v);
}}
className="flex items-center text-sm font-serif text-gray-500 hover:text-gray-600 transition-colors"
>
<span className="font-medium">
{isStreaming
? THINKING_PHRASES[thinkingIndex]
: "Thought process"}
</span>
{!isStreaming && (
<ChevronDown
size={10}
className={`relative top-px ml-1 transition-transform duration-200 ${isContentOpen ? "" : "-rotate-90"}`}
/>
)}
</button>
{showContent && (
<div className="mt-2">
<div
className={`relative ${isCollapsed ? "overflow-hidden" : ""}`}
style={
isCollapsed
? {
maxHeight: `${REASONING_COLLAPSED_MAX_HEIGHT_REM}rem`,
}
: undefined
}
>
<div
ref={contentRef}
className="text-sm font-serif text-gray-400 prose prose-sm max-w-none [&>*]:text-gray-400 [&>*]:text-sm"
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code: (props) => (
<code
className="font-serif text-gray-600"
{...withoutMarkdownNode(props)}
/>
),
}}
>
{text}
</ReactMarkdown>
</div>
{isCollapsed && (
<>
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-10 bg-gradient-to-b from-white/0 to-white" />
<button
type="button"
onClick={() => setIsExpanded(true)}
className="absolute left-1/2 bottom-2 z-10 -translate-x-1/2 text-gray-400 transition-colors hover:text-gray-600"
aria-label="Expand thought process"
>
<ChevronDown className="h-3.5 w-3.5" />
</button>
</>
)}
</div>
{isOverflowing && isContentOpen && isExpanded && (
<button
type="button"
onClick={() => setIsExpanded(false)}
className="mx-auto mt-2 flex text-gray-400 transition-colors hover:text-gray-600"
aria-label="Minimise thought process"
>
<ChevronDown className="h-3.5 w-3.5 rotate-180" />
</button>
)}
</div>
)}
</EventBlock>
);
}
export function DocReadBlock({
filename,
onClick,
showConnector,
isStreaming,
}: {
filename: string;
onClick?: () => void;
showConnector?: boolean;
isStreaming?: boolean;
}) {
return (
<EventBlock
showConnector={showConnector}
isStreaming={isStreaming}
dotColor="green"
>
<div className="flex min-w-0 items-center gap-1.5">
<span className="shrink-0 font-medium">
{isStreaming ? "Reading" : "Read"}
</span>
{isStreaming ? (
<span className="flex min-w-0 items-center gap-1.5">
<FileTypeIcon
fileType={filename}
className="h-3.5 w-3.5"
/>
<span className="truncate">{filename}...</span>
</span>
) : onClick ? (
<button
onClick={onClick}
className="flex min-w-0 items-center gap-1.5 text-left transition-colors hover:text-gray-700 cursor-pointer"
>
<FileTypeIcon
fileType={filename}
className="h-3.5 w-3.5"
/>
<span className="truncate">{filename}</span>
</button>
) : (
<span className="flex min-w-0 items-center gap-1.5">
<FileTypeIcon
fileType={filename}
className="h-3.5 w-3.5"
/>
<span className="truncate">{filename}</span>
</span>
)}
</div>
</EventBlock>
);
}
export function DocFindBlock({
filename,
query,
totalMatches,
isStreaming,
showConnector,
}: {
filename: string;
query: string;
totalMatches: number;
isStreaming?: boolean;
showConnector?: boolean;
}) {
const matchSuffix = isStreaming
? ""
: ` (${totalMatches} ${totalMatches === 1 ? "match" : "matches"})`;
return (
<EventBlock
showConnector={showConnector}
isStreaming={isStreaming}
dotColor={totalMatches > 0 ? "green" : "gray"}
>
<span className="font-medium">
{isStreaming ? "Finding" : "Found"}
</span>{" "}
<span>
&ldquo;{query}&rdquo;{matchSuffix}
<span className="ml-1 text-gray-400">in {filename}</span>
{isStreaming && "..."}
</span>
</EventBlock>
);
}
export function DocCreatedBlock({
filename,
showConnector,
isStreaming,
}: {
filename: string;
showConnector?: boolean;
isStreaming?: boolean;
}) {
return (
<EventBlock
showConnector={showConnector}
isStreaming={isStreaming}
dotColor="green"
>
<span className="font-medium">
{isStreaming ? "Creating" : "Created"}
</span>{" "}
<span>{isStreaming ? `${filename}...` : filename}</span>
</EventBlock>
);
}
export function DocReplicatedBlock({
filename,
count,
showConnector,
isStreaming,
hasError,
}: {
filename: string;
/**
* How many consecutive replicates of this same source got collapsed
* into this block. 1; only rendered when > 1.
*/
count: number;
showConnector?: boolean;
isStreaming?: boolean;
hasError?: boolean;
}) {
const label = isStreaming ? "Replicating" : "Replicated";
const suffix =
!isStreaming && count > 1
? ` ${count} times`
: isStreaming
? "..."
: "";
return (
<EventBlock
showConnector={showConnector}
isStreaming={isStreaming}
dotColor={hasError ? "red" : "green"}
>
<span className="font-medium">{label}</span>{" "}
<span>
{filename}
{suffix}
</span>
</EventBlock>
);
}
export function DocDownloadBlock({
filename,
download_url,
onOpen,
isReloading = false,
versionNumber,
}: {
filename: string;
download_url: string;
onOpen?: () => void;
isReloading?: boolean;
versionNumber?: number | null;
}) {
const hasVersion =
typeof versionNumber === "number" &&
Number.isFinite(versionNumber) &&
versionNumber > 0;
const extMatch = filename.match(/\.(\w+)$/);
const ext = extMatch ? extMatch[1].toUpperCase() : "FILE";
const rawBasename = extMatch
? filename.slice(0, -extMatch[0].length)
: filename;
// Strip any legacy "[Edited V3]" suffix that may still be baked into
// older saved download filenames — the version is surfaced as a
// separate tag now.
const basename = rawBasename.replace(/\s*\[Edited V\d+\]\s*$/, "").trim();
// Only backend-relative URLs are accepted. The download fetch carries
// the user's bearer token, so any absolute URL from tool output is
// refused to keep the token from leaking off-origin.
const API_BASE =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3001";
const isSafeHref = download_url.startsWith("/");
const href = isSafeHref ? `${API_BASE}${download_url}` : null;
const [busy, setBusy] = useState(false);
const handleDownload = async (e?: {
stopPropagation?: () => void;
preventDefault?: () => void;
}) => {
e?.stopPropagation?.();
e?.preventDefault?.();
if (busy || isReloading || !href) return;
setBusy(true);
try {
const {
data: { session },
} = await supabase.auth.getSession();
const token = session?.access_token;
const resp = await fetch(href, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const blob = await resp.blob();
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = blobUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
} finally {
setBusy(false);
}
};
const spinning = busy || isReloading;
const body = (
<div className="flex items-center gap-3 px-4 py-3 min-w-0 flex-1">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 min-w-0">
<p className="text-base font-serif text-gray-900 text-wrap">
{basename}
</p>
{hasVersion && (
<span className="shrink-0 inline-flex items-center rounded-md border border-white/70 bg-white/55 px-1.5 py-0.5 text-[10px] font-medium text-gray-500 shadow-[inset_0_1px_0_rgba(255,255,255,0.8)] backdrop-blur-xl">
V{versionNumber}
</span>
)}
</div>
<p className="text-xs text-blue-500 mt-0.5">{ext}</p>
</div>
</div>
);
const downloadIcon = spinning ? (
<div
aria-disabled
className="shrink-0 flex items-center bg-white/25 px-6 text-gray-400 cursor-not-allowed"
>
<Loader2 size={13} className="animate-spin" />
</div>
) : (
<button
type="button"
onClick={handleDownload}
className="shrink-0 flex items-center bg-white/25 px-6 text-gray-500 transition-colors hover:bg-white/55 hover:text-gray-700 cursor-pointer"
>
<Download size={13} />
</button>
);
if (onOpen) {
return (
<div
className={`flex items-stretch overflow-hidden w-full font-sans ${RESPONSE_GLASS_SURFACE}`}
>
<button
type="button"
onClick={onOpen}
className="flex items-stretch flex-1 min-w-0 text-left transition-colors hover:bg-white/45 cursor-pointer"
>
{body}
</button>
{downloadIcon}
</div>
);
}
if (spinning) {
return (
<div
className={`flex items-stretch overflow-hidden w-full font-sans ${RESPONSE_GLASS_SURFACE}`}
>
{body}
{downloadIcon}
</div>
);
}
return (
<div
className={`flex items-stretch overflow-hidden w-full font-sans ${RESPONSE_GLASS_SURFACE}`}
>
<button
type="button"
onClick={handleDownload}
className="flex items-stretch flex-1 min-w-0 text-left transition-colors hover:bg-white/45 cursor-pointer"
>
{body}
</button>
{downloadIcon}
</div>
);
}
export function WorkflowAppliedBlock({
title,
showConnector,
onClick,
}: {
title: string;
showConnector?: boolean;
onClick?: () => void;
}) {
return (
<EventBlock showConnector={showConnector} dotColor="green">
<span className="font-medium">Applied Workflow</span>{" "}
{onClick ? (
<button
onClick={onClick}
className="text-left hover:text-gray-700 transition-colors cursor-pointer"
>
{title}
</button>
) : (
<span>{title}</span>
)}
</EventBlock>
);
}
export function AskInputsBlock({
event,
response,
showConnector,
}: {
event: Extract<AssistantEvent, { type: "ask_inputs" }>;
response?: Extract<AssistantEvent, { type: "ask_inputs_response" }>;
showConnector?: boolean;
}) {
const responseById = new Map(
response?.responses.map((item) => [item.id, item]) ?? [],
);
return (
<EventBlock
showConnector={showConnector}
dotColor={response ? "green" : "gray"}
>
<p className="font-medium text-gray-600">
{response ? "Asked for input" : "Asking for input"}
</p>
<div className="mt-2 space-y-2 text-gray-800">
{event.items.map((item, index) => {
const itemResponse = responseById.get(item.id);
const responseText = (() => {
if (!itemResponse) return null;
if (itemResponse.skipped) return "Skipped";
if (itemResponse.kind === "choice") {
return itemResponse.answer ?? "";
}
const filenames = itemResponse.filenames;
return filenames.length
? filenames.join(", ")
: "No documents attached";
})();
return (
<div key={item.id}>
<p className="text-xs text-gray-500">
{index + 1}.{" "}
{item.kind === "choice"
? "Question"
: "Documents"}
</p>
<p className="mt-0.5">
{item.kind === "choice"
? item.question
: item.document_types.join(", ") ||
"Documents requested"}
</p>
{responseText !== null && (
<p className="mt-0.5 text-gray-600">
{responseText}
</p>
)}
</div>
);
})}
</div>
</EventBlock>
);
}
export type CourtListenerBlockItem = {
caseName: string | null;
citation: string | null;
dateFiled?: string | null;
url?: string | null;
query?: string;
totalMatches?: number;
hasError?: boolean;
};
export function CourtListenerBlock({
label,
detail,
isStreaming,
hasError,
showConnector,
items,
}: {
label: string;
detail?: string;
isStreaming?: boolean;
hasError?: boolean;
showConnector?: boolean;
items?: CourtListenerBlockItem[];
}) {
const [isOpen, setIsOpen] = useState(false);
const hasItems = !!items && items.length > 0;
return (
<EventBlock
showConnector={showConnector}
isStreaming={isStreaming}
dotColor={hasError ? "red" : "green"}
>
{hasItems ? (
<button
onClick={() => setIsOpen((v) => !v)}
className="text-left hover:text-gray-700 transition-colors inline-flex items-center"
>
<span className="font-medium">{label}</span>
{detail ? <span>&nbsp;{detail}</span> : null}
{isStreaming ? <span>...</span> : null}
<ChevronDown
size={10}
className={`relative top-px ml-1 transition-transform duration-200 ${isOpen ? "" : "-rotate-90"}`}
/>
</button>
) : (
<>
<span className="font-medium">{label}</span>
{detail ? <span> {detail}</span> : null}
{isStreaming ? <span>...</span> : null}
</>
)}
{isOpen && hasItems && (
<ul className="mt-2 flex flex-col gap-1 text-sm font-serif text-gray-500">
{items!.map((item, idx) => {
const label = [item.caseName, item.citation]
.filter(Boolean)
.join(", ");
const primary = label || item.url || "Unknown case";
const searchText = item.query
? `Searched for "${item.query}" in ${primary}${
typeof item.totalMatches === "number"
? ` (${item.totalMatches} ${
item.totalMatches === 1
? "match"
: "matches"
})`
: ""
}`
: null;
return (
<li key={idx}>
<div
className={
item.hasError ? "text-red-500" : ""
}
>
{item.url ? (
<a
href={item.url}
target="_blank"
rel="noreferrer"
className="hover:text-gray-700 hover:underline underline-offset-2"
>
{searchText ?? primary}
</a>
) : searchText ? (
<span>{searchText}</span>
) : (
<span>{primary}</span>
)}
</div>
</li>
);
})}
</ul>
)}
</EventBlock>
);
}
export function DocEditedBlock({
filename,
showConnector,
isStreaming,
hasError,
}: {
filename: string;
showConnector?: boolean;
isStreaming?: boolean;
hasError?: boolean;
}) {
return (
<EventBlock
showConnector={showConnector}
isStreaming={isStreaming}
dotColor={hasError ? "red" : "green"}
>
<span className="font-medium">
{isStreaming ? "Editing" : hasError ? "Edit failed" : "Edited"}
</span>{" "}
<span>{isStreaming ? `${filename}...` : filename}</span>
</EventBlock>
);
}

View file

@ -0,0 +1,278 @@
import type { RefObject } from "react";
import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
import remarkMath from "remark-math";
import remarkGfm from "remark-gfm";
import rehypeKatex from "rehype-katex";
import "katex/dist/katex.min.css";
import type { AssistantEvent, Citation } from "../../shared/types";
import { RESPONSE_GLASS_ANNOTATION, withoutMarkdownNode } from "./messageStyles";
import { citationTooltip } from "./CitationSources";
import { internalCaseHref } from "./citationUtils";
export function MarkdownContent({
text,
inlineCitationTargets,
caseCitations,
caseOpinions,
onCitationClick,
onCaseClick,
divRef,
}: {
text: string;
inlineCitationTargets: Citation[];
caseCitations: Map<
string,
Extract<AssistantEvent, { type: "case_citation" }>
>;
caseOpinions: Map<
number,
Extract<AssistantEvent, { type: "case_opinions" }>["case"]
>;
onCitationClick?: (c: Citation) => void;
onCaseClick?: (
c: Extract<AssistantEvent, { type: "case_citation" }>,
) => void;
divRef?: RefObject<HTMLDivElement | null>;
}) {
function findCaseCitation(href: string) {
return caseCitations.get(internalCaseHref(href) ?? "");
}
return (
<div
ref={divRef}
className="text-gray-900 mb-4 text-base prose prose-sm max-w-none font-serif"
>
<ReactMarkdown
remarkPlugins={[
[remarkMath, { singleDollarTextMath: false }],
remarkGfm,
]}
rehypePlugins={[rehypeKatex]}
urlTransform={(url) =>
/^us-case-\d+$/.test(url) ? url : defaultUrlTransform(url)
}
components={{
table: (props) => (
<div className="overflow-x-auto my-4 rounded-lg">
<table
className="min-w-full divide-y divide-gray-300 overflow-hidden"
{...withoutMarkdownNode(props)}
/>
</div>
),
thead: (props) => (
<thead
className="bg-gray-100"
{...withoutMarkdownNode(props)}
/>
),
tbody: (props) => (
<tbody
className="divide-y divide-gray-200"
{...withoutMarkdownNode(props)}
/>
),
tr: (props) => <tr {...withoutMarkdownNode(props)} />,
th: (props) => (
<th
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
{...withoutMarkdownNode(props)}
/>
),
td: (props) => (
<td
className="whitespace-normal px-3 py-4 text-sm text-gray-900"
{...withoutMarkdownNode(props)}
/>
),
h1: (props) => (
<h1
className="mt-6 mb-4 text-3xl font-serif font-semibold"
{...withoutMarkdownNode(props)}
/>
),
h2: (props) => (
<h2
className="mt-5 mb-3 text-2xl font-serif font-semibold"
{...withoutMarkdownNode(props)}
/>
),
h3: (props) => (
<h3
className="text-xl font-semibold mt-4 mb-2"
{...withoutMarkdownNode(props)}
/>
),
h4: (props) => (
<h4
className="text-lg font-semibold mt-4 mb-2"
{...withoutMarkdownNode(props)}
/>
),
p: ({ node, ...props }) => {
const parent =
node && typeof node === "object" && "parent" in node
? (node as { parent?: { type?: string } })
.parent
: undefined;
if (parent?.type === "listItem") {
return (
<p
className="inline leading-7 m-0"
{...props}
/>
);
}
return <p className="mb-4 leading-7" {...props} />;
},
ul: (props) => (
<ul
className="list-disc list-outside mb-4 pl-6"
{...withoutMarkdownNode(props)}
/>
),
ol: (props) => (
<ol
className="list-decimal list-outside mb-4 pl-6"
{...withoutMarkdownNode(props)}
/>
),
li: (props) => (
<li
className="mb-2 leading-7"
{...withoutMarkdownNode(props)}
/>
),
strong: (props) => (
<strong
className="font-semibold"
{...withoutMarkdownNode(props)}
/>
),
em: (props) => (
<em className="italic" {...withoutMarkdownNode(props)} />
),
code: (props) => {
const { children, ...codeProps } =
withoutMarkdownNode(props);
const text = String(children);
const citMatch = text.match(/^§(\d+)§$/);
if (citMatch) {
const idx = parseInt(citMatch[1]);
const annotation = inlineCitationTargets[idx];
if (annotation) {
const tooltipText = citationTooltip(annotation);
return (
<button
onClick={() =>
onCitationClick?.(annotation)
}
data-citation-ref={annotation.ref}
className={`${RESPONSE_GLASS_ANNOTATION} mx-0.5 align-super`}
title={tooltipText}
>
{annotation.ref}
</button>
);
}
}
return (
<code
className="bg-gray-100 px-1.5 py-0.5 rounded text-sm font-serif"
{...codeProps}
>
{children}
</code>
);
},
blockquote: (props) => (
<blockquote
className="border-l-4 border-gray-300 pl-4 italic my-4"
{...withoutMarkdownNode(props)}
/>
),
a: (props) => {
const { href, children, ...anchorProps } =
withoutMarkdownNode(props);
if (href) {
const isInternalCaseHref = !!internalCaseHref(href);
const citation = findCaseCitation(href);
if (citation && onCaseClick) {
return (
<button
type="button"
onClick={() =>
onCaseClick({
...citation,
case:
citation.cluster_id !== null
? caseOpinions.get(
citation.cluster_id,
)
: undefined,
})
}
className="text-left text-blue-600 hover:text-blue-700 underline"
>
{children}
</button>
);
}
if (citation) {
return (
<a
href={citation.url}
className="text-blue-600 hover:text-blue-700 underline"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
);
}
if (isInternalCaseHref) {
return (
<span className="text-blue-600 underline">
{children}
</span>
);
}
return (
<a
href={href}
className="text-blue-600 hover:text-blue-700 underline"
target="_blank"
rel="noopener noreferrer"
{...anchorProps}
>
{children}
</a>
);
}
return (
<a
href={href}
className="text-blue-600 hover:text-blue-700 underline"
target="_blank"
rel="noopener noreferrer"
{...anchorProps}
>
{children}
</a>
);
},
hr: (props) => (
<hr
className="my-6 border-gray-200"
{...withoutMarkdownNode(props)}
/>
),
}}
>
{text}
</ReactMarkdown>
</div>
);
}

View file

@ -0,0 +1,53 @@
import { useEffect, useRef, useState } from "react";
import { MikeIcon } from "@/app/components/chat/mike-icon";
export type StatusState = "active" | "error" | null;
export function ResponseStatus({ status }: { status: StatusState }) {
const [showDone, setShowDone] = useState(false);
const [doneVisible, setDoneVisible] = useState(false);
const wasActiveRef = useRef(false);
const isActive = status === "active";
const isError = status === "error";
useEffect(() => {
const wasActive = wasActiveRef.current;
wasActiveRef.current = isActive;
let raf = 0;
let doneTimeout = 0;
if (wasActive && !isActive) {
raf = window.requestAnimationFrame(() => {
setShowDone(true);
setDoneVisible(true);
doneTimeout = window.setTimeout(
() => setDoneVisible(false),
1500,
);
});
} else if (!wasActive && isActive) {
raf = window.requestAnimationFrame(() => {
setShowDone(false);
setDoneVisible(false);
});
}
return () => {
window.cancelAnimationFrame(raf);
if (doneTimeout) window.clearTimeout(doneTimeout);
};
}, [isActive]);
return (
<div className="w-full h-9 flex items-center mb-2">
<MikeIcon
spin={isActive}
done={showDone && doneVisible}
error={isError}
mike={!isError && !(showDone && doneVisible)}
size={22}
/>
</div>
);
}

View file

@ -0,0 +1,36 @@
import type { Citation } from "../../shared/types";
export function preprocessCitations(
text: string,
citations: Citation[],
inlineCitationTargets: Citation[],
): string {
// Replace [N] or [N, M, ...] inline markers with internal §idx§ tokens backed by citations.
return text.replace(/\[(\d+(?:,\s*\d+)*)\]/g, (full, refsStr) => {
const refs = (refsStr as string)
.split(",")
.map((s: string) => parseInt(s.trim(), 10));
const tokens = refs.flatMap((ref: number) => {
const citation = citations.find((a) => a.ref === ref);
if (!citation) return [];
const idx = inlineCitationTargets.length;
inlineCitationTargets.push(citation);
return [`\`§${idx}§\`\u200B`];
});
return tokens.length > 0 ? tokens.join("") : full;
});
}
// ---------------------------------------------------------------------------
// Markdown renderer (shared config)
// ---------------------------------------------------------------------------
export function internalCaseHref(
value: string | number | null | undefined,
): string | null {
if (typeof value === "number") return `us-case-${value}`;
if (!value) return null;
const match = value.match(/^us-case-(\d+)$/);
return match ? `us-case-${match[1]}` : null;
}

View file

@ -0,0 +1,33 @@
import type { AssistantEvent } from "../../shared/types";
export function eventErrorMessage(event: AssistantEvent): string | null {
if (event.type === "error") return event.message;
if ("error" in event && typeof event.error === "string" && event.error) {
return event.error;
}
return null;
}
export function toolCallLabel(name: string): string {
if (name === "ask_inputs") return "Asking for input...";
if (name === "generate_docx") return "Creating document...";
if (name === "generate_excel") return "Creating spreadsheet...";
if (name === "generate_ppt") return "Creating presentation...";
if (name === "edit_document") return "Editing document...";
if (name === "read_document") return "Reading document...";
if (name === "fetch_documents") return "Reading documents...";
if (name === "find_in_document") return "Searching document...";
if (name === "replicate_document") return "Copying document...";
if (name === "read_workflow") return "Loading workflow...";
if (name === "list_workflows") return "Loading workflows...";
if (name === "list_documents") return "Loading documents...";
if (name === "courtlistener_search_case_law")
return "Searching case law...";
if (name === "courtlistener_get_cases") return "Fetching cases...";
if (name === "courtlistener_find_in_case") return "Searching case...";
if (name === "courtlistener_read_case") return "Reading case...";
if (name === "courtlistener_verify_citations")
return "Verifying citations...";
if (name.startsWith("mcp_")) return "Using connector...";
return name ? `Running ${name}...` : "Working...";
}

View file

@ -0,0 +1,12 @@
export const RESPONSE_GLASS_SURFACE =
"rounded-xl border border-white/70 bg-white/55 shadow-[0_3px_9px_rgba(15,23,42,0.03),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-4px_9px_rgba(255,255,255,0.05)] backdrop-blur-2xl";
export const RESPONSE_GLASS_ANNOTATION =
"inline-flex h-4 w-4 items-center justify-center rounded-full border border-gray-200/60 bg-gray-200/80 text-[12px] font-serif font-medium text-gray-800 shadow-[0_1px_2px_rgba(15,23,42,0.04),inset_0_1px_0_rgba(243,244,246,0.85),inset_0_-2px_4px_rgba(229,231,235,0.65)] backdrop-blur-xl transition-colors hover:bg-gray-200 hover:text-gray-950";
export function withoutMarkdownNode<P extends { node?: unknown }>(
props: P,
): Omit<P, "node"> {
const { node, ...rest } = props;
void node;
return rest;
}

View file

@ -0,0 +1,60 @@
import { useEffect, useRef, useState } from "react";
/**
* Hide jitter from arrival of streamed text chunks by revealing characters at
* a smooth, rate-paced clip rather than rendering every chunk verbatim.
*
* Returns a prefix of `text` whose length grows over time toward the full
* length. When `active` is false (stream ended, message replayed from
* history, etc.), snaps to the full text immediately.
*
* Rate adapts to backlog: small backlogs reveal at a 40 cps floor; large
* backlogs catch up within ~0.4s, so the smoothing never lags noticeably
* behind the server.
*/
export function useSmoothedReveal(text: string, active: boolean): string {
const [revealedInt, setRevealedInt] = useState(text.length);
const revealedFloat = useRef<number>(text.length);
useEffect(() => {
if (!active) {
revealedFloat.current = text.length;
return;
}
// Defensive clamp in case the text was edited / replaced shorter.
if (revealedFloat.current > text.length) {
revealedFloat.current = text.length;
}
let lastTick = performance.now();
let raf = 0;
let cancelled = false;
const step = (now: number) => {
if (cancelled) return;
const dt = Math.max(0, (now - lastTick) / 1000);
lastTick = now;
const target = text.length;
const prev = revealedFloat.current;
if (prev < target) {
const backlog = target - prev;
const cps = Math.max(40, backlog / 0.4);
const next = Math.min(target, prev + cps * dt);
revealedFloat.current = next;
const nextInt = Math.floor(next);
setRevealedInt((cur) => (cur === nextInt ? cur : nextInt));
}
raf = requestAnimationFrame(step);
};
raf = requestAnimationFrame(step);
return () => {
cancelled = true;
cancelAnimationFrame(raf);
};
}, [text.length, active]);
return text.slice(0, Math.min(revealedInt, text.length));
}

View file

@ -0,0 +1,221 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { AlertCircle, Upload, Loader2, X } from "lucide-react";
import {
uploadStandaloneDocument,
uploadProjectDocument,
addDocumentToProject,
} from "@/app/lib/mikeApi";
import type { Document } from "../shared/types";
import { FileDirectory } from "../shared/FileDirectory";
import {
useDirectoryData,
invalidateDirectoryCache,
} from "../shared/useDirectoryData";
import { Modal } from "./Modal";
import {
SUPPORTED_DOCUMENT_ACCEPT,
formatUnsupportedDocumentWarning,
partitionSupportedDocumentFiles,
} from "@/app/lib/documentUploadValidation";
export { invalidateDirectoryCache };
interface Props {
open: boolean;
onClose: () => void;
onSelect: (documents: Document[], projectId?: string) => void;
breadcrumb: string[];
allowMultiple?: boolean;
projectId?: string;
}
export function AddDocumentsModal({
open,
onClose,
onSelect,
breadcrumb,
allowMultiple = true,
projectId,
}: Props) {
const { loading, standaloneDocuments, projects } = useDirectoryData(open);
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 [extraUploadedDocs, setExtraUploadedDocs] = useState<Document[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!open) return;
setSelectedIds(new Set());
setExtraUploadedDocs([]);
setUploadingFilenames([]);
setUploadWarning(null);
}, [open]);
if (!open) return null;
const allStandalone = [
...extraUploadedDocs.filter(
(u) => !standaloneDocuments.some((d) => d.id === u.id),
),
...standaloneDocuments,
];
const availableProjects = projects
.filter((p) => p.id !== projectId)
.map((p) => ({
...p,
documents: p.documents || [],
}));
const allDocs = [
...allStandalone,
...availableProjects.flatMap((p) => p.documents || []),
];
async function handleConfirm() {
const selected = allDocs.filter((d) => selectedIds.has(d.id));
if (projectId) {
const toAssign = selected.filter((d) => d.project_id !== projectId);
const alreadyHere = selected.filter(
(d) => d.project_id === projectId,
);
if (toAssign.length > 0) {
setUploading(true);
try {
const assigned = await Promise.all(
toAssign.map((d) =>
addDocumentToProject(projectId, d.id),
),
);
onSelect([...alreadyHere, ...assigned], projectId);
} catch (err) {
console.error("Failed to assign documents:", err);
} finally {
setUploading(false);
}
} else {
onSelect(alreadyHere, projectId);
}
onClose();
return;
}
const projectIds = new Set(
selected.map((d) => d.project_id).filter(Boolean),
);
const singleProjectId =
projectIds.size === 1 ? [...projectIds][0]! : undefined;
onSelect(selected, singleProjectId);
onClose();
}
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(e.target.files || []);
if (!files.length) return;
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(
supported.map((f) =>
projectId
? uploadProjectDocument(projectId, f)
: uploadStandaloneDocument(f),
),
);
invalidateDirectoryCache();
setExtraUploadedDocs((prev) => [...uploaded, ...prev]);
uploaded.forEach((d) =>
setSelectedIds((prev) => new Set([...prev, d.id])),
);
} catch (err) {
console.error("Upload failed:", err);
} finally {
setUploading(false);
setUploadingFilenames([]);
if (fileInputRef.current) fileInputRef.current.value = "";
}
}
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}
/>
{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>
)}
<div className="flex min-h-0 flex-1 flex-col">
<FileDirectory
standaloneDocs={allStandalone}
directoryProjects={availableProjects}
loading={loading}
selectedIds={selectedIds}
onChange={setSelectedIds}
allowMultiple={allowMultiple}
emptyMessage="No documents yet"
uploadingFilenames={uploadingFilenames}
searchable
searchAutoFocus
searchNoResultsMessage="No matches found"
showProjectTabs
/>
</div>
</Modal>
);
}

View file

@ -0,0 +1,250 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Check, Loader2, Upload, X } from "lucide-react";
import { SearchBar } from "@/app/components/ui/search-bar";
import { getProject, uploadProjectDocument } from "@/app/lib/mikeApi";
import type { Document } from "../shared/types";
import { DocFileIcon } from "../shared/FileDirectory";
import { VersionChip } from "../shared/VersionChip";
import { Modal } from "./Modal";
interface Props {
open: boolean;
onClose: () => void;
onSelect: (documents: Document[]) => void;
breadcrumb: string[];
projectId: string;
/** Docs already in the target list — rendered checked + disabled. */
excludeDocIds?: Set<string>;
allowMultiple?: boolean;
}
function formatDate(iso: string | null) {
if (!iso) return null;
return new Date(iso).toLocaleDateString(undefined, {
day: "numeric",
month: "short",
year: "numeric",
});
}
export function AddProjectDocsModal({
open,
onClose,
onSelect,
breadcrumb,
projectId,
excludeDocIds,
allowMultiple = true,
}: Props) {
const [docs, setDocs] = useState<Document[]>([]);
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState("");
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!open) return;
setSearch("");
setSelectedIds(new Set());
let cancelled = false;
setLoading(true);
getProject(projectId)
.then((p) => {
if (!cancelled) setDocs(p.documents ?? []);
})
.catch(() => {
if (!cancelled) setDocs([]);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [open, projectId]);
if (!open) return null;
const q = search.toLowerCase().trim();
const filtered = q
? docs.filter((d) => d.filename.toLowerCase().includes(q))
: docs;
const isExcluded = (id: string) => !!excludeDocIds?.has(id);
function toggle(id: string) {
if (isExcluded(id)) return;
if (!allowMultiple) {
setSelectedIds(new Set([id]));
return;
}
setSelectedIds((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}
function handleConfirm() {
const selected = docs.filter((d) => selectedIds.has(d.id));
onSelect(selected);
onClose();
}
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(e.target.files || []);
if (!files.length) return;
setUploading(true);
try {
const uploaded = await Promise.all(
files.map((f) => uploadProjectDocument(projectId, f)),
);
setDocs((prev) => [...uploaded, ...prev]);
setSelectedIds((prev) => {
const next = new Set(prev);
uploaded.forEach((d) => next.add(d.id));
return next;
});
} catch (err) {
console.error("Upload failed:", err);
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
}
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,.xlsx,.xlsm,.xls,.pptx,.ppt"
multiple
className="hidden"
onChange={handleUpload}
/>
<div className="pt-1 pb-2">
<SearchBar
value={search}
onValueChange={setSearch}
placeholder="Search..."
autoFocus
/>
</div>
{/* File list */}
<div className="min-h-0 flex-1 overflow-y-auto">
{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-100 animate-pulse shrink-0" />
<div
className="h-3 rounded bg-gray-100 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 rounded-md px-2 py-2 text-xs text-left transition-all ${
excluded
? "opacity-50 cursor-not-allowed"
: checked
? "bg-gray-100"
: "hover:bg-gray-100/70"
}`}
>
<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>
)}
</div>
</Modal>
);
}

View file

@ -0,0 +1,203 @@
"use client";
import { createPortal } from "react-dom";
import type { ButtonHTMLAttributes, ReactNode } from "react";
import { X } from "lucide-react";
import { PillButton } from "@/app/components/ui/pill-button";
import { cn } from "@/app/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[];
headerAction?: ReactNode;
size?: ModalSize;
className?: string;
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,
headerAction,
size = "lg",
className,
footerStatus,
primaryAction,
secondaryAction,
cancelAction,
}: ModalProps) {
const hasHeader = breadcrumbs?.length;
const hasFooter =
footerStatus ||
primaryAction ||
secondaryAction ||
cancelAction;
const resolvedCancelAction = cancelAction;
if (!open) return null;
return createPortal(
<div
className={cn(
"fixed inset-0 z-[200] flex items-center justify-center px-4",
"bg-white/10 backdrop-blur-[2px]",
)}
onClick={onClose}
>
<div
className={cn(
"w-full rounded-3xl flex h-[600px] flex-col",
sizeClassName[size],
"border border-white/70 bg-gray-50/95 shadow-[0_14px_40px_rgba(15,23,42,0.101),0_5px_14px_rgba(15,23,42,0.067)] backdrop-blur-3xl",
className,
)}
onClick={(e) => e.stopPropagation()}
>
{hasHeader && (
<div className="flex items-center justify-between gap-3 p-4 pl-5">
<div className="flex min-w-0 flex-1 items-center justify-between gap-3">
<div className="flex min-w-0 flex-wrap items-center gap-1.5 text-xs leading-none text-gray-400">
{breadcrumbs?.map((segment, index) => (
<span
key={index}
className="flex items-center gap-1.5"
>
{index > 0 && <span></span>}
<span
className={cn(
"truncate",
index ===
(breadcrumbs?.length ?? 0) -
1 && "text-gray-700",
)}
>
{segment}
</span>
</span>
))}
</div>
{headerAction}
</div>
<button
onClick={onClose}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-white/70 bg-white/55 text-gray-500 shadow-[inset_0_1px_0_rgba(255,255,255,0.75),inset_0_-1px_0_rgba(255,255,255,0.55),0_6px_18px_rgba(15,23,42,0.08)] backdrop-blur-xl transition-colors hover:bg-white/75 hover:text-gray-700"
aria-label="Close"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)}
{/* Body never scrolls itself (so children's edge shadows are
never clipped by the header/footer). Content that can exceed
the modal height wraps its scrollable region in an inner
`overflow-y-auto` div. */}
<div className="flex min-h-0 flex-1 flex-col px-5">
{children}
</div>
{hasFooter && (
<div
className={cn(
"flex items-center gap-3 p-3",
secondaryAction
? "justify-between"
: "justify-end",
"border-t border-white/60",
)}
>
{secondaryAction && (
<div className="flex min-w-0 items-center gap-2">
<ModalActionButton
action={secondaryAction}
fallbackVariant="secondary"
/>
</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;
if (fallbackVariant === "cancel") {
return (
<button
type="button"
className="px-2 py-1.5 text-sm text-gray-500 transition-colors hover:text-gray-800 disabled:cursor-not-allowed disabled:opacity-40"
{...props}
>
{label}
</button>
);
}
const tone =
variant === "danger"
? "danger"
: fallbackVariant === "secondary" && variant === "secondary"
? "blue"
: variant === "primary"
? "black"
: "white";
return (
<PillButton tone={tone} size="normal" {...props}>
{icon}
{label}
</PillButton>
);
}

View file

@ -0,0 +1,35 @@
"use client";
import type { ComponentPropsWithoutRef, ReactNode } from "react";
import { cn } from "@/app/lib/utils";
type ModalFieldLabelProps = ComponentPropsWithoutRef<"label"> & {
children: ReactNode;
as?: "label" | "p" | "span";
};
export function ModalFieldLabel({
as = "label",
children,
className,
...props
}: ModalFieldLabelProps) {
const classes = cn(
"mb-2 block text-xs font-medium text-gray-700",
className,
);
if (as === "p") {
return <p className={classes}>{children}</p>;
}
if (as === "span") {
return <span className={classes}>{children}</span>;
}
return (
<label className={classes} {...props}>
{children}
</label>
);
}

View file

@ -0,0 +1,71 @@
"use client";
import type { LucideIcon } from "lucide-react";
import { cn } from "@/app/lib/utils";
export interface SegmentedToggleOption<T extends string> {
value: T;
label: string;
icon?: LucideIcon;
}
interface ModalSegmentedToggleProps<T extends string> {
value: T;
onChange: (value: T) => void;
options: SegmentedToggleOption<T>[];
disabled?: boolean;
size?: "sm" | "md";
className?: string;
}
export function ModalSegmentedToggle<T extends string>({
value,
onChange,
options,
disabled = false,
size = "md",
className,
}: ModalSegmentedToggleProps<T>) {
return (
<div
className={cn(
"inline-grid gap-1 rounded-full bg-gray-100",
size === "sm" ? "h-8 p-1" : "h-9 p-1",
className,
)}
style={{
gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))`,
}}
>
{options.map((option) => {
const Icon = option.icon;
const active = option.value === value;
return (
<button
key={option.value}
type="button"
onClick={() => onChange(option.value)}
disabled={disabled}
aria-pressed={active}
className={cn(
"flex h-full items-center justify-center rounded-full text-xs transition-all disabled:cursor-not-allowed disabled:opacity-60",
size === "sm" ? "gap-1 px-3" : "gap-1.5 px-3",
active
? "bg-white/80 text-gray-900 shadow-[0_5px_16px_rgba(15,23,42,0.12),inset_0_1px_0_rgba(255,255,255,0.92),inset_0_-1px_0_rgba(255,255,255,0.62)] backdrop-blur-xl"
: "text-gray-500 hover:text-gray-700",
)}
>
{Icon && (
<Icon
className={
size === "sm" ? "h-2.5 w-2.5" : "h-3 w-3"
}
/>
)}
{option.label}
</button>
);
})}
</div>
);
}

View file

@ -0,0 +1,146 @@
"use client";
import { useState } from "react";
import { ChevronDown, type LucideIcon } from "lucide-react";
import { cn } from "@/app/lib/utils";
export type ModalSelectOption =
| string
| {
value: string;
label: string;
icon?: LucideIcon;
iconClassName?: string;
};
interface ModalSelectProps {
id: string;
value: string;
options: readonly ModalSelectOption[];
onChange: (value: string) => void;
placeholder?: string;
disabled?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
className?: string;
menuClassName?: string;
}
function normalizeOption(option: ModalSelectOption) {
return typeof option === "string"
? { value: option, label: option }
: option;
}
export function ModalSelect({
id,
value,
options,
onChange,
placeholder = "Select...",
disabled = false,
open,
onOpenChange,
className,
menuClassName,
}: ModalSelectProps) {
const [internalOpen, setInternalOpen] = useState(false);
const isOpen = open ?? internalOpen;
const normalizedOptions = options.map(normalizeOption);
const selected = normalizedOptions.find((option) => option.value === value);
const hasValue = value.trim().length > 0;
function setOpen(next: boolean) {
onOpenChange?.(next);
if (open === undefined) {
setInternalOpen(next);
}
}
function handleSelect(nextValue: string) {
setOpen(false);
onChange(nextValue);
}
return (
<div className="relative">
<button
id={id}
type="button"
onClick={() => setOpen(!isOpen)}
disabled={disabled}
className={cn(
"flex h-10 w-full items-center justify-between rounded-xl border border-white/70 bg-white/55 px-3 text-sm text-gray-700 shadow-[0_3px_9px_rgba(15,23,42,0.052),inset_0_1px_0_rgba(255,255,255,0.86),inset_0_-1px_0_rgba(255,255,255,0.58)] backdrop-blur-xl transition-colors hover:bg-white/70 focus:bg-white/70 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60",
className,
)}
aria-haspopup="listbox"
aria-expanded={isOpen}
>
<span className="flex min-w-0 items-center gap-2">
{selected?.icon && (
<selected.icon
className={cn(
"h-3.5 w-3.5 shrink-0",
selected.iconClassName,
)}
/>
)}
<span
className={cn(
"truncate",
!selected && !hasValue && "text-gray-400",
)}
>
{selected?.label ?? (hasValue ? value : placeholder)}
</span>
</span>
<ChevronDown
className={cn(
"ml-2 h-3.5 w-3.5 shrink-0 text-gray-400 transition-transform",
isOpen && "rotate-180",
)}
/>
</button>
{isOpen && !disabled && (
<div
role="listbox"
aria-labelledby={id}
className={cn(
"absolute left-0 top-full z-30 mt-1 max-h-56 w-full overflow-y-auto rounded-2xl border border-white/70 bg-gray-50/95 p-1 shadow-[0_12px_32px_rgba(15,23,42,0.156),inset_0_1px_0_rgba(255,255,255,0.86),inset_0_-1px_0_rgba(255,255,255,0.58)] backdrop-blur-2xl",
menuClassName,
)}
>
{normalizedOptions.map((option) => (
<button
key={option.value}
type="button"
role="option"
aria-selected={option.value === value}
onClick={() => handleSelect(option.value)}
className={cn(
"flex w-full items-center rounded-md px-3 py-2 text-left text-sm transition-all hover:bg-gray-100/70",
option.value === value
? "bg-gray-100 text-gray-900"
: "text-gray-700",
)}
>
<span className="flex min-w-0 items-center gap-2">
{option.icon && (
<option.icon
className={cn(
"h-3.5 w-3.5 shrink-0",
option.iconClassName,
)}
/>
)}
<span className="truncate">
{option.label}
</span>
</span>
</button>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,28 @@
"use client";
import { forwardRef, type InputHTMLAttributes } from "react";
import { cn } from "@/app/lib/utils";
type ModalTextInputVariant = "glass" | "minimal";
type ModalTextInputProps = InputHTMLAttributes<HTMLInputElement> & {
variant?: ModalTextInputVariant;
};
const variantClasses: Record<ModalTextInputVariant, string> = {
glass: "h-10 w-full rounded-xl border border-white/70 bg-white px-3 text-sm text-gray-700 shadow-[0_3px_9px_rgba(15,23,42,0.052),inset_0_1px_0_rgba(255,255,255,0.86),inset_0_-1px_0_rgba(255,255,255,0.58)] outline-none placeholder:text-gray-400 backdrop-blur-xl transition-colors disabled:cursor-not-allowed disabled:opacity-60",
minimal:
"w-full bg-transparent font-serif text-2xl text-gray-800 outline-none placeholder:text-gray-300 disabled:cursor-not-allowed disabled:text-gray-400",
};
export const ModalTextInput = forwardRef<HTMLInputElement, ModalTextInputProps>(
({ className, variant = "glass", ...props }, ref) => (
<input
ref={ref}
className={cn(variantClasses[variant], className)}
{...props}
/>
),
);
ModalTextInput.displayName = "ModalTextInput";

View file

@ -0,0 +1,22 @@
"use client";
import { forwardRef, type TextareaHTMLAttributes } from "react";
import { cn } from "@/app/lib/utils";
type ModalTextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement>;
export const ModalTextarea = forwardRef<
HTMLTextAreaElement,
ModalTextareaProps
>(({ className, ...props }, ref) => (
<textarea
ref={ref}
className={cn(
"min-h-24 w-full resize-none rounded-xl border border-white/70 bg-white px-3 py-2.5 text-sm leading-relaxed text-gray-700 shadow-[0_3px_9px_rgba(15,23,42,0.052),inset_0_1px_0_rgba(255,255,255,0.86),inset_0_-1px_0_rgba(255,255,255,0.58)] outline-none placeholder:text-gray-400 backdrop-blur-xl transition-colors disabled:cursor-not-allowed disabled:opacity-60",
className,
)}
{...props}
/>
));
ModalTextarea.displayName = "ModalTextarea";

View file

@ -0,0 +1,435 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { User, Loader2 } from "lucide-react";
import type { ProjectPeople } from "@/app/lib/mikeApi";
import { AddUserInput } from "../shared/AddUserInput";
import { Modal } from "./Modal";
/**
* Any resource the modal can manage members for projects today, tabular
* reviews now, anything else with a `shared_with` email list later.
*/
export interface SharedResource {
id: string;
shared_with?: string[] | null;
owner_display_name?: string | null;
owner_email?: string | null;
}
interface Props {
open: boolean;
onClose: () => void;
/** The thing being shared (project, review, …). */
resource: SharedResource | null;
/**
* Resolve the owner + members roster for the given resource. Different
* resource types hit different endpoints (`/projects/:id/people`,
* `/tabular-review/:id/people`, ) so the caller passes the appropriate
* fetcher.
*/
fetchPeople: (id: string) => Promise<ProjectPeople>;
/** Currently signed-in user's email — gets the "You" tag if it matches. */
currentUserEmail?: string | null;
breadcrumb: string[];
/**
* Persist a new shared_with list. Parent should PATCH the resource and
* sync its local state on success. Throw to surface an error inline.
*/
onSharedWithChange?: (sharedWith: string[]) => Promise<void> | void;
}
type RosterRow = {
email: string | null;
user_id?: string | null;
display_name: string | null;
role: "owner" | "member";
};
/**
* Roster of every Mike member with access to the project, with controls to
* add/remove members. Mirrors AddDocumentsModal's frame.
*/
export function PeopleModal({
open,
onClose,
resource,
fetchPeople,
currentUserEmail,
breadcrumb,
onSharedWithChange,
}: Props) {
const [busy, setBusy] = useState<"add" | "remove" | null>(null);
const [removingEmail, setRemovingEmail] = useState<string | null>(null);
const [memberMenuEmail, setMemberMenuEmail] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
// Server-resolved roster: owner email/display_name + members'
// display_names. We keep `resource.shared_with` as the source of truth
// for membership and just merge display_names from this fetch.
const [people, setPeople] = useState<ProjectPeople | null>(null);
const [lookupDisplayByEmail, setLookupDisplayByEmail] = useState<
Map<string, string | null>
>(new Map());
const [peopleLoading, setPeopleLoading] = useState(false);
const [loadedRosterKey, setLoadedRosterKey] = useState<string | null>(null);
const resourceId = resource?.id ?? null;
const sharedWith: string[] = useMemo(
() =>
Array.isArray(resource?.shared_with)
? (resource.shared_with as string[])
: [],
[resource?.shared_with],
);
useEffect(() => {
if (!open) return;
setError(null);
setBusy(null);
setRemovingEmail(null);
setMemberMenuEmail(null);
}, [open]);
useEffect(() => {
if (!memberMenuEmail) return;
function handleClickAway(event: PointerEvent) {
const target = event.target;
if (
target instanceof HTMLElement &&
target.closest("[data-people-member-menu]")
) {
return;
}
setMemberMenuEmail(null);
}
document.addEventListener("pointerdown", handleClickAway);
return () =>
document.removeEventListener("pointerdown", handleClickAway);
}, [memberMenuEmail]);
// Re-fetch roster whenever the modal opens or membership changes —
// keyed by the joined shared_with list so add/remove triggers a refresh.
const sharedKey = sharedWith
.map((e) => e.toLowerCase())
.sort()
.join(",");
const rosterKey = `${resourceId ?? ""}:${sharedKey}`;
useEffect(() => {
if (!open || !resourceId) return;
let cancelled = false;
setPeopleLoading(true);
setPeople(null);
setLoadedRosterKey(null);
fetchPeople(resourceId)
.then((data) => {
if (cancelled) return;
setPeople(data);
setLoadedRosterKey(rosterKey);
})
.catch(() => {
if (!cancelled) setLoadedRosterKey(rosterKey);
})
.finally(() => {
if (!cancelled) setPeopleLoading(false);
});
return () => {
cancelled = true;
};
}, [open, resourceId, rosterKey, fetchPeople]);
if (!open || !resource) return null;
const memberDisplayByEmail = new Map<string, string | null>();
for (const m of people?.members ?? []) {
memberDisplayByEmail.set(m.email.toLowerCase(), m.display_name);
}
const ownerEmail =
people?.owner.email?.trim().toLowerCase() ??
resource.owner_email?.trim().toLowerCase() ??
null;
const ownerDisplayName =
people?.owner.display_name ?? resource.owner_display_name ?? null;
const roster: RosterRow[] = [];
if (people?.owner || ownerEmail || ownerDisplayName) {
roster.push({
email: ownerEmail,
user_id: people?.owner.user_id ?? null,
display_name: ownerDisplayName,
role: "owner",
});
}
for (const email of sharedWith) {
const lower = email.toLowerCase();
if (ownerEmail && lower === ownerEmail.toLowerCase()) continue;
roster.push({
email,
display_name:
memberDisplayByEmail.get(lower) ??
lookupDisplayByEmail.get(lower) ??
null,
role: "member",
});
}
const normalizedCurrentUserEmail =
currentUserEmail?.trim().toLowerCase() ?? null;
const sharedLower = sharedWith.map((e) => e.toLowerCase());
const rosterPending = peopleLoading || loadedRosterKey !== rosterKey;
function validateNewEmail(email: string) {
if (sharedLower.includes(email)) return `${email} already has access.`;
if (ownerEmail && email === ownerEmail.toLowerCase()) {
return `${email} is the owner.`;
}
if (
normalizedCurrentUserEmail &&
email === normalizedCurrentUserEmail
) {
return "You cannot share this with yourself.";
}
return null;
}
async function handleAddUser(user: {
email: string;
display_name: string | null;
}) {
setLookupDisplayByEmail((prev) => {
const next = new Map(prev);
next.set(user.email.trim().toLowerCase(), user.display_name);
return next;
});
await handleAdd(user.email);
}
async function handleAdd(email: string) {
if (!onSharedWithChange || busy !== null) return;
setBusy("add");
setError(null);
try {
const next = [...sharedWith, email];
await onSharedWithChange(next);
} catch (e) {
throw new Error(
e instanceof Error
? e.message
: "Couldn't add the member. Try again.",
);
} finally {
setBusy(null);
}
}
async function handleRemove(email: string) {
if (!onSharedWithChange || busy !== null) return;
setBusy("remove");
setRemovingEmail(email);
setError(null);
try {
const next = sharedWith.filter(
(e) => e.toLowerCase() !== email.toLowerCase(),
);
await onSharedWithChange(next);
} catch (e) {
setError(
e instanceof Error
? e.message
: "Couldn't remove the member. Try again.",
);
} finally {
setBusy(null);
setRemovingEmail(null);
setMemberMenuEmail(null);
}
}
return (
<Modal open={open} onClose={onClose} breadcrumbs={breadcrumb}>
<div className="flex min-h-0 flex-1 flex-col gap-5 pb-5">
{/* Add-member row */}
{onSharedWithChange && (
<section className="space-y-2">
<AddUserInput
onAdd={handleAddUser}
validateEmail={validateNewEmail}
busy={busy === "add"}
placeholder="Add by email..."
autoFocus
submitLabel="Add member"
className="bg-white focus-within:bg-white"
/>
{error && (
<p className="mt-1.5 text-xs text-red-500">
{error}
</p>
)}
</section>
)}
<section className="flex min-h-0 flex-1 flex-col">
<div className="mb-2 flex items-center gap-2">
<h3 className="text-xs font-medium text-gray-500">
People with Access
</h3>
{peopleLoading && (
<Loader2 className="h-3 w-3 animate-spin text-gray-400" />
)}
</div>
{/* Member list */}
{rosterPending ? (
<div className="min-h-0 flex-1 space-y-1">
{[1, 2].map((item) => (
<div
key={item}
className="flex items-center gap-2.5 rounded-lg px-2 py-1.5"
>
<div className="h-6 w-6 shrink-0 animate-pulse rounded-full bg-gray-100" />
<div className="min-w-0 flex-1">
<div className="h-3 w-40 animate-pulse rounded bg-gray-100" />
</div>
<div className="h-4 w-12 shrink-0 animate-pulse rounded-full bg-gray-100" />
</div>
))}
</div>
) : roster.length === 0 ? (
<div className="flex min-h-0 flex-1 items-center justify-center text-sm text-gray-400">
No one has access yet.
</div>
) : (
<ul className="min-h-0 flex-1 space-y-1 overflow-y-auto">
{roster.map((entry) => {
const entryEmail = entry.email ?? "";
const rowKey =
entry.email ??
entry.user_id ??
`${entry.role}-unknown`;
const isYou =
!!currentUserEmail &&
!!entryEmail &&
entryEmail.toLowerCase() ===
currentUserEmail.toLowerCase();
const isRemoving =
busy === "remove" &&
removingEmail === entryEmail;
const displayName = entry.display_name?.trim();
const primary = isYou
? "You"
: displayName || entryEmail || "User";
const showEmail =
!isYou && !!displayName && !!entryEmail;
const initial = displayName
?.charAt(0)
.toUpperCase();
return (
<li
key={`${entry.role}-${rowKey}`}
className="group relative flex items-center gap-2.5 rounded-lg px-2 py-1.5 transition-colors hover:bg-gray-100/70"
>
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-white/80 bg-white text-gray-700 shadow-[0_4px_12px_rgba(15,23,42,0.10),inset_0_1px_0_rgba(255,255,255,0.92),inset_0_-1px_0_rgba(255,255,255,0.64)]">
{initial ? (
<span className="font-serif text-[11px] leading-none">
{initial}
</span>
) : (
<User className="h-2.5 w-2.5" />
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-xs text-gray-800">
{primary}
{showEmail && (
<span className="text-gray-400">
{" "}
· {entry.email}
</span>
)}
</p>
</div>
{entry.role === "owner" && (
<span className="shrink-0 rounded-full px-2 py-1 text-xs text-gray-400">
Owner
</span>
)}
{entry.role === "member" && (
<div
className="relative flex shrink-0 items-center"
data-people-member-menu
>
<span className="rounded-full px-2 py-1 text-xs text-gray-400">
Member
</span>
{onSharedWithChange && (
<>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
setMemberMenuEmail(
(current) =>
current ===
entryEmail
? null
: entryEmail,
);
}}
disabled={
busy !== null
}
title="Member actions"
className={`flex h-6 items-center justify-center overflow-hidden rounded-full text-xs leading-none text-gray-500 transition-all hover:bg-gray-200/70 hover:text-gray-800 disabled:opacity-50 ${
memberMenuEmail ===
entryEmail
? "w-6 opacity-100"
: "w-0 opacity-0 group-hover:w-6 group-hover:opacity-100"
}`}
>
···
</button>
{memberMenuEmail ===
entryEmail && (
<div
className="absolute right-0 top-full z-30 mt-1 min-w-28 overflow-hidden rounded-xl border border-white/70 bg-gray-50/95 p-1 shadow-[0_8px_20px_rgba(15,23,42,0.09),inset_0_1px_0_rgba(255,255,255,0.86),inset_0_-1px_0_rgba(255,255,255,0.58)] backdrop-blur-2xl"
onClick={(
event,
) =>
event.stopPropagation()
}
>
<button
type="button"
onClick={() =>
void handleRemove(
entryEmail,
)
}
disabled={
busy !==
null
}
className="flex w-full items-center gap-2 rounded-lg px-3 py-1.5 text-left text-xs text-red-500 transition-colors hover:bg-red-500/10 disabled:opacity-50"
>
{isRemoving && (
<Loader2 className="h-3 w-3 animate-spin" />
)}
Delete
</button>
</div>
)}
</>
)}
</div>
)}
</li>
);
})}
</ul>
)}
</section>
</div>
</Modal>
);
}

View file

@ -0,0 +1,136 @@
"use client";
import { useState, type ButtonHTMLAttributes, type ReactNode } from "react";
import { Folder } from "lucide-react";
import { SearchBar } from "@/app/components/ui/search-bar";
import type { Project } from "../shared/types";
import { Modal } from "./Modal";
type PrimaryAction = Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
"className"
> & {
label: ReactNode;
};
interface Props {
open: boolean;
onClose: () => void;
projects: Project[];
loading: boolean;
selectedId: string | null;
onSelect: (id: string | null) => void;
breadcrumbs?: ReactNode[];
primaryAction?: PrimaryAction;
}
export function ProjectPickerModal({
open,
onClose,
projects,
loading,
selectedId,
onSelect,
breadcrumbs,
primaryAction,
}: Props) {
const [search, setSearch] = useState("");
const q = search.toLowerCase().trim();
const filtered = q
? projects.filter((p) => p.name.toLowerCase().includes(q))
: projects;
return (
<Modal
open={open}
onClose={onClose}
breadcrumbs={breadcrumbs}
primaryAction={primaryAction}
>
<div className="pt-1 pb-2">
<SearchBar
value={search}
onValueChange={setSearch}
placeholder="Search projects..."
autoFocus
/>
</div>
<div className="min-h-0 flex-1 overflow-y-auto pb-2">
{loading ? (
<div className="space-y-px">
<div className="flex items-center rounded-md px-2 py-2">
<div className="h-3 w-14 rounded bg-gray-100 animate-pulse" />
</div>
{[65, 45, 80, 55, 70].map((w, i) => (
<div
key={i}
className="flex items-center gap-2 rounded-md px-2 py-2"
>
<div className="h-3.5 w-3.5 rounded-full border border-gray-200 shrink-0" />
<div className="h-3.5 w-3.5 rounded bg-gray-100 animate-pulse shrink-0" />
<div
className="h-3 rounded bg-gray-100 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 projects yet"}
</p>
) : (
<div className="rounded-sm overflow-hidden">
<div className="flex items-center justify-between px-2 py-2">
<p className="text-xs font-medium text-gray-400">
Projects
</p>
</div>
<div className="space-y-px">
{filtered.map((project) => {
const isSelected = selectedId === project.id;
const documentCount =
project.document_count ??
project.documents?.length ??
0;
return (
<button
key={project.id}
onClick={() =>
onSelect(
isSelected ? null : project.id,
)
}
className={`w-full flex rounded-md items-center gap-2 px-2 py-2 text-xs transition-all text-left ${isSelected ? "bg-gray-100" : "hover:bg-gray-100/70"}`}
>
<span
className={`shrink-0 h-3.5 w-3.5 rounded border flex items-center justify-center ${isSelected ? "bg-gray-900 border-gray-900" : "border-gray-300"}`}
>
{isSelected && (
<span className="h-1.5 w-1.5 rounded-sm bg-white" />
)}
</span>
<Folder className="h-3.5 w-3.5 shrink-0 text-gray-400" />
<span
className={`flex-1 truncate ${isSelected ? "text-gray-900" : "text-gray-700"}`}
>
{project.name}
{project.cm_number && (
<span className="ml-1 font-normal text-gray-400">
(#{project.cm_number})
</span>
)}
</span>
<span className="shrink-0 text-gray-400">
{documentCount}
</span>
</button>
);
})}
</div>
</div>
)}
</div>
</Modal>
);
}

View file

@ -0,0 +1,45 @@
"use client";
import { useRouter } from "next/navigation";
import { AlertTriangle } from "lucide-react";
import { providerLabel, type ModelProvider } from "@/app/lib/modelAvailability";
import { WarningPopup } from "../popups/WarningPopup";
interface Props {
open: boolean;
onClose: () => void;
provider: ModelProvider | null;
/** Optional override for the body sentence. */
message?: string;
}
export function ApiKeyMissingPopup({ open, onClose, provider, message }: Props) {
const router = useRouter();
if (!open) return null;
const providerName = provider ? providerLabel(provider) : "this provider";
const body =
message ??
`You haven't added a ${providerName} API key yet. Add one in your account settings to use this model.`;
const handleGoToAccount = () => {
onClose();
router.push("/account/models");
};
return (
<WarningPopup
open={open}
onClose={onClose}
title="API key required"
message={body}
icon={
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-red-600" />
}
primaryAction={{
label: "Go to account settings",
onClick: handleGoToAccount,
}}
/>
);
}

View file

@ -0,0 +1,114 @@
"use client";
import { createPortal } from "react-dom";
import type { ReactNode } from "react";
import { Loader2, Trash2 } from "lucide-react";
import { PillButton } from "@/app/components/ui/pill-button";
import { cn } from "@/app/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 isDeleteAction = normalizedConfirmLabel.toLowerCase() === "delete";
const resolvedConfirmLabel =
confirmStatus === "loading" ? (
<span className="inline-flex h-full items-center gap-1.5">
<Loader2 className="h-3 w-3 shrink-0 animate-spin" />
{progressiveLabel(normalizedConfirmLabel)}
</span>
) : confirmStatus === "complete" ? (
completedLabel(normalizedConfirmLabel)
) : isDeleteAction ? (
<span className="inline-flex h-full items-center gap-1.5">
<Trash2 className="h-3 w-3 shrink-0" />
{confirmLabel}
</span>
) : (
confirmLabel
);
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 px-4 py-3 text-sm shadow-[0_4px_14px_rgba(15,23,42,0.08),inset_0_1px_0_rgba(255,255,255,0.92)] backdrop-blur-2xl",
className,
)}
>
{title && (
<div className="text-sm font-medium text-gray-950 mb-3">
{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">
<PillButton
tone="white"
size="sm"
onClick={onCancel}
>
{cancelLabel}
</PillButton>
<PillButton
tone={isDeleteAction ? "danger" : "black"}
size="sm"
onClick={onConfirm}
disabled={resolvedConfirmDisabled}
className="h-7 px-3.5 leading-none"
aria-busy={confirmBusy}
>
{resolvedConfirmLabel}
</PillButton>
</div>
</div>
</div>,
document.body,
);
}
function progressiveLabel(label: string) {
const lower = label.toLowerCase();
if (lower.endsWith("e")) return `${label.slice(0, -1)}ing...`;
return `${label}ing...`;
}
function completedLabel(label: string) {
const lower = label.toLowerCase();
if (lower.endsWith("e")) return `${label}d`;
return `${label}ed`;
}

View file

@ -0,0 +1,294 @@
"use client";
import {
useEffect,
useRef,
useState,
type ClipboardEvent,
type KeyboardEvent,
} from "react";
import { Loader2 } from "lucide-react";
import { supabase } from "@/app/lib/supabase";
import { Modal } from "../modals/Modal";
type MfaFactor = {
id: string;
friendly_name?: string | null;
factor_type: string;
};
const isDev = process.env.NODE_ENV !== "production";
const devLog = (...args: Parameters<typeof console.log>) => {
if (isDev) console.log(...args);
};
export async function needsMfaVerification() {
const { data, error } =
await supabase.auth.mfa.getAuthenticatorAssuranceLevel();
if (error) throw error;
return data.nextLevel === "aal2" && data.currentLevel !== "aal2";
}
interface MfaVerificationPopupProps {
open: boolean;
onCancel: () => void;
onVerified: () => void;
title?: string;
message?: string;
}
export function MfaVerificationPopup({
open,
onCancel,
onVerified,
title = "Two-factor verification required",
message = "Enter a code from your authenticator app to continue.",
}: MfaVerificationPopupProps) {
const [factors, setFactors] = useState<MfaFactor[]>([]);
const [selectedFactorId, setSelectedFactorId] = useState("");
const [code, setCode] = useState("");
const [loading, setLoading] = useState(false);
const [verifying, setVerifying] = useState(false);
const [error, setError] = useState<string | null>(null);
const canVerify =
!verifying &&
!loading &&
!!selectedFactorId &&
code.trim().length === 6;
useEffect(() => {
if (!open) return;
let cancelled = false;
devLog("[mfa-popup] opened");
async function loadFactors() {
setLoading(true);
setError(null);
setCode("");
const { data, error: listError } =
await supabase.auth.mfa.listFactors();
if (cancelled) return;
if (listError) {
devLog("[mfa-popup] list factors failed", {
error: listError.message,
});
setError(listError.message);
setFactors([]);
setSelectedFactorId("");
} else {
const verified = (data.totp ?? []) as MfaFactor[];
devLog("[mfa-popup] factors loaded", {
totpCount: verified.length,
selectedFactorId: verified[0]?.id ?? null,
});
setFactors(verified);
setSelectedFactorId(verified[0]?.id ?? "");
}
setLoading(false);
}
void loadFactors();
return () => {
cancelled = true;
};
}, [open]);
async function verify() {
if (!canVerify) return;
setVerifying(true);
setError(null);
devLog("[mfa-popup] verifying code", { factorId: selectedFactorId });
const { error: verifyError } =
await supabase.auth.mfa.challengeAndVerify({
factorId: selectedFactorId,
code: code.trim(),
});
setVerifying(false);
if (verifyError) {
devLog("[mfa-popup] verification failed", {
error: verifyError.message,
});
setError(verifyError.message);
return;
}
devLog("[mfa-popup] verification succeeded");
setCode("");
onVerified();
}
if (!open) return null;
return (
<Modal
open={open}
onClose={onCancel}
breadcrumbs={[title]}
size="sm"
className="h-auto min-h-[310px] max-h-[min(92vh,400px)]"
cancelAction={{
label: "Cancel",
onClick: onCancel,
disabled: verifying,
}}
primaryAction={{
label: verifying ? (
<span className="inline-flex items-center gap-1.5">
<Loader2 className="h-3 w-3 animate-spin" />
Verifying...
</span>
) : (
"Verify"
),
onClick: () => void verify(),
disabled: !canVerify,
}}
>
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto pb-2 pt-0">
<p className="text-sm text-gray-500 pb-6">{message}</p>
{loading ? (
<div className="flex h-13 items-center justify-center text-sm text-gray-500">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading authenticator...
</div>
) : factors.length === 0 ? (
<p className="rounded-lg bg-gray-100 px-3 py-2 text-sm text-gray-600">
No verified authenticator factor is available for this
session.
</p>
) : (
<div className="space-y-4">
{factors.length > 1 && (
<select
value={selectedFactorId}
onChange={(event) =>
setSelectedFactorId(event.target.value)
}
className="h-9 w-full rounded-lg bg-gray-100 px-3 text-sm text-gray-900 outline-none focus-visible:ring-2 focus-visible:ring-gray-300/45"
>
{factors.map((factor) => (
<option key={factor.id} value={factor.id}>
{factor.friendly_name ||
"Authenticator app"}
</option>
))}
</select>
)}
<VerificationCodeInput
value={code}
onChange={setCode}
disabled={verifying}
autoFocus={open && !loading}
onSubmit={() => void verify()}
canSubmit={canVerify}
/>
</div>
)}
{error && <p className="text-xs text-red-600">{error}</p>}
</div>
</Modal>
);
}
export function VerificationCodeInput({
value,
onChange,
disabled,
autoFocus,
onSubmit,
canSubmit,
}: {
value: string;
onChange: (value: string) => void;
disabled?: boolean;
autoFocus?: boolean;
onSubmit?: () => void;
canSubmit?: boolean;
}) {
const inputsRef = useRef<Array<HTMLInputElement | null>>([]);
const digits = Array.from({ length: 6 }, (_, index) => value[index] ?? "");
useEffect(() => {
if (!autoFocus || disabled) return;
const focusTimer = window.setTimeout(() => {
const firstEmptyIndex = digits.findIndex((digit) => !digit);
inputsRef.current[
firstEmptyIndex === -1 ? 0 : firstEmptyIndex
]?.focus();
}, 0);
return () => window.clearTimeout(focusTimer);
}, [autoFocus, disabled]);
function updateDigit(index: number, nextValue: string) {
const digit = nextValue.replace(/\D/g, "").slice(-1);
const nextDigits = [...digits];
nextDigits[index] = digit;
onChange(nextDigits.join(""));
if (digit && index < inputsRef.current.length - 1) {
inputsRef.current[index + 1]?.focus();
}
}
function handlePaste(event: ClipboardEvent<HTMLInputElement>) {
event.preventDefault();
const pasted = event.clipboardData
.getData("text")
.replace(/\D/g, "")
.slice(0, 6);
if (!pasted) return;
onChange(pasted);
inputsRef.current[Math.min(pasted.length, 6) - 1]?.focus();
}
function handleKeyDown(
event: KeyboardEvent<HTMLInputElement>,
index: number,
) {
if (event.key === "Enter") {
event.preventDefault();
if (canSubmit) onSubmit?.();
return;
}
if (event.key === "Backspace" && !digits[index] && index > 0) {
inputsRef.current[index - 1]?.focus();
}
if (event.key === "ArrowLeft" && index > 0) {
event.preventDefault();
inputsRef.current[index - 1]?.focus();
}
if (event.key === "ArrowRight" && index < digits.length - 1) {
event.preventDefault();
inputsRef.current[index + 1]?.focus();
}
}
return (
<div
className="flex justify-center gap-2"
role="group"
aria-label="Six digit verification code"
>
{digits.map((digit, index) => (
<input
key={index}
ref={(element) => {
inputsRef.current[index] = element;
}}
type="text"
inputMode="numeric"
autoComplete={index === 0 ? "one-time-code" : "off"}
value={digit}
disabled={disabled}
onChange={(event) => updateDigit(index, event.target.value)}
onPaste={handlePaste}
onKeyDown={(event) => handleKeyDown(event, index)}
className="h-13 w-12 rounded-lg border border-gray-300 bg-gray-50 text-center text-2xl font-medium font-serif text-gray-950 shadow-none outline-none transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-300/45 disabled:cursor-not-allowed disabled:opacity-45"
aria-label={`Verification code digit ${index + 1}`}
maxLength={1}
/>
))}
</div>
);
}

View file

@ -0,0 +1,57 @@
"use client";
import { Lock } from "lucide-react";
import { WarningPopup } from "../popups/WarningPopup";
interface Props {
open: boolean;
onClose: () => void;
/** Short headline above the body, e.g. "Owner-only action". */
title?: string;
/** Sentence describing what the user tried to do. */
action?: string;
/** Email of the project/resource owner, shown so the user knows who to ask. */
ownerEmail?: string | null;
/** Override the default message entirely. */
message?: string;
}
/**
* Lightweight "you don't have permission" popup shown when a non-owner
* attempts an owner-only action (manage people, rename, delete, ) on a
* shared project. Replaces the silent 404 the backend would otherwise
* return so the user understands why the action didn't go through.
*/
export function OwnerOnlyPopup({
open,
onClose,
title = "Owner-only action",
action,
ownerEmail,
message,
}: Props) {
if (!open) return null;
const body =
message ??
(action
? `Only the project owner can ${action}.`
: "Only the project owner can perform this action.");
return (
<WarningPopup
open={open}
onClose={onClose}
title={title}
message={body}
icon={<Lock className="h-3.5 w-3.5 shrink-0 text-red-600" />}
>
{ownerEmail && (
<p className="mt-1 text-xs text-gray-600">
Ask <span className="text-gray-600">{ownerEmail}</span> if
you need access.
</p>
)}
</WarningPopup>
);
}

View file

@ -0,0 +1,117 @@
"use client";
import { createPortal } from "react-dom";
import type { ReactNode } from "react";
import { AlertCircle, X } from "lucide-react";
import { cn } from "@/app/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;
const warningIcon = icon ?? (
<AlertCircle className="h-3 w-3 shrink-0 text-red-600" />
);
return createPortal(
<div className="pointer-events-none fixed left-1/2 top-5 z-[220] w-[min(92vw,520px)] -translate-x-1/2 px-4">
<div
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,
)}
>
<div className="min-w-0 flex-1 self-center text-red-600">
{title && (
<div className="flex items-center gap-1.5 font-medium mb-1">
{warningIcon}
{title}
</div>
)}
{message && (
<div
className={cn(!title && "flex items-start gap-1.5")}
>
{!title && warningIcon}
<span className="min-w-0">{message}</span>
</div>
)}
{children}
{(primaryAction || secondaryAction) && (
<div className="mt-2 flex items-center gap-2">
{secondaryAction && (
<WarningPopupButton action={secondaryAction} />
)}
{primaryAction && (
<WarningPopupButton
action={primaryAction}
primary
/>
)}
</div>
)}
</div>
<button
type="button"
onClick={onClose}
className="shrink-0 text-red-700 transition-colors hover:text-red-500"
aria-label="Dismiss warning"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>,
document.body,
);
}
function WarningPopupButton({
action,
primary = false,
}: {
action: WarningPopupAction;
primary?: boolean;
}) {
return (
<button
type="button"
onClick={action.onClick}
disabled={action.disabled}
className={cn(
"rounded-lg px-3 py-1 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40",
primary
? "bg-gray-900 text-white hover:bg-gray-700"
: "text-gray-700 hover:bg-white/70",
)}
>
{action.label}
</button>
);
}

View file

@ -19,7 +19,7 @@ import { WarningPopup } from "@/app/components/popups/WarningPopup";
import type { Document } from "@/app/components/shared/types";
import { isSpreadsheetFilename } from "@/app/components/shared/types";
import type { DocumentVersion } from "@/app/lib/mikeApi";
import { cn } from "@/lib/utils";
import { cn } from "@/app/lib/utils";
import { formatBytes } from "./ProjectPageParts";
const MIN_DOC_COLUMN_WIDTH = 420;
@ -489,7 +489,6 @@ export function DocumentSidePanel({
documentId={doc.id}
versionId={selectedVersionId}
rounded={false}
bordered={false}
/>
) : (
<PdfView
@ -499,7 +498,6 @@ export function DocumentSidePanel({
version_id: selectedVersionId,
}}
rounded={false}
bordered={false}
/>
)}
</div>

View file

@ -12,7 +12,7 @@ import { FileDirectory } from "../shared/FileDirectory";
import { AddUserInput } from "../shared/AddUserInput";
import type { Project } from "../shared/types";
import type { UserLookupResult } from "@/app/lib/mikeApi";
import { useAuth } from "@/contexts/AuthContext";
import { useAuth } from "@/app/contexts/AuthContext";
import { Modal } from "../modals/Modal";
import { ModalFieldLabel } from "../modals/ModalFieldLabel";
import { ModalTextInput } from "../modals/ModalTextInput";
@ -151,12 +151,12 @@ export function NewProjectModal({ open, onClose, onCreated }: Props) {
breadcrumbs={[
"Projects",
"New project",
step === "details" ? "Details" : "Documents",
step === "details" ? "Details" : "Add Documents",
]}
secondaryAction={
step === "documents"
? {
label: `Upload documents${pendingFiles.length > 0 ? ` (${pendingFiles.length})` : ""}`,
label: `Upload${pendingFiles.length > 0 ? ` (${pendingFiles.length})` : ""}`,
icon: <Upload className="h-3.5 w-3.5" />,
onClick: () => fileInputRef.current?.click(),
disabled: loading,
@ -309,7 +309,7 @@ export function NewProjectModal({ open, onClose, onCreated }: Props) {
</div>
</div>
) : (
<div className="min-h-0 flex-1">
<div className="flex min-h-0 flex-1 flex-col">
<FileDirectory
standaloneDocs={standaloneDocuments}
directoryProjects={dirProjects}
@ -319,6 +319,7 @@ export function NewProjectModal({ open, onClose, onCreated }: Props) {
emptyMessage="No existing documents"
searchable
searchAutoFocus
showProjectTabs
/>
</div>
)}

View file

@ -44,7 +44,7 @@ import {
AddDocumentsModal,
invalidateDirectoryCache,
} from "@/app/components/modals/AddDocumentsModal";
import { useAuth } from "@/contexts/AuthContext";
import { useAuth } from "@/app/contexts/AuthContext";
import { WarningPopup } from "@/app/components/popups/WarningPopup";
import { ConfirmPopup } from "@/app/components/popups/ConfirmPopup";
import {

View file

@ -0,0 +1,73 @@
"use client";
import { useMemo } from "react";
import { PRACTICE_OPTIONS } from "../workflows/practices";
import { ModalSelect, type ModalSelectOption } from "../modals/ModalSelect";
import { ModalTextInput } from "../modals/ModalTextInput";
const OPTION_NONE = "__none__";
const OPTION_OTHER = "Other";
interface ProjectPracticeFieldProps {
id: string;
value: string;
onChange: (value: string) => void;
disabled?: boolean;
}
export function ProjectPracticeField({
id,
value,
onChange,
disabled = false,
}: ProjectPracticeFieldProps) {
const selectedOption = useMemo(() => {
if (!value.trim()) return OPTION_NONE;
return (PRACTICE_OPTIONS as readonly string[]).includes(value)
? value
: OPTION_OTHER;
}, [value]);
const customValue =
selectedOption === OPTION_OTHER && value !== OPTION_OTHER ? value : "";
const options = useMemo<ModalSelectOption[]>(
() => [
{ value: OPTION_NONE, label: "None" },
...PRACTICE_OPTIONS,
],
[],
);
function handleSelect(option: string) {
if (option === OPTION_NONE) {
onChange("");
return;
}
if (option === OPTION_OTHER) {
onChange(OPTION_OTHER);
return;
}
onChange(option);
}
return (
<div className="space-y-2">
<ModalSelect
id={id}
value={selectedOption}
options={options}
onChange={handleSelect}
placeholder="Select practice"
disabled={disabled}
/>
{selectedOption === OPTION_OTHER && (
<ModalTextInput
type="text"
value={customValue}
onChange={(event) => onChange(event.target.value)}
placeholder="Enter practice..."
disabled={disabled}
/>
)}
</div>
);
}

View file

@ -34,8 +34,8 @@ import { ConfirmPopup } from "@/app/components/popups/ConfirmPopup";
import { OwnerOnlyPopup } from "@/app/components/popups/OwnerOnlyPopup";
import { PeopleModal } from "@/app/components/modals/PeopleModal";
import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext";
import { useAuth } from "@/contexts/AuthContext";
import { useUserProfile } from "@/contexts/UserProfileContext";
import { useAuth } from "@/app/contexts/AuthContext";
import { useUserProfile } from "@/app/contexts/UserProfileContext";
import { ProjectDetailsModal } from "./ProjectDetailsModal";
import {
ProjectPageHeader,

View file

@ -9,7 +9,7 @@ import {
deleteProject,
} from "@/app/lib/mikeApi";
import { OwnerOnlyPopup } from "@/app/components/popups/OwnerOnlyPopup";
import { useAuth } from "@/contexts/AuthContext";
import { useAuth } from "@/app/contexts/AuthContext";
import type { Project } from "@/app/components/shared/types";
import { NewProjectModal } from "./NewProjectModal";
import { ProjectDetailsModal } from "./ProjectDetailsModal";

View file

@ -1,8 +1,8 @@
"use client";
import { Suspense } from "react";
import { AuthProvider } from "@/contexts/AuthContext";
import { UserProfileProvider } from "@/contexts/UserProfileContext";
import { AuthProvider } from "@/app/contexts/AuthContext";
import { UserProfileProvider } from "@/app/contexts/UserProfileContext";
import { MfaLoginGate } from "@/app/components/shared/MfaLoginGate";
export function Providers({ children }: { children: React.ReactNode }) {

View file

@ -0,0 +1,123 @@
"use client";
import { useState } from "react";
import type { KeyboardEvent } from "react";
import { Loader2, UserPlus } from "lucide-react";
import {
lookupUserByEmail,
type UserLookupResult,
} from "@/app/lib/mikeApi";
import { cn } from "@/app/lib/utils";
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
interface AddUserInputProps {
onAdd: (user: UserLookupResult) => Promise<void> | void;
validateEmail?: (email: string) => Promise<string | null> | string | null;
busy?: boolean;
placeholder?: string;
autoFocus?: boolean;
submitLabel?: string;
className?: string;
}
export function AddUserInput({
onAdd,
validateEmail,
busy = false,
placeholder = "Add by email...",
autoFocus = false,
submitLabel = "Add user",
className,
}: AddUserInputProps) {
const [input, setInput] = useState("");
const [checking, setChecking] = useState(false);
const [error, setError] = useState<string | null>(null);
const trimmedEmail = input.trim().toLowerCase();
const showAddButton = trimmedEmail.length > 0;
async function commitUser() {
const email = trimmedEmail;
if (!email || busy || checking) return;
if (!EMAIL_RE.test(email)) {
setError("Enter a valid email.");
return;
}
setError(null);
setChecking(true);
try {
const validationError = await validateEmail?.(email);
if (validationError) {
setError(validationError);
return;
}
const user = await lookupUserByEmail(email);
if (!user.exists) {
setError(`${email} does not belong to a Mike user.`);
return;
}
await onAdd(user);
setInput("");
} catch (err) {
setError(
err instanceof Error
? err.message
: "Could not add this user. Try again.",
);
} finally {
setChecking(false);
}
}
function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) {
if (event.key === "Enter" || event.key === ",") {
event.preventDefault();
void commitUser();
}
}
return (
<div>
<div
className={cn(
"flex min-h-10 items-center gap-2 rounded-xl border border-white/70 bg-white/55 px-3 py-1.5 shadow-[0_3px_9px_rgba(15,23,42,0.04),inset_0_1px_0_rgba(255,255,255,0.86),inset_0_-1px_0_rgba(255,255,255,0.58)] backdrop-blur-xl transition-colors focus-within:bg-white/70",
className,
)}
>
<UserPlus className="h-3.5 w-3.5 shrink-0 text-gray-400" />
<input
type="email"
value={input}
onChange={(event) => {
setInput(event.target.value);
setError(null);
}}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className="min-w-0 flex-1 bg-transparent text-sm text-gray-700 outline-none placeholder:text-gray-400"
autoFocus={autoFocus}
/>
{showAddButton && (
<button
type="button"
onMouseDown={(event) => event.preventDefault()}
onClick={() => void commitUser()}
disabled={busy || checking}
title={submitLabel}
className="inline-flex h-6 shrink-0 items-center gap-1 rounded-full border border-blue-500/35 bg-blue-600/90 px-2.5 text-[11px] font-medium leading-none text-white shadow-[0_3px_9px_rgba(37,99,235,0.10),inset_0_1px_0_rgba(255,255,255,0.28),inset_0_-1px_0_rgba(255,255,255,0.16),inset_0_-4px_9px_rgba(29,78,216,0.2)] backdrop-blur-xl transition-colors hover:bg-blue-600 disabled:cursor-not-allowed disabled:opacity-40"
>
{(busy || checking) && (
<Loader2 className="h-3 w-3 animate-spin" />
)}
Add
</button>
)}
</div>
{error && <p className="mt-1.5 text-xs text-red-500">{error}</p>}
</div>
);
}

View file

@ -4,6 +4,7 @@ import { useState, useEffect, useMemo } from "react";
import {
PanelLeft,
MessageSquare,
Folder,
FolderOpen,
Table2,
Library,
@ -11,16 +12,16 @@ import {
ChevronsUpDown,
ChevronDown,
} from "lucide-react";
import { useAuth } from "@/contexts/AuthContext";
import { useUserProfile } from "@/contexts/UserProfileContext";
import { useAuth } from "@/app/contexts/AuthContext";
import { useUserProfile } from "@/app/contexts/UserProfileContext";
import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext";
import { useRouter, usePathname } from "next/navigation";
import Link from "next/link";
import { MikeIcon } from "@/components/chat/mike-icon";
import { MikeIcon } from "@/app/components/chat/mike-icon";
import { SidebarChatItem } from "@/app/components/shared/SidebarChatItem";
import { listProjects } from "@/app/lib/mikeApi";
import type { Project } from "@/app/components/shared/types";
import { cn } from "@/lib/utils";
import { cn } from "@/app/lib/utils";
const NAV_ITEMS = [
{ href: "/assistant", label: "Assistant", icon: MessageSquare },
@ -85,9 +86,10 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
});
}, [user]);
useEffect(() => {
if (!isOpen) setShouldAnimate(true);
}, [isOpen]);
const handleToggle = () => {
if (isOpen) setShouldAnimate(true);
onToggle();
};
useEffect(() => {
const handleClickOutside = () => setIsDropdownOpen(false);
@ -121,113 +123,222 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
if (!user) return null;
return (
<div
className={cn(
isOpen
? "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_-1px_6px_rgba(15,23,42,0.034),0_4px_9px_rgba(15,23,42,0.074),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]",
<>
{/* Mobile: tapping outside the expanded sidebar closes it. The
sidebar (z-[99]) sits above this scrim (z-[98]); md+ is
unaffected since the sidebar is part of the layout there. */}
{isOpen && (
<div
className="fixed inset-0 z-[98] bg-gray-300/20 md:hidden"
onClick={handleToggle}
aria-hidden="true"
/>
)}
>
{/* Toggle + Logo */}
<div
className={`items-center justify-between px-2.5 py-3 ${
!isOpen ? "hidden md:flex" : "flex"
}`}
className={cn(
isOpen
? "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_-1px_6px_rgba(15,23,42,0.034),0_4px_9px_rgba(15,23,42,0.074),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
className={`items-center justify-between px-2.5 py-3 ${
!isOpen ? "hidden md:flex" : "flex"
}`}
>
{isOpen && (
<div className="px-2">
<Link
href="/assistant"
className="flex items-center gap-1.5 hover:opacity-80 transition-opacity"
>
<MikeIcon size={22} />
<span
className={`text-2xl font-light font-serif ${
shouldAnimate ? "sidebar-fade-in" : ""
}`}
>
Mike
</span>
</Link>
</div>
)}
<button
onClick={handleToggle}
className={cn(
"h-9 w-9 p-2.5 items-center flex transition-colors",
"rounded-md hover:bg-gray-100",
)}
title={isOpen ? "Close sidebar" : "Open sidebar"}
>
<PanelLeft className="h-4 w-4" />
</button>
</div>
{/* Nav items */}
{NAV_ITEMS.map(({ href, label, icon: Icon }) => {
const isActive =
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={cn(
"w-full h-9 flex items-center gap-3 px-2.5 py-2 rounded-md transition-colors text-left",
isActive
? "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 ${
isActive
? "text-gray-900"
: "text-black"
}`}
/>
{isOpen && (
<span
className={`text-sm font-medium ${
shouldAnimate
? "sidebar-fade-in-2"
: ""
}`}
>
{label}
</span>
)}
</button>
</div>
);
})}
{isOpen && (
<div className="px-2">
<Link
href="/assistant"
className="flex items-center gap-1.5 hover:opacity-80 transition-opacity"
>
<MikeIcon size={22} />
<span
className={`text-2xl font-light font-serif ${
<div className="mt-4 flex-1 min-h-0 flex flex-col gap-4">
{/* Recent Projects */}
<div>
<button
onClick={() => setProjectsCollapsed((v) => !v)}
className={`mb-2 flex w-full items-center justify-between px-5 text-xs font-semibold text-gray-500 transition-colors hover:text-gray-700 ${
shouldAnimate ? "sidebar-fade-in" : ""
}`}
>
Mike
</span>
</Link>
</div>
)}
<button
onClick={onToggle}
className={cn(
"h-9 w-9 p-2.5 items-center flex transition-colors",
"rounded-md hover:bg-gray-100",
)}
title={isOpen ? "Close sidebar" : "Open sidebar"}
>
<PanelLeft className="h-4 w-4" />
</button>
</div>
{/* Nav items */}
{NAV_ITEMS.map(({ href, label, icon: Icon }) => {
const isActive =
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={cn(
"w-full h-9 flex items-center gap-3 px-2.5 py-2 rounded-md transition-colors text-left",
isActive
? "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 ${
isActive ? "text-gray-900" : "text-black"
}`}
/>
{isOpen && (
<span
className={`text-sm font-medium ${
shouldAnimate ? "sidebar-fade-in-2" : ""
<span>Recent Projects</span>
<ChevronDown
className={`h-3.5 w-3.5 transition-transform ${
projectsCollapsed ? "-rotate-90" : ""
}`}
>
{label}
</span>
/>
</button>
{!projectsCollapsed && (
<>
{!recentProjects ? (
<div className="space-y-1 px-2.5">
{[50, 65, 45].map((w, i) => (
<div
key={i}
className="h-9 flex items-center px-3 rounded-md"
>
<div
className="h-3 bg-gray-200 rounded animate-pulse"
style={{
width: `${w}%`,
}}
/>
</div>
))}
</div>
) : recentProjects.length === 0 ? (
<div
className={`px-5 py-2 text-xs text-gray-500 ${
shouldAnimate
? "sidebar-fade-in-2"
: ""
}`}
>
No projects yet
</div>
) : (
<div
className={`space-y-1 px-2.5 ${
shouldAnimate
? "sidebar-fade-in-2"
: ""
}`}
>
{recentProjects.map((project) => {
const isActive =
pathname ===
`/projects/${project.id}` ||
pathname.startsWith(
`/projects/${project.id}/`,
);
return (
<button
key={project.id}
onClick={() =>
router.push(
`/projects/${project.id}`,
)
}
title={project.name}
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-200/60 text-gray-900"
: "text-gray-700 hover:bg-gray-100",
)}
>
{isActive ? (
<FolderOpen className="h-3.5 w-3.5 shrink-0 text-gray-600" />
) : (
<Folder className="h-3.5 w-3.5 shrink-0 text-gray-600" />
)}
<span className="min-w-0 flex-1 truncate">
{project.name}
</span>
</button>
);
})}
</div>
)}
</>
)}
</button>
</div>
);
})}
</div>
{isOpen && (
<div className="mt-4 flex-1 min-h-0 flex flex-col gap-4">
{/* Recent Projects */}
<div>
<button
onClick={() => setProjectsCollapsed((v) => !v)}
className={`mb-2 flex w-full items-center justify-between px-5 text-xs font-semibold text-gray-500 transition-colors hover:text-gray-700 ${
shouldAnimate ? "sidebar-fade-in" : ""
}`}
>
<span>Recent Projects</span>
<ChevronDown
className={`h-3.5 w-3.5 transition-transform ${
projectsCollapsed ? "-rotate-90" : ""
{/* Assistant History */}
<div className="flex min-h-0 flex-1 flex-col">
<button
onClick={() => setHistoryCollapsed((v) => !v)}
className={`mb-2 flex w-full items-center justify-between px-5 text-xs font-semibold text-gray-500 transition-colors hover:text-gray-700 ${
shouldAnimate ? "sidebar-fade-in" : ""
}`}
/>
</button>
{!projectsCollapsed && (
<>
{!recentProjects ? (
>
<span>Assistant History</span>
<ChevronDown
className={`h-3.5 w-3.5 transition-transform ${
historyCollapsed ? "-rotate-90" : ""
}`}
/>
</button>
<div
className={`overflow-y-auto flex-1 ${
historyCollapsed ? "hidden" : ""
}`}
>
{!chats ? (
<div className="space-y-1 px-2.5">
{[50, 65, 45].map((w, i) => (
{[40, 60, 50, 70, 45].map((w, i) => (
<div
key={i}
className="h-9 flex items-center px-3 rounded-md"
@ -239,220 +350,142 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
</div>
))}
</div>
) : recentProjects.length === 0 ? (
) : chats.length === 0 ? (
<div
className={`px-5 py-2 text-xs text-gray-500 ${
className={`text-xs text-gray-500 py-2 px-5 ${
shouldAnimate
? "sidebar-fade-in-2"
: ""
}`}
>
No projects yet
No chats yet
</div>
) : (
<div
className={`space-y-1 px-2.5 ${
shouldAnimate
? "sidebar-fade-in-2"
: ""
}`}
>
{recentProjects.map((project) => {
const isActive =
pathname ===
`/projects/${project.id}` ||
pathname.startsWith(
`/projects/${project.id}/`,
);
return (
<button
key={project.id}
onClick={() =>
router.push(
`/projects/${project.id}`,
)
<>
<div
className={`space-y-1 px-2.5 ${
shouldAnimate
? "sidebar-fade-in-2"
: ""
}`}
>
{chats.map((chat) => (
<SidebarChatItem
key={chat.id}
chat={chat}
isActive={
routeChatId === chat.id
}
title={project.name}
projectName={
chat.project_id
? projectNames[
chat
.project_id
]
: undefined
}
onSelect={() => {
setCurrentChatId(
chat.id,
);
router.push(
chat.project_id
? `/projects/${chat.project_id}/assistant/chat/${chat.id}`
: `/assistant/chat/${chat.id}`,
);
}}
/>
))}
</div>
{hasMoreChats && (
<div className="px-2.5 pt-1">
<button
onClick={loadMoreChats}
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-200/60 text-gray-900"
: "text-gray-700 hover:bg-gray-100",
"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",
)}
>
<FolderOpen className="h-3.5 w-3.5 shrink-0 text-gray-600" />
<span className="min-w-0 flex-1 truncate">
{project.name}
</span>
Load more
</button>
);
})}
</div>
</div>
)}
</>
)}
</>
)}
</div>
</div>
</div>
)}
{/* Assistant History */}
<div className="flex min-h-0 flex-1 flex-col">
<button
onClick={() => setHistoryCollapsed((v) => !v)}
className={`mb-2 flex w-full items-center justify-between px-5 text-xs font-semibold text-gray-500 transition-colors hover:text-gray-700 ${
shouldAnimate ? "sidebar-fade-in" : ""
}`}
>
<span>Assistant History</span>
<ChevronDown
className={`h-3.5 w-3.5 transition-transform ${
historyCollapsed ? "-rotate-90" : ""
}`}
/>
</button>
<div
className={`overflow-y-auto flex-1 ${
historyCollapsed ? "hidden" : ""
}`}
>
{!chats ? (
<div className="space-y-1 px-2.5">
{[40, 60, 50, 70, 45].map((w, i) => (
<div
key={i}
className="h-9 flex items-center px-3 rounded-md"
>
<div
className="h-3 bg-gray-200 rounded animate-pulse"
style={{ width: `${w}%` }}
/>
</div>
))}
{/* User Profile */}
<div className="mt-auto p-1">
{user && (
<div className="relative">
<button
onClick={() =>
setIsDropdownOpen(!isDropdownOpen)
}
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-200/60"
: "hover:bg-gray-100",
)}
title={!isOpen ? user.email : undefined}
>
<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>
) : chats.length === 0 ? (
<div
className={`text-xs text-gray-500 py-2 px-5 ${
shouldAnimate ? "sidebar-fade-in-2" : ""
}`}
>
No chats yet
</div>
) : (
<>
{isOpen && (
<div
className={`space-y-1 px-2.5 ${
className={`text-left flex-1 min-w-0 pl-3 flex items-center justify-between gap-2 ${
shouldAnimate
? "sidebar-fade-in-2"
: ""
}`}
>
{chats.map((chat) => (
<SidebarChatItem
key={chat.id}
chat={chat}
isActive={
routeChatId === chat.id
}
projectName={
chat.project_id
? projectNames[
chat.project_id
]
: undefined
}
onSelect={() => {
setCurrentChatId(chat.id);
router.push(
chat.project_id
? `/projects/${chat.project_id}/assistant/chat/${chat.id}`
: `/assistant/chat/${chat.id}`,
);
}}
/>
))}
</div>
{hasMoreChats && (
<div className="px-2.5 pt-1">
<button
onClick={loadMoreChats}
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>
<div className="flex flex-col gap-0.5 min-w-0">
<div className="text-sm font-medium text-gray-900 leading-none">
{getDisplayName()}
</div>
<div className="text-[12px] text-gray-500 leading-none">
{getUserTier()}
</div>
</div>
)}
</>
)}
</div>
</div>
</div>
)}
<ChevronsUpDown className="h-4 w-4 flex-shrink-0 text-gray-400" />
</div>
)}
</button>
{/* User Profile */}
<div className="mt-auto p-1">
{user && (
<div className="relative">
<button
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
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-200/60"
: "hover:bg-gray-100",
)}
title={!isOpen ? user.email : undefined}
>
<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 && (
{isDropdownOpen && (
<div
className={`text-left flex-1 min-w-0 pl-3 flex items-center justify-between gap-2 ${
shouldAnimate ? "sidebar-fade-in-2" : ""
}`}
className={cn(
"absolute bottom-full left-0 z-50 mb-1 p-1 whitespace-nowrap",
isOpen ? "right-0" : "w-56",
"bg-white/80 rounded-xl shadow-[0_6px_17px_rgba(15,23,42,0.1)] border border-white/70 backdrop-blur-xl",
)}
>
<div className="flex flex-col gap-0.5 min-w-0">
<div className="text-sm font-medium text-gray-900 leading-none">
{getDisplayName()}
</div>
<div className="text-[12px] text-gray-500 leading-none">
{getUserTier()}
</div>
</div>
<ChevronsUpDown className="h-4 w-4 flex-shrink-0 text-gray-400" />
<button
onClick={() => {
router.push("/account");
setIsDropdownOpen(false);
}}
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
</button>
</div>
)}
</button>
{isDropdownOpen && (
<div
className={cn(
"absolute bottom-full left-0 z-50 mb-1 p-1 whitespace-nowrap",
isOpen ? "right-0" : "w-56",
"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={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
</button>
</div>
)}
</div>
)}
</div>
)}
</div>
</div>
</div>
</>
);
}

View file

@ -7,13 +7,13 @@ import {
ChevronRight,
Folder,
FolderOpen,
Trash2,
Loader2,
} from "lucide-react";
import type { Document, Project } from "./types";
import { VersionChip } from "./VersionChip";
import { FileTypeIcon } from "./FileTypeIcon";
import { SearchBar } from "@/components/ui/search-bar";
import { SearchBar } from "@/app/components/ui/search-bar";
import { ModalSegmentedToggle } from "../modals/ModalSegmentedToggle";
function formatDate(iso: string | null) {
if (!iso) return null;
@ -37,13 +37,12 @@ interface FileDirectoryProps {
allowMultiple?: boolean;
forceExpanded?: boolean;
emptyMessage?: string;
heading?: string;
onDelete?: (ids: string[]) => void | Promise<void>;
uploadingFilenames?: string[];
searchable?: boolean;
searchPlaceholder?: string;
searchAutoFocus?: boolean;
searchNoResultsMessage?: string;
showProjectTabs?: boolean;
}
export function FileDirectory({
@ -55,21 +54,21 @@ export function FileDirectory({
allowMultiple = true,
forceExpanded = false,
emptyMessage = "No documents yet",
heading = "Documents",
onDelete,
uploadingFilenames = [],
searchable = false,
searchPlaceholder = "Search...",
searchAutoFocus = false,
searchNoResultsMessage = "No matches found",
showProjectTabs,
}: FileDirectoryProps) {
const [expandedProjects, setExpandedProjects] = useState<Set<string>>(
new Set(),
);
const [deleting, setDeleting] = useState(false);
const [selectedTab, setSelectedTab] = useState<"files" | "projects">(
"files",
);
const [search, setSearch] = useState("");
const selectedCount = selectedIds.size;
const q = search.trim().toLowerCase();
const visibleStandaloneDocs = q
? standaloneDocs.filter((doc) => doc.filename.toLowerCase().includes(q))
@ -104,30 +103,22 @@ export function FileDirectory({
);
})
: directoryProjects;
async function handleDelete() {
if (!onDelete || selectedCount === 0 || deleting) return;
const ids = Array.from(selectedIds);
setDeleting(true);
try {
await onDelete(ids);
const next = new Set(selectedIds);
ids.forEach((id) => next.delete(id));
onChange(next);
} finally {
setDeleting(false);
}
}
const showTabs = showProjectTabs ?? directoryProjects.length > 0;
const activeTab = showTabs ? selectedTab : "files";
const hasVisibleFiles =
visibleStandaloneDocs.length > 0 ||
visibleUploadingFilenames.length > 0;
const hasVisibleProjects = visibleDirectoryProjects.length > 0;
const activeTabHasNoResults =
q &&
((activeTab === "files" && !hasVisibleFiles) ||
(activeTab === "projects" && !hasVisibleProjects));
const allDocs = [
...standaloneDocs,
...directoryProjects.flatMap((p) => p.documents ?? []),
];
const allStandaloneSelected =
visibleStandaloneDocs.length > 0 &&
visibleStandaloneDocs.every((d) => selectedIds.has(d.id));
function toggle(docId: string) {
if (!allowMultiple) {
onChange(new Set([docId]));
@ -142,18 +133,6 @@ export function FileDirectory({
onChange(next);
}
function toggleAll() {
if (allStandaloneSelected) {
const next = new Set(selectedIds);
visibleStandaloneDocs.forEach((d) => next.delete(d.id));
onChange(next);
} else {
const next = new Set(selectedIds);
visibleStandaloneDocs.forEach((d) => next.add(d.id));
onChange(next);
}
}
function toggleFolder(projectId: string) {
if (forceExpanded) return;
setExpandedProjects((prev) => {
@ -169,23 +148,23 @@ export function FileDirectory({
if (loading) {
return (
<div className="space-y-px">
<div className="flex min-h-0 flex-1 flex-col space-y-2">
{searchable && (
<SearchBar
value={search}
onValueChange={setSearch}
placeholder={searchPlaceholder}
autoFocus={searchAutoFocus}
wrapperClassName="mb-2"
wrapperClassName={showTabs ? "mb-4" : "mb-3"}
/>
)}
{/* Documents header skeleton */}
<div className="flex items-center justify-between rounded-md px-2 py-2">
<div className="h-3 w-20 rounded bg-gray-100 animate-pulse" />
<div className="h-3 w-12 rounded bg-gray-100 animate-pulse" />
</div>
{/* File rows skeleton */}
<div>
{showTabs && (
<FileDirectoryTabs
activeTab={activeTab}
onChange={setSelectedTab}
/>
)}
<div className="min-h-0 flex-1 overflow-y-auto">
{[60, 45, 75, 55, 40].map((w, i) => (
<div
key={i}
@ -210,255 +189,269 @@ export function FileDirectory({
uploadingFilenames.length === 0
) {
return (
<div>
<div className="flex min-h-0 flex-1 flex-col space-y-2">
{searchable && (
<SearchBar
value={search}
onValueChange={setSearch}
placeholder={searchPlaceholder}
autoFocus={searchAutoFocus}
wrapperClassName="mb-2"
wrapperClassName={showTabs ? "mb-4" : "mb-3"}
/>
)}
<p className="text-center text-sm text-gray-400 py-8">
{emptyMessage}
</p>
{showTabs && (
<FileDirectoryTabs
activeTab={activeTab}
onChange={setSelectedTab}
/>
)}
<div className="min-h-0 flex-1 overflow-y-auto">
<p className="text-center text-sm text-gray-400 py-8">
{emptyMessage}
</p>
</div>
</div>
);
}
return (
<div className="space-y-2 rounded-sm">
<div className="flex min-h-0 flex-1 flex-col space-y-2 rounded-sm">
{searchable && (
<SearchBar
value={search}
onValueChange={setSearch}
placeholder={searchPlaceholder}
autoFocus={searchAutoFocus}
wrapperClassName={showTabs ? "mb-4" : "mb-3"}
/>
)}
{q &&
visibleStandaloneDocs.length === 0 &&
visibleDirectoryProjects.length === 0 &&
visibleUploadingFilenames.length === 0 ? (
<p className="text-center text-sm text-gray-400 py-8">
{searchNoResultsMessage}
</p>
{showTabs && (
<FileDirectoryTabs
activeTab={activeTab}
onChange={setSelectedTab}
/>
)}
{activeTabHasNoResults ? (
<div className="min-h-0 flex-1 overflow-y-auto">
<p className="text-center text-sm text-gray-400 py-8">
{searchNoResultsMessage}
</p>
</div>
) : (
<div>
{(visibleStandaloneDocs.length > 0 ||
visibleUploadingFilenames.length > 0 ||
(onDelete && selectedCount > 0)) && (
<div className="flex items-center justify-between px-2 py-2">
<p className="text-xs font-medium text-gray-400">
{heading}
</p>
<div className="flex items-center gap-3">
{onDelete && selectedCount > 0 && (
<button
type="button"
onClick={handleDelete}
disabled={deleting}
className="inline-flex items-center gap-1 text-xs text-red-500 hover:text-red-700 transition-colors disabled:opacity-50"
>
<Trash2 className="h-3 w-3" />
Delete
</button>
)}
{visibleStandaloneDocs.length > 0 && (
<button
type="button"
onClick={toggleAll}
className="text-xs text-gray-400 hover:text-gray-600 transition-colors"
>
{allStandaloneSelected
? "Deselect all"
: "Select all"}
</button>
)}
</div>
</div>
)}
{visibleUploadingFilenames.map((filename) => (
<div
key={`uploading-${filename}`}
className="w-full flex items-center gap-2 px-2 py-2 text-xs text-left"
>
<span className="shrink-0 h-3.5 w-3.5 rounded border border-gray-300" />
<Loader2 className="h-3.5 w-3.5 animate-spin text-gray-400 shrink-0" />
<span className="flex-1 truncate text-gray-400">
{filename}
</span>
<span className="shrink-0 text-gray-300">
Uploading
</span>
</div>
))}
{visibleStandaloneDocs.map((doc) => {
const selected = selectedIds.has(doc.id);
return (
<button
type="button"
key={doc.id}
onClick={() => toggle(doc.id)}
className={`w-full rounded-md flex items-center gap-2 px-2 py-2 text-xs transition-all text-left ${
selected
? "bg-gray-100"
: "hover:bg-gray-100/70"
}`}
>
<span
className={`shrink-0 h-3.5 w-3.5 rounded border flex items-center justify-center ${
selected
? "bg-gray-900 border-gray-900"
: "border-gray-300"
}`}
<div className="min-h-0 flex-1 overflow-y-auto">
{activeTab === "files" && (
<>
{visibleUploadingFilenames.map((filename) => (
<div
key={`uploading-${filename}`}
className="w-full flex items-center gap-2 px-2 py-2 text-xs text-left"
>
{selected && (
<Check className="h-2.5 w-2.5 text-white" />
)}
</span>
<DocFileIcon fileType={doc.file_type} />
<span
className={`flex-1 truncate ${
selected
? "text-gray-900"
: "text-gray-700"
}`}
>
{doc.filename}
</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 className="shrink-0 h-3.5 w-3.5 rounded border border-gray-300" />
<Loader2 className="h-3.5 w-3.5 animate-spin text-gray-400 shrink-0" />
<span className="flex-1 truncate text-gray-400">
{filename}
</span>
)}
</button>
);
})}
{visibleStandaloneDocs.length > 0 &&
visibleDirectoryProjects.length > 0 && (
<div className="py-2 px-2 mt-2">
<p className="text-xs font-medium text-gray-400">
Projects
</p>
</div>
)}
{visibleDirectoryProjects.map((project) => {
const isExpanded =
forceExpanded ||
!!q ||
expandedProjects.has(project.id);
const docs = project.documents ?? [];
return (
<div key={project.id}>
<button
type="button"
onClick={() => toggleFolder(project.id)}
className="w-full rounded-md flex items-center gap-2 px-2 py-2 text-xs transition-all text-left hover:bg-gray-100/70"
>
{isExpanded ? (
<ChevronDown className="h-3 w-3 text-gray-400 shrink-0" />
) : (
<ChevronRight className="h-3 w-3 text-gray-400 shrink-0" />
)}
{isExpanded ? (
<FolderOpen className="h-3.5 w-3.5 shrink-0 text-gray-400" />
) : (
<Folder className="h-3.5 w-3.5 shrink-0 text-gray-400" />
)}
<span className="flex-1 truncate font-medium text-gray-700">
{project.name}
{project.cm_number && (
<span className="ml-1 font-normal text-gray-400">
(#{project.cm_number})
<span className="shrink-0 text-gray-300">
Uploading
</span>
</div>
))}
{visibleStandaloneDocs.map((doc) => {
const selected = selectedIds.has(doc.id);
return (
<button
type="button"
key={doc.id}
onClick={() => toggle(doc.id)}
className={`w-full rounded-md flex items-center gap-2 px-2 py-2 text-xs transition-all text-left ${
selected
? "bg-gray-100"
: "hover:bg-gray-100/70"
}`}
>
<span
className={`shrink-0 h-3.5 w-3.5 rounded border flex items-center justify-center ${
selected
? "bg-gray-900 border-gray-900"
: "border-gray-300"
}`}
>
{selected && (
<Check className="h-2.5 w-2.5 text-white" />
)}
</span>
<DocFileIcon fileType={doc.file_type} />
<span
className={`flex-1 truncate ${
selected
? "text-gray-900"
: "text-gray-700"
}`}
>
{doc.filename}
</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>
)}
</span>
<span className="text-xs text-gray-400 shrink-0">
{docs.length}
</span>
</button>
{isExpanded && (
<div>
{docs.length === 0 ? (
<p className="pl-7 py-1 text-xs text-gray-400">
Empty
</p>
</button>
);
})}
{!q &&
visibleStandaloneDocs.length === 0 &&
visibleUploadingFilenames.length === 0 && (
<p className="text-center text-sm text-gray-400 py-8">
{emptyMessage}
</p>
)}
</>
)}
{activeTab === "projects" &&
visibleDirectoryProjects.map((project) => {
const isExpanded =
forceExpanded ||
!!q ||
expandedProjects.has(project.id);
const docs = project.documents ?? [];
return (
<div key={project.id}>
<button
type="button"
onClick={() =>
toggleFolder(project.id)
}
className="w-full rounded-md flex items-center gap-2 px-2 py-2 text-xs transition-all text-left hover:bg-gray-100/70"
>
{isExpanded ? (
<ChevronDown className="h-3 w-3 text-gray-400 shrink-0" />
) : (
docs.map((doc) => {
const selected =
selectedIds.has(doc.id);
return (
<button
type="button"
key={doc.id}
onClick={() =>
toggle(doc.id)
}
className={`w-full rounded-md flex items-center gap-2 pl-7 pr-2 py-2 text-xs transition-all text-left ${
selected
? "bg-gray-100"
: "hover:bg-gray-100/70"
}`}
>
<span
className={`shrink-0 h-3.5 w-3.5 rounded border flex items-center justify-center ${
<ChevronRight className="h-3 w-3 text-gray-400 shrink-0" />
)}
{isExpanded ? (
<FolderOpen className="h-3.5 w-3.5 shrink-0 text-gray-400" />
) : (
<Folder className="h-3.5 w-3.5 shrink-0 text-gray-400" />
)}
<span className="flex-1 truncate font-medium text-gray-700">
{project.name}
{project.cm_number && (
<span className="ml-1 font-normal text-gray-400">
(#{project.cm_number})
</span>
)}
</span>
<span className="text-xs text-gray-400 shrink-0">
{docs.length}
</span>
</button>
{isExpanded && (
<div>
{docs.length === 0 ? (
<p className="pl-7 py-1 text-xs text-gray-400">
Empty
</p>
) : (
docs.map((doc) => {
const selected =
selectedIds.has(doc.id);
return (
<button
type="button"
key={doc.id}
onClick={() =>
toggle(doc.id)
}
className={`w-full rounded-md flex items-center gap-2 pl-7 pr-2 py-2 text-xs transition-all text-left ${
selected
? "bg-gray-900 border-gray-900"
: "border-gray-300"
? "bg-gray-100"
: "hover:bg-gray-100/70"
}`}
>
{selected && (
<Check className="h-2.5 w-2.5 text-white" />
)}
</span>
<DocFileIcon
fileType={
doc.file_type
}
/>
<span
className={`flex-1 truncate min-w-0 ${
selected
? "text-gray-900"
: "text-gray-700"
}`}
>
{doc.filename}
</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
className={`shrink-0 h-3.5 w-3.5 rounded border flex items-center justify-center ${
selected
? "bg-gray-900 border-gray-900"
: "border-gray-300"
}`}
>
{selected && (
<Check className="h-2.5 w-2.5 text-white" />
)}
</span>
)}
</button>
);
})
)}
</div>
)}
</div>
);
})}
<DocFileIcon
fileType={
doc.file_type
}
/>
<span
className={`flex-1 truncate min-w-0 ${
selected
? "text-gray-900"
: "text-gray-700"
}`}
>
{doc.filename}
</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>
)}
</div>
);
})}
{activeTab === "projects" &&
!q &&
visibleDirectoryProjects.length === 0 && (
<p className="text-center text-sm text-gray-400 py-8">
No projects yet
</p>
)}
</div>
)}
</div>
);
}
function FileDirectoryTabs({
activeTab,
onChange,
}: {
activeTab: "files" | "projects";
onChange: (tab: "files" | "projects") => void;
}) {
return (
<ModalSegmentedToggle
value={activeTab}
onChange={onChange}
options={[
{ value: "files", label: "Files" },
{ value: "projects", label: "Projects" },
]}
size="sm"
className="self-start"
/>
);
}

View file

@ -6,8 +6,8 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
} from "@/app/components/ui/dropdown-menu";
import { cn } from "@/app/lib/utils";
export type HeaderActionsMenuItem = {
label: string;

View file

@ -2,8 +2,8 @@
import { useEffect, useState, type ReactNode } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useAuth } from "@/contexts/AuthContext";
import { useUserProfile } from "@/contexts/UserProfileContext";
import { useAuth } from "@/app/contexts/AuthContext";
import { useUserProfile } from "@/app/contexts/UserProfileContext";
import { needsMfaVerification } from "../popups/MfaVerificationPopup";
type GateState = "idle" | "checking" | "required" | "verified";

View file

@ -12,7 +12,7 @@ import {
import { createPortal } from "react-dom";
import { ChevronLeft, Loader2, Plus, Search, Trash2 } from "lucide-react";
import { usePageChrome } from "@/app/contexts/PageChromeContext";
import { cn } from "@/lib/utils";
import { cn } from "@/app/lib/utils";
export interface PageHeaderBreadcrumb {
label?: ReactNode;

View file

@ -7,12 +7,12 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
} from "@/app/components/ui/dropdown-menu";
import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext";
import { useAuth } from "@/contexts/AuthContext";
import { useAuth } from "@/app/contexts/AuthContext";
import { OwnerOnlyPopup } from "@/app/components/popups/OwnerOnlyPopup";
import type { Chat } from "@/app/components/shared/types";
import { cn } from "@/lib/utils";
import { cn } from "@/app/lib/utils";
interface Props {
chat: Chat;

View file

@ -8,7 +8,7 @@ import {
type MouseEvent,
type ReactNode,
} from "react";
import { cn } from "@/lib/utils";
import { cn } from "@/app/lib/utils";
import {
CLOSE_ROW_ACTIONS_EVENT,
closeRowActionMenus,

View file

@ -590,16 +590,20 @@ export interface WorkflowContributor {
export interface Workflow {
id: string;
user_id: string | null;
title: string;
type: "assistant" | "tabular";
prompt_md: string | null;
metadata: {
title: string;
description: string | null;
type: "assistant" | "tabular";
contributors: WorkflowContributor[];
language: string;
version: string | null;
practice: string | null;
jurisdictions: string[] | null;
};
skill_md: string | null;
columns_config: ColumnConfig[] | null;
is_system: boolean;
created_at: string;
language?: string | null;
version?: string | null;
practice?: string | null;
jurisdictions?: string[] | null;
shared_by_name?: string | null;
allow_edit?: boolean;
is_owner?: boolean;

View file

@ -0,0 +1,503 @@
"use client";
import { useEffect, useMemo, useRef } from "react";
import { Loader2 } from "lucide-react";
import { useFetchDocxBytes } from "@/app/hooks/useFetchDocxBytes";
import { supabase } from "@/app/lib/supabase";
import {
clearDocxQuoteHighlights,
highlightDocxQuote,
} from "./highlightDocxQuote";
import type { CitationQuote } from "../types";
interface Props {
documentId: string;
versionId?: string | null;
/**
* Called once the document has been rendered to the DOM. Handy for
* scrolling to a particular tracked change after a re-render.
*/
onReady?: () => void;
/**
* Tracked-change to scroll to + briefly flash after each render. The
* `key` is used to re-trigger scrolling when the same edit is clicked
* twice in a row.
*/
highlightEdit?: {
key: string;
inserted_text?: string;
deleted_text?: string;
/**
* Numeric w:id values of the <w:ins>/<w:del> wrappers in
* document.xml. Preferred over text matching uniquely identifies
* the right DOM element even when multiple edits share identical
* inserted/deleted text. `docx-preview` drops these during parsing,
* so we re-tag each rendered <ins>/<del> with data-w-id after load.
*/
ins_w_id?: string | null;
del_w_id?: string | null;
} | null;
/**
* Forces a byte re-fetch when it changes, even if documentId/versionId
* are stable. Used after accept/reject: the backend overwrites bytes at
* the same storage path (no new version row), so the hook has no other
* signal that the file changed.
*/
refetchKey?: number;
/**
* Citation quotes to highlight in the rendered output. The first match
* is scrolled into view. Page numbers are ignored DOCX has no explicit
* 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").
*/
warning?: string | null;
/**
* Called when the user dismisses the warning banner.
*/
onWarningDismiss?: () => void;
/**
* Scroll position to restore after the first render used by parents
* that track per-tab scroll and want to re-enter at the same spot.
* Null/undefined means "no override" (preserve the pre-render scroll).
*/
initialScrollTop?: number | null;
/**
* Fires on scroll (throttled by rAF) so the parent can persist the
* current scrollTop against its tab state.
*/
onScrollChange?: (scrollTop: number) => void;
rounded?: boolean;
}
function findEditElement(
root: HTMLElement,
tag: "ins" | "del",
opts: { w_id?: string | null; text?: string },
): HTMLElement | null {
if (opts.w_id) {
const byId = root.querySelector(
`${tag}[data-w-id="${CSS.escape(opts.w_id)}"]`,
) as HTMLElement | null;
if (byId) return byId;
}
const text = opts.text ?? "";
const normalize = (s: string) => s.replace(/\s+/g, " ").trim();
const target = normalize(text);
if (!target) return null;
const candidates = Array.from(root.querySelectorAll(tag)) as HTMLElement[];
return (
candidates.find((el) => normalize(el.textContent ?? "") === target) ??
candidates.find((el) =>
normalize(el.textContent ?? "").includes(target),
) ??
null
);
}
function scrollToHighlight(
container: HTMLElement,
scrollEl: HTMLElement,
edit: {
inserted_text?: string;
deleted_text?: string;
ins_w_id?: string | null;
del_w_id?: string | null;
},
) {
const insEl = findEditElement(container, "ins", {
w_id: edit.ins_w_id,
text: edit.inserted_text,
});
const delEl = findEditElement(container, "del", {
w_id: edit.del_w_id,
text: edit.deleted_text,
});
const anchor = insEl ?? delEl;
if (!anchor) return;
const scrollRect = scrollEl.getBoundingClientRect();
const targetRect = anchor.getBoundingClientRect();
const offset = targetRect.top - scrollRect.top + scrollEl.scrollTop - 80;
scrollEl.scrollTo({ top: Math.max(0, offset), behavior: "smooth" });
const flashed = [insEl, delEl].filter((el): el is HTMLElement => !!el);
flashed.forEach((el) => el.classList.add("docx-edit-flash"));
window.setTimeout(() => {
flashed.forEach((el) => el.classList.remove("docx-edit-flash"));
}, 2000);
}
/**
* Fetch the ordered list of w:ids for every w:ins/w:del in the current
* version and tag each rendered <ins>/<del> with data-w-id. The backend
* returns ids in document order, and docx-preview emits <ins>/<del>
* in the same order, so we can align by index.
*/
async function tagWIdsOnRenderedDom(
container: HTMLElement,
documentId: string,
versionId: string | null | undefined,
): Promise<void> {
try {
const {
data: { session },
} = await supabase.auth.getSession();
const token = session?.access_token;
const apiBase =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3001";
const qs = versionId
? `?version_id=${encodeURIComponent(versionId)}`
: "";
const resp = await fetch(
`${apiBase}/single-documents/${documentId}/tracked-change-ids${qs}`,
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
);
if (!resp.ok) {
console.warn(
"[DocxView] tracked-change-ids fetch failed",
resp.status,
);
return;
}
const data = (await resp.json()) as {
ids: { kind: "ins" | "del"; w_id: string }[];
};
const domEls = Array.from(
container.querySelectorAll("ins, del"),
) as HTMLElement[];
const ids = data.ids ?? [];
let tagged = 0;
let mismatched = 0;
for (let i = 0; i < Math.min(domEls.length, ids.length); i++) {
const el = domEls[i];
const info = ids[i];
if (el.tagName.toLowerCase() !== info.kind) {
mismatched++;
continue;
}
el.setAttribute("data-w-id", info.w_id);
tagged++;
}
} catch (e) {
console.warn("[DocxView] tagWIdsOnRenderedDom failed", e);
}
}
/**
* Renders a .docx in the browser using `docx-preview`. Tracked changes
* (`w:ins` / `w:del`) show up automatically with coloured strike/underline
* styling. Scroll position is preserved across re-renders so Accept/Reject
* doesn't jump the user back to the top.
*/
export function DocxView({
documentId,
versionId,
onReady,
highlightEdit,
refetchKey,
quotes,
quoteFocusKey,
warning,
onWarningDismiss,
initialScrollTop,
onScrollChange,
rounded = true,
}: Props) {
const scrollRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const lastScrollTopRef = useRef(0);
const renderKeyRef = useRef(0);
// Ref-stabilize onReady and highlightEdit so the render effect only
// re-fires when `bytes` actually change. Without this, any parent
// re-render (e.g. clicking a new highlight) creates a new onReady
// identity, triggers a full re-render, and snaps scroll back to top.
const onReadyRef = useRef(onReady);
onReadyRef.current = onReady;
const highlightEditRef = useRef(highlightEdit);
highlightEditRef.current = highlightEdit;
const quotesRef = useRef(quotes);
quotesRef.current = quotes;
const initialScrollTopRef = useRef(initialScrollTop ?? null);
initialScrollTopRef.current = initialScrollTop ?? null;
const onScrollChangeRef = useRef(onScrollChange);
onScrollChangeRef.current = onScrollChange;
// Stable key for the quote list so the re-highlight effect re-fires
// only when the actual text/order of quotes changes.
const quoteKey = useMemo(
() => (quotes ?? []).map((q) => q.quote).join("||"),
[quotes],
);
const { bytes, loading, error } = useFetchDocxBytes(
documentId,
versionId,
refetchKey,
);
/**
* Highlight every quote in `list` inside the rendered DOM and scroll
* the first match into view. Returns true if any match was found.
*/
const applyQuoteHighlights = (
containerEl: HTMLElement,
scrollEl: HTMLElement,
list: CitationQuote[] | undefined,
): boolean => {
clearDocxQuoteHighlights(containerEl);
if (!list || list.length === 0) return false;
let firstMatch: HTMLElement | null = null;
for (const q of list) {
const match = highlightDocxQuote(containerEl, q.quote);
if (match && !firstMatch) firstMatch = match;
}
if (!firstMatch) return false;
const scrollRect = scrollEl.getBoundingClientRect();
const targetRect = firstMatch.getBoundingClientRect();
const offset =
targetRect.top -
scrollRect.top +
scrollEl.scrollTop -
scrollEl.clientHeight / 2 +
targetRect.height / 2;
scrollEl.scrollTo({
top: Math.max(0, offset),
behavior: "instant" as ScrollBehavior,
});
return true;
};
/**
* docx-preview renders pages at their natural Word page width (e.g.
* ~816px for US Letter). When the side-panel is narrower than that,
* the page overflows horizontally. Apply CSS `zoom` on each
* section.docx so the document shrinks to fit `zoom` (unlike
* `transform: scale`) also shrinks the layout box, so the scroll
* container's scrollHeight adapts. We zoom each page rather than the
* wrapper because docx-preview injects flex styles on `.docx-wrapper`
* that can interfere with wrapper-level zoom.
*/
const applyDocxScale = () => {
const containerEl = containerRef.current;
const scrollEl = scrollRef.current;
if (!containerEl || !scrollEl) return;
const wrapper = containerEl.querySelector<HTMLElement>(".docx-wrapper");
if (!wrapper) return;
const sections = Array.from(
wrapper.querySelectorAll<HTMLElement>("section.docx"),
);
if (sections.length === 0) return;
// Reset zoom on every page before measuring so offsetWidth reports
// each page's natural width (pages can have different widths — e.g.
// mixed portrait/landscape sections).
sections.forEach((s) => {
s.style.zoom = "1";
});
// Use the scroll container's content box (clientWidth - padding)
// as the available width.
const styles = window.getComputedStyle(scrollEl);
const padX =
(parseFloat(styles.paddingLeft) || 0) +
(parseFloat(styles.paddingRight) || 0);
const available = scrollEl.clientWidth - padX;
if (available <= 0) return;
// Scale each page independently against its own natural width so
// landscape/custom-size pages still fit without distorting the
// page dividers.
sections.forEach((s) => {
const w = s.offsetWidth;
if (!w) return;
const scale = Math.min(1, available / w);
s.style.zoom = String(scale);
});
};
// Observe the scroll container (which tracks the side panel's width)
// and re-scale whenever it resizes. Also observe the docx container so
// we re-scale once docx-preview finishes inserting pages.
useEffect(() => {
const scrollEl = scrollRef.current;
const containerEl = containerRef.current;
if (!scrollEl || !containerEl) return;
let raf = 0;
const schedule = () => {
if (raf) cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => applyDocxScale());
};
const ro = new ResizeObserver(schedule);
ro.observe(scrollEl);
ro.observe(containerEl);
return () => {
if (raf) cancelAnimationFrame(raf);
ro.disconnect();
};
}, []);
useEffect(() => {
let cancelled = false;
if (!bytes || !containerRef.current || !scrollRef.current) return;
const scrollEl = scrollRef.current;
const containerEl = containerRef.current;
// Remember scroll position across re-renders so Accept/Reject stays put.
lastScrollTopRef.current = scrollEl.scrollTop;
const thisRender = ++renderKeyRef.current;
(async () => {
try {
const { renderAsync } = await import("docx-preview");
if (cancelled) return;
containerEl.innerHTML = "";
await renderAsync(bytes, containerEl, undefined, {
inWrapper: true,
ignoreWidth: false,
ignoreHeight: false,
renderChanges: true,
experimental: true,
});
if (cancelled) return;
await tagWIdsOnRenderedDom(
containerEl,
documentId,
versionId ?? null,
);
if (cancelled) return;
// Scale to fit before scrolling so offsets are computed
// against the post-zoom layout.
applyDocxScale();
requestAnimationFrame(() => {
if (
!scrollRef.current ||
thisRender !== renderKeyRef.current
)
return;
const pendingHighlight = highlightEditRef.current;
const pendingQuotes = quotesRef.current;
const pendingInitialScroll = initialScrollTopRef.current;
if (pendingHighlight) {
scrollToHighlight(
containerEl,
scrollRef.current,
pendingHighlight,
);
// Highlight quotes too, but don't override the edit scroll
if (pendingQuotes?.length) {
for (const q of pendingQuotes)
highlightDocxQuote(containerEl, q.quote);
}
} else if (
pendingQuotes &&
applyQuoteHighlights(
containerEl,
scrollRef.current,
pendingQuotes,
)
) {
// scrolled inside applyQuoteHighlights
} else if (typeof pendingInitialScroll === "number") {
scrollRef.current.scrollTop = pendingInitialScroll;
} else {
scrollRef.current.scrollTop = lastScrollTopRef.current;
}
onReadyRef.current?.();
});
} catch (e) {
console.error("docx-preview render failed", e);
}
})();
return () => {
cancelled = true;
};
}, [bytes]);
// Re-scroll/highlight if the target edit changes without a re-render
// (e.g. same doc, different edit clicked).
useEffect(() => {
if (!highlightEdit || !containerRef.current || !scrollRef.current)
return;
scrollToHighlight(
containerRef.current,
scrollRef.current,
highlightEdit,
);
}, [highlightEdit?.key]); // eslint-disable-line react-hooks/exhaustive-deps
// Re-apply quote highlights when the quote list changes without a full
// re-render (e.g. clicking a different citation on the same doc).
useEffect(() => {
if (!containerRef.current || !scrollRef.current) return;
applyQuoteHighlights(
containerRef.current,
scrollRef.current,
quotesRef.current,
);
}, [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
// re-renders (Accept/Reject preserving scroll within one view).
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
let scheduled = false;
const onScroll = () => {
lastScrollTopRef.current = el.scrollTop;
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => {
scheduled = false;
onScrollChangeRef.current?.(el.scrollTop);
});
};
el.addEventListener("scroll", onScroll, { passive: true });
return () => el.removeEventListener("scroll", onScroll);
}, []);
return (
<div
className={`relative flex flex-col flex-1 overflow-hidden bg-gray-100 ${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">
<span>{warning}</span>
<button
type="button"
onClick={() => onWarningDismiss?.()}
className="text-amber-600 hover:text-amber-900"
aria-label="Dismiss warning"
>
×
</button>
</div>
)}
<div
ref={scrollRef}
className="flex-1 overflow-auto px-5 pt-5 pb-3 docx-view-scroll"
data-document-id={documentId}
data-version-id={versionId ?? ""}
>
{loading && !bytes && (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-7 w-7 animate-spin text-gray-400" />
</div>
)}
{error && (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-red-500">{error}</p>
</div>
)}
<div ref={containerRef} className="docx-view-container" />
</div>
</div>
);
}

View file

@ -0,0 +1,598 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Loader2, ZoomIn, ZoomOut } from "lucide-react";
import { useFetchSingleDoc } from "@/app/hooks/useFetchSingleDoc";
import { DocxView } from "./DocxView";
import type { CitationQuote } from "../types";
import {
clearHighlights,
getPdfJs,
highlightQuote,
STANDARD_FONT_DATA_URL,
} from "./highlightQuote";
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;
rounded?: boolean;
}
type QuoteEntry = { page?: number; quote: string };
const SIDE_PADDING = 20;
const ZOOM_MIN = 0.5;
const ZOOM_MAX = 3.0;
const ZOOM_STEP = 0.25;
type RenderedPage = {
page: import("pdfjs-dist").PDFPageProxy;
viewport: import("pdfjs-dist").PageViewport;
wrapper: HTMLDivElement;
canvas: HTMLCanvasElement;
textDivs: HTMLElement[];
};
export function PdfView({
doc,
quotes,
quoteFocusKey,
quote,
fallbackPage,
rounded = true,
}: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const pdfDocRef = useRef<import("pdfjs-dist").PDFDocumentProxy | null>(
null,
);
const renderedPagesRef = useRef<RenderedPage[]>([]);
const quoteListRef = useRef<QuoteEntry[]>([]);
const zoomRef = useRef(1.0);
const currentPageRef = useRef(1);
const quoteList: QuoteEntry[] = useMemo(() => {
if (quotes?.length)
return quotes.map((q) => ({ page: q.page, quote: q.quote }));
if (quote) return [{ page: fallbackPage, quote }];
return [];
}, [quotes, quote, fallbackPage]);
// Stable string key so effects can depend on quote-list identity
const quoteKey = quoteList
.map((q) => `${q.page ?? ""}:${q.quote}`)
.join("|");
const [containerWidth, setContainerWidth] = useState(0);
const [zoom, setZoom] = useState(1.0);
const [currentPage, setCurrentPage] = useState(1);
const [numPages, setNumPages] = useState(0);
const { result, loading, error } = useFetchSingleDoc(
doc?.document_id ?? null,
doc?.version_id ?? null,
);
// /display returned DOCX bytes — the active version has no PDF
// rendition, so fall back to docx-preview (still applies citation
// highlighting via the same `quotes` API).
const fallbackToDocx = result?.type === "docx";
// Track container width via ResizeObserver so re-renders fire on resize
useEffect(() => {
const el = scrollContainerRef.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
setContainerWidth(entries[0]?.contentRect.width ?? 0);
});
ro.observe(el);
return () => ro.disconnect();
}, []);
// Track current page via scroll position
useEffect(() => {
const scrollEl = scrollContainerRef.current;
if (!scrollEl) return;
const handleScroll = () => {
const pages = renderedPagesRef.current;
if (!pages.length) return;
const scrollCenter = scrollEl.scrollTop + scrollEl.clientHeight / 2;
let closest = 0;
let closestDist = Infinity;
pages.forEach((p, i) => {
const pageCenter =
p.wrapper.offsetTop + p.wrapper.clientHeight / 2;
const dist = Math.abs(pageCenter - scrollCenter);
if (dist < closestDist) {
closestDist = dist;
closest = i;
}
});
currentPageRef.current = closest + 1;
setCurrentPage(closest + 1);
};
scrollEl.addEventListener("scroll", handleScroll, { passive: true });
return () => scrollEl.removeEventListener("scroll", handleScroll);
}, []);
// Highlights all entries in `list` across the already-rendered pages.
// Returns the 1-based page number of the first successfully highlighted entry, or null.
const applyHighlights = useCallback(
async (list: QuoteEntry[]): Promise<number | null> => {
// Clear any prior highlights across all pages
for (const p of renderedPagesRef.current)
clearHighlights(p.textDivs);
let firstHitPage: number | null = null;
for (const entry of list) {
let hitPage: number | null = null;
if (entry.page) {
const target = renderedPagesRef.current[entry.page - 1];
if (target) {
const found = await highlightQuote(
target.textDivs,
entry.quote,
);
if (found) hitPage = entry.page;
}
}
// Fall back to scanning all pages for this quote
if (hitPage === null) {
console.warn(
`Quote not found on hinted page, scanning all pages: "${entry.quote.slice(0, 60)}..."`,
);
for (let i = 0; i < renderedPagesRef.current.length; i++) {
const p = renderedPagesRef.current[i];
const found = await highlightQuote(
p.textDivs,
entry.quote,
);
if (found) {
hitPage = i + 1;
break;
}
}
}
if (hitPage !== null && firstHitPage === null) {
firstHitPage = hitPage;
}
}
return firstHitPage;
},
[],
);
const renderPDF = useCallback(
async (
doc: import("pdfjs-dist").PDFDocumentProxy,
list: QuoteEntry[],
scrollToPage?: number,
) => {
if (!containerRef.current) return;
containerRef.current.innerHTML = "";
renderedPagesRef.current = [];
const lib = await getPdfJs();
lib.TextLayer.cleanup();
setNumPages(doc.numPages);
setCurrentPage(1);
currentPageRef.current = 1;
const hasCitation = list.length > 0;
if (hasCitation && scrollContainerRef.current) {
scrollContainerRef.current.style.opacity = "0";
}
const reveal = () => {
if (scrollContainerRef.current)
scrollContainerRef.current.style.opacity = "1";
};
const panelW = containerRef.current.clientWidth;
const firstPage = await doc.getPage(1);
const naturalWidth = firstPage.getViewport({ scale: 1 }).width;
const baseScale = Math.max(
0.5,
(panelW - SIDE_PADDING) / naturalWidth,
);
const scale = baseScale * zoomRef.current;
for (let pageNum = 1; pageNum <= doc.numPages; pageNum++) {
const page = await doc.getPage(pageNum);
const viewport = page.getViewport({ scale });
const wrapper = document.createElement("div");
wrapper.style.position = "relative";
wrapper.style.margin = "0 auto 8px";
wrapper.style.width = "fit-content";
wrapper.className = "shadow-md";
const canvas = document.createElement("canvas");
canvas.width = viewport.width;
canvas.height = viewport.height;
canvas.style.display = "block";
wrapper.appendChild(canvas);
containerRef.current?.appendChild(wrapper);
const ctx = canvas.getContext("2d");
if (!ctx) continue;
const task = page.render({ canvasContext: ctx, viewport });
try {
await task.promise;
} catch (e: unknown) {
if (
(e as { name?: string })?.name !==
"RenderingCancelledException"
) {
console.error("PDF render error", e);
}
continue;
}
const textLayerDiv = document.createElement("div");
textLayerDiv.className = "pdf-text-layer";
textLayerDiv.style.position = "absolute";
textLayerDiv.style.left = "0";
textLayerDiv.style.top = "0";
textLayerDiv.style.width = `${viewport.width}px`;
textLayerDiv.style.height = `${viewport.height}px`;
textLayerDiv.style.setProperty("--scale-factor", String(scale));
wrapper.appendChild(textLayerDiv);
const textLayer = new lib.TextLayer({
textContentSource: page.streamTextContent(),
container: textLayerDiv,
viewport,
});
await textLayer.render();
const textDivs = textLayer.textDivs;
renderedPagesRef.current.push({
page,
viewport,
wrapper,
canvas,
textDivs,
});
}
// Apply highlights across all entries, then scroll to the first hit.
let targetPage: number | null = null;
if (list.length) {
targetPage = await applyHighlights(list);
if (targetPage === null) {
// Fallback: scroll to the first entry's page hint, even without a highlight
const hint = list.find((e) => e.page)?.page ?? null;
targetPage = hint;
}
}
if (targetPage && targetPage >= 1) {
scrollToHighlightOnPage(targetPage);
} else if (!hasCitation && scrollToPage && scrollToPage > 1) {
// Restore scroll position after zoom re-render
const pageEntry = renderedPagesRef.current[scrollToPage - 1];
if (pageEntry)
pageEntry.wrapper.scrollIntoView({
behavior: "instant" as ScrollBehavior,
block: "start",
});
}
reveal();
},
[applyHighlights],
);
// Scroll so the first highlight on `pageNum` lands at the vertical center
// of the viewer. We compute the scroll position explicitly on the scroll
// container — calling `scrollIntoView` on a child of the absolutely-
// positioned text layer can scroll just the overlay while leaving the
// canvas untouched, which is why we don't use it here.
function scrollToHighlightOnPage(pageNum: number) {
const pageEntry = renderedPagesRef.current[pageNum - 1];
const scrollEl = scrollContainerRef.current;
if (!pageEntry || !scrollEl) return;
const highlightEl = pageEntry.wrapper.querySelector<HTMLElement>(
".pdf-text-highlight",
);
if (highlightEl) {
const containerRect = scrollEl.getBoundingClientRect();
const highlightRect = highlightEl.getBoundingClientRect();
const offsetWithinContainer = highlightRect.top - containerRect.top;
const targetTop =
scrollEl.scrollTop +
offsetWithinContainer -
scrollEl.clientHeight / 2 +
highlightRect.height / 2;
scrollEl.scrollTo({
top: Math.max(0, targetTop),
behavior: "instant" as ScrollBehavior,
});
} else {
const wrapperRect = pageEntry.wrapper.getBoundingClientRect();
const containerRect = scrollEl.getBoundingClientRect();
const targetTop =
scrollEl.scrollTop + (wrapperRect.top - containerRect.top);
scrollEl.scrollTo({
top: Math.max(0, targetTop),
behavior: "instant" as ScrollBehavior,
});
}
}
const rehighlightQuotes = useCallback(
async (list: QuoteEntry[]) => {
const targetPage = await applyHighlights(list);
const scrollPage =
targetPage ?? list.find((e) => e.page)?.page ?? null;
if (scrollPage && scrollPage >= 1) {
scrollToHighlightOnPage(scrollPage);
}
},
[applyHighlights],
);
// Trackpad pinch-to-zoom (wheel + ctrlKey)
useEffect(() => {
const el = scrollContainerRef.current;
if (!el) return;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
const handleWheel = (e: WheelEvent) => {
if (!e.ctrlKey) return;
e.preventDefault();
const delta = e.deltaMode === 0 ? e.deltaY / 300 : e.deltaY * 0.1;
const next = Math.min(
ZOOM_MAX,
Math.max(
ZOOM_MIN,
Math.round(zoomRef.current * Math.exp(-delta) * 100) / 100,
),
);
if (next === zoomRef.current) return;
zoomRef.current = next;
setZoom(next);
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
if (pdfDocRef.current) {
renderPDF(
pdfDocRef.current,
quoteListRef.current,
currentPageRef.current,
);
}
}, 150);
};
el.addEventListener("wheel", handleWheel, { passive: false });
return () => {
el.removeEventListener("wheel", handleWheel);
if (debounceTimer) clearTimeout(debounceTimer);
};
}, [renderPDF]);
// Touch pinch-to-zoom
useEffect(() => {
const el = scrollContainerRef.current;
if (!el) return;
let initialDist = 0;
let initialZoom = 1.0;
function getTouchDist(touches: TouchList) {
const dx = touches[0].clientX - touches[1].clientX;
const dy = touches[0].clientY - touches[1].clientY;
return Math.hypot(dx, dy);
}
const handleTouchStart = (e: TouchEvent) => {
if (e.touches.length === 2) {
initialDist = getTouchDist(e.touches);
initialZoom = zoomRef.current;
}
};
const handleTouchMove = (e: TouchEvent) => {
if (e.touches.length !== 2 || initialDist === 0) return;
e.preventDefault();
const next = Math.min(
ZOOM_MAX,
Math.max(
ZOOM_MIN,
Math.round(
initialZoom *
(getTouchDist(e.touches) / initialDist) *
100,
) / 100,
),
);
zoomRef.current = next;
setZoom(next);
};
const handleTouchEnd = (e: TouchEvent) => {
if (e.touches.length < 2 && initialDist > 0) {
initialDist = 0;
if (pdfDocRef.current) {
renderPDF(
pdfDocRef.current,
quoteListRef.current,
currentPageRef.current,
);
}
}
};
el.addEventListener("touchstart", handleTouchStart, { passive: true });
el.addEventListener("touchmove", handleTouchMove, { passive: false });
el.addEventListener("touchend", handleTouchEnd, { passive: true });
return () => {
el.removeEventListener("touchstart", handleTouchStart);
el.removeEventListener("touchmove", handleTouchMove);
el.removeEventListener("touchend", handleTouchEnd);
};
}, [renderPDF]);
// Clean up PDF.js static font-measurement canvases on unmount
useEffect(() => {
return () => {
getPdfJs().then((lib) => lib.TextLayer.cleanup());
};
}, []);
// Render PDF when fetch result arrives
useEffect(() => {
if (!result || result.type !== "pdf") return;
pdfDocRef.current = null;
renderedPagesRef.current = [];
quoteListRef.current = quoteList;
zoomRef.current = 1.0;
setZoom(1.0);
setNumPages(0);
const list = quoteList;
let cancelled = false;
(async () => {
const lib = await getPdfJs();
if (cancelled) return;
const pdfDoc = await lib.getDocument({
data: new Uint8Array(result.buffer),
standardFontDataUrl: STANDARD_FONT_DATA_URL,
}).promise;
if (cancelled) return;
pdfDocRef.current = pdfDoc;
await renderPDF(pdfDoc, list);
})();
return () => {
cancelled = true;
};
}, [result, renderPDF]); // eslint-disable-line react-hooks/exhaustive-deps
// Re-render at new scale when container is resized (debounced 150ms)
useEffect(() => {
if (!pdfDocRef.current) return;
const timer = setTimeout(() => {
if (pdfDocRef.current) {
renderPDF(pdfDocRef.current, quoteListRef.current);
}
}, 150);
return () => clearTimeout(timer);
}, [containerWidth, renderPDF]); // eslint-disable-line react-hooks/exhaustive-deps
// Re-highlight when quotes change without full re-render
useEffect(() => {
if (!pdfDocRef.current) return;
quoteListRef.current = quoteList;
rehighlightQuotes(quoteList);
}, [quoteKey, quoteFocusKey, rehighlightQuotes]); // eslint-disable-line react-hooks/exhaustive-deps
function handleZoomIn() {
const next = Math.min(
ZOOM_MAX,
Math.round((zoomRef.current + ZOOM_STEP) * 100) / 100,
);
zoomRef.current = next;
setZoom(next);
if (pdfDocRef.current) {
renderPDF(
pdfDocRef.current,
quoteListRef.current,
currentPageRef.current,
);
}
}
function handleZoomOut() {
const next = Math.max(
ZOOM_MIN,
Math.round((zoomRef.current - ZOOM_STEP) * 100) / 100,
);
zoomRef.current = next;
setZoom(next);
if (pdfDocRef.current) {
renderPDF(
pdfDocRef.current,
quoteListRef.current,
currentPageRef.current,
);
}
}
if (fallbackToDocx && doc?.document_id) {
return (
<DocxView
documentId={doc.document_id}
versionId={doc.version_id ?? null}
quotes={quotes}
quoteFocusKey={quoteFocusKey}
rounded={rounded}
/>
);
}
return (
<div
className={`relative flex flex-col bg-gray-100 flex-1 overflow-hidden ${rounded ? "rounded-lg" : ""}`}
>
<div
ref={scrollContainerRef}
className="flex-1 overflow-auto px-3 pt-5 pb-3"
>
{loading && (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-7 w-7 animate-spin text-gray-400" />
</div>
)}
{error && (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-red-500">{error}</p>
</div>
)}
<div ref={containerRef} />
</div>
{numPages > 0 && (
<>
{/* Page counter — bottom left */}
<div className="absolute bottom-4 left-4 pointer-events-none">
<span className="flex items-center px-3 py-1.5 rounded-full text-xs font-medium tabular-nums text-gray-700 bg-white/25 backdrop-blur-md border border-white/30 shadow-md">
{currentPage}/{numPages}
</span>
</div>
{/* Zoom controls — bottom right */}
<div className="absolute bottom-4 right-4 flex items-center gap-px rounded-full bg-white/25 backdrop-blur-md border border-white/30 shadow-md px-1 py-1">
<button
onClick={handleZoomOut}
disabled={zoom <= ZOOM_MIN}
className="flex items-center justify-center w-7 h-7 rounded-full text-gray-600 hover:bg-white/80 disabled:opacity-30 transition-colors"
>
<ZoomOut className="h-3.5 w-3.5" />
</button>
<span className="text-xs font-medium text-gray-600 tabular-nums w-9 text-center select-none">
{Math.round(zoom * 100)}%
</span>
<button
onClick={handleZoomIn}
disabled={zoom >= ZOOM_MAX}
className="flex items-center justify-center w-7 h-7 rounded-full text-gray-600 hover:bg-white/80 disabled:opacity-30 transition-colors"
>
<ZoomIn className="h-3.5 w-3.5" />
</button>
</div>
</>
)}
</div>
);
}

View file

@ -0,0 +1,518 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Loader2 } from "lucide-react";
import LuckyExcel, { type LuckyExcelSheet } from "luckyexcel";
import type { WorkbookInstance } from "@fortune-sheet/react";
import type { Cell, Sheet } from "@fortune-sheet/core";
import "@fortune-sheet/react/dist/index.css";
import { useFetchSingleDoc } from "@/app/hooks/useFetchSingleDoc";
type HighlightRange = { row: [number, number]; column: [number, number] };
type WorkbookComponent = typeof import("@fortune-sheet/react").Workbook;
/** A cell locator to highlight, e.g. { sheet: "Q3 Budget", cell: "B7" }. */
export type HighlightCell = { sheet?: string; cell?: string };
interface Props {
documentId: string;
versionId?: string | null;
/** Cell(s) to select/scroll to (from a spreadsheet citation). */
highlightCells?: HighlightCell[];
rounded?: boolean;
}
/** "B" -> 1, "AA" -> 26 (0-based column index). */
function columnLettersToIndex(letters: string): number {
let n = 0;
for (const ch of letters.toUpperCase()) {
n = n * 26 + (ch.charCodeAt(0) - 64);
}
return n - 1;
}
/** Parse an A1 address like "B7" into 0-based { r, c }. */
function parseA1(cell: string): { r: number; c: number } | null {
const m = cell.trim().match(/^([A-Za-z]+)(\d+)$/);
if (!m) return null;
const c = columnLettersToIndex(m[1]);
const r = Number.parseInt(m[2], 10) - 1;
if (c < 0 || r < 0) return null;
return { r, c };
}
/** Parse an A1 address or range ("B7" or "B7:C9") into 0-based row/col spans. */
function parseRange(range: string): HighlightRange | null {
const [startRaw, endRaw] = range.split(":");
const start = parseA1(startRaw);
if (!start) return null;
const end = endRaw ? parseA1(endRaw) : start;
if (!end) return null;
return {
row: [Math.min(start.r, end.r), Math.max(start.r, end.r)],
column: [Math.min(start.c, end.c), Math.max(start.c, end.c)],
};
}
/**
* Expand a highlight range to cover any merged ranges it intersects. Fortune-
* sheet paints a merge as one block anchored at its top-left cell and never
* paints the covered cells, so a citation to a covered cell (e.g. B1 inside
* A1:C1) would otherwise highlight nothing. The model is asked to cite the full
* merged range, but this is a deterministic fallback for when it cites a covered
* cell anyway. Expanding to the anchor makes `afterRenderCell` paint the block.
*/
function expandRangeForMerges(
sheet: Sheet,
range: HighlightRange,
): HighlightRange {
const merges = (sheet.config as { merge?: Record<string, MergeInfo> })
?.merge;
if (!merges) return range;
let [r0, r1] = range.row;
let [c0, c1] = range.column;
for (const m of Object.values(merges)) {
const mr1 = m.r + m.rs - 1;
const mc1 = m.c + m.cs - 1;
if (r0 <= mr1 && r1 >= m.r && c0 <= mc1 && c1 >= m.c) {
r0 = Math.min(r0, m.r);
r1 = Math.max(r1, mr1);
c0 = Math.min(c0, m.c);
c1 = Math.max(c1, mc1);
}
}
return { row: [r0, r1], column: [c0, c1] };
}
/**
* Pixel offset (from the grid origin) and size of a row/col span, derived from
* the sheet's column widths / row heights. Fortune-sheet defaults are 73px wide
* and 19px tall; per-index overrides live in `config.columnlen`/`config.rowlen`.
* Hidden rows/cols aren't accounted for, so this is best-effort (enough to
* decide whether the cell is on screen and where to center it).
*/
function rangePixelRect(
sheet: Sheet,
range: HighlightRange,
): { x: number; y: number; w: number; h: number } {
const cfg = (sheet.config ?? {}) as {
columnlen?: Record<string, number>;
rowlen?: Record<string, number>;
};
const colLen = cfg.columnlen ?? {};
const rowLen = cfg.rowlen ?? {};
const colW = (c: number) => colLen[c] ?? sheet.defaultColWidth ?? 73;
const rowH = (r: number) => rowLen[r] ?? sheet.defaultRowHeight ?? 19;
let x = 0;
for (let c = 0; c < range.column[0]; c++) x += colW(c);
let w = 0;
for (let c = range.column[0]; c <= range.column[1]; c++) w += colW(c);
let y = 0;
for (let r = 0; r < range.row[0]; r++) y += rowH(r);
let h = 0;
for (let r = range.row[0]; r <= range.row[1]; r++) h += rowH(r);
return { x, y, w, h };
}
type MergeInfo = { r: number; c: number; rs: number; cs: number };
type CellData = { r: number; c: number; v: Record<string, unknown> };
/**
* Expand `config.merge` onto the cells. Luckyexcel records merges in
* `config.merge` but Fortune-sheet only renders a merge when the cells carry
* `mc` (the anchor gets `{r,c,rs,cs}`; every covered cell points back with
* `{r,c}`). Without this, merged ranges render as separate single cells.
*/
function applyMergeCells(sheets: LuckyExcelSheet[]): void {
for (const sheet of sheets) {
const merges = (sheet.config as { merge?: Record<string, MergeInfo> })
?.merge;
if (!merges) continue;
if (!Array.isArray(sheet.celldata)) sheet.celldata = [];
const celldata = sheet.celldata as CellData[];
const byKey = new Map<string, CellData>();
for (const entry of celldata) {
if (typeof entry?.r === "number" && typeof entry?.c === "number") {
byKey.set(`${entry.r}_${entry.c}`, entry);
}
}
const ensureCell = (r: number, c: number): CellData => {
const key = `${r}_${c}`;
let entry = byKey.get(key);
if (!entry) {
entry = { r, c, v: {} };
celldata.push(entry);
byKey.set(key, entry);
}
if (!entry.v || typeof entry.v !== "object") entry.v = {};
return entry;
};
for (const mc of Object.values(merges)) {
ensureCell(mc.r, mc.c).v.mc = {
r: mc.r,
c: mc.c,
rs: mc.rs,
cs: mc.cs,
};
for (let rr = mc.r; rr < mc.r + mc.rs; rr++) {
for (let cc = mc.c; cc < mc.c + mc.cs; cc++) {
if (rr === mc.r && cc === mc.c) continue;
ensureCell(rr, cc).v.mc = { r: mc.r, c: mc.c };
}
}
}
}
}
/**
* Make text cells overflow into empty adjacent cells, mirroring Excel's default.
* Fortune-sheet only spills a cell's text when its `tb` (text-break) is "1";
* Luckyexcel leaves text cells clipping, so we set `tb: "1"` on unwrapped,
* non-merged text cells. Fortune-sheet still only paints the overflow over
* genuinely empty neighbors, so this matches Excel (numbers/dates keep the
* default and are not spilled).
*/
function applyExcelTextOverflow(sheets: LuckyExcelSheet[]): void {
for (const sheet of sheets) {
const celldata = sheet.celldata;
if (!Array.isArray(celldata)) continue;
for (const entry of celldata) {
const cell = (entry as { v?: Record<string, unknown> } | null)?.v;
if (!cell || typeof cell !== "object") continue;
if (cell.mc) continue; // part of a merge - leave as-is
if (cell.tb === "2") continue; // explicit wrap-text - keep
if (typeof cell.v === "string" && cell.v.length > 0) {
cell.tb = "1"; // text: overflow into empty neighbors
}
}
}
}
/**
* Tint a row/column header cell gray via Fortune-sheet's own header render
* hooks. Fortune-sheet paints the header labels (A/B/C, 1/2/3) onto the grid
* canvas with a white fill and dark text; we lay a translucent gray over that
* cell in the `afterRender…HeaderCell` pass. A low alpha darkens the white
* background into gray while leaving the dark labels legible unlike an opaque
* CSS background on the header overlay divs, which sits in front of the canvas
* and hides the labels entirely.
*/
const HEADER_TINT = "rgba(148, 163, 184, 0.18)";
function tintHeaderCell(
x: number,
y: number,
width: number,
height: number,
ctx: CanvasRenderingContext2D,
): void {
ctx.save();
ctx.fillStyle = HEADER_TINT;
ctx.fillRect(x, y, width, height);
ctx.restore();
}
/**
* Renders an Excel workbook as a read-only grid using Fortune-sheet. It fetches
* the document's raw `.xlsx`/`.xlsm`/`.xls` bytes itself (via /display) and
* converts them to Fortune-sheet data with Luckyexcel, preserving the original
* styling (fills, fonts, borders, merges).
*
* Spreadsheet citations are highlighted by cell: `highlightCells` activates the
* cited sheet tab and scrolls the A1 address/range into view, where a canvas
* hook paints the highlight.
*/
export function SpreadsheetView({
documentId,
versionId,
highlightCells,
rounded = true,
}: Props) {
const workbookRef = useRef<WorkbookInstance>(null);
// The frame element, used to reach Fortune-sheet's scrollbars for measuring
// the current scroll offset and viewport size when deciding whether to scroll.
const containerRef = useRef<HTMLDivElement>(null);
// Current highlight, read by the render hook. A ref (not state) so updating
// it never re-mounts the Workbook or changes the settings object.
const highlightRef = useRef<HighlightRange | null>(null);
const [sheets, setSheets] = useState<Sheet[] | null>(null);
const [WorkbookComponent, setWorkbookComponent] =
useState<WorkbookComponent | null>(null);
const [error, setError] = useState<string | null>(null);
// Fetch the raw workbook bytes. For spreadsheets, /display returns the
// original .xlsx/.xlsm/.xls bytes rather than a PDF rendition.
const { result, error: fetchError } = useFetchSingleDoc(
documentId,
versionId,
);
// Fortune-sheet touches browser-only APIs while loading, so keep the import
// inside the client component even though this file also owns the view.
useEffect(() => {
let cancelled = false;
import("@fortune-sheet/react").then((mod) => {
if (!cancelled) setWorkbookComponent(() => mod.Workbook);
});
return () => {
cancelled = true;
};
}, []);
// Stable key so the highlight effect only re-runs when the target changes.
const highlightKey = useMemo(
() =>
(highlightCells ?? [])
.map((h) => `${h.sheet ?? ""}!${h.cell ?? ""}`)
.join("|"),
[highlightCells],
);
// Parse the workbook with Luckyexcel, which converts the .xlsx to
// Fortune-sheet data while preserving styling (fills, fonts, borders,
// alignment, column widths).
useEffect(() => {
if (!result) return;
if (result.type !== "spreadsheet") {
setError("This spreadsheet could not be displayed.");
return;
}
let cancelled = false;
setSheets(null);
setError(null);
try {
const file = new File([result.buffer], "spreadsheet.xlsx");
LuckyExcel.transformExcelToLucky(file, (exportJson) => {
if (cancelled) return;
if (exportJson?.sheets?.length) {
applyMergeCells(exportJson.sheets);
applyExcelTextOverflow(exportJson.sheets);
setSheets(exportJson.sheets as unknown as Sheet[]);
} else {
setError("This spreadsheet could not be displayed.");
}
});
} catch {
if (!cancelled)
setError("This spreadsheet could not be displayed.");
}
return () => {
cancelled = true;
};
}, [result]);
// Draw the citation highlight on the canvas. Stable identity so the Workbook
// settings never change; it reads the live target from `highlightRef`. We use
// this instead of `setSelection`, whose in-place mutation of the range object
// crashes under React Strict Mode's double-invoked immer producer.
const afterRenderCell = useCallback(
(
_cell: Cell | null,
info: {
row: number;
column: number;
startX: number;
startY: number;
endX: number;
endY: number;
},
ctx: CanvasRenderingContext2D,
) => {
const range = highlightRef.current;
if (!range) return;
if (
info.row < range.row[0] ||
info.row > range.row[1] ||
info.column < range.column[0] ||
info.column > range.column[1]
) {
return;
}
const w = info.endX - info.startX;
const h = info.endY - info.startY;
ctx.save();
ctx.fillStyle = "rgba(59, 130, 246, 0.16)";
ctx.fillRect(info.startX, info.startY, w, h);
ctx.strokeStyle = "#3b82f6";
ctx.lineWidth = 2;
ctx.strokeRect(info.startX + 1, info.startY + 1, w - 2, h - 2);
ctx.restore();
},
[],
);
const hooks = useMemo(
() => ({
afterRenderCell,
// Tint the header strips gray while keeping the A/B/C, 1/2/3 labels
// visible (see tintHeaderCell). Column cells fill from y=0; row cells
// fill from x=0 — matching Fortune-sheet's own default header rects.
afterRenderColumnHeaderCell: (
_char: string,
_idx: number,
left: number,
width: number,
height: number,
ctx: CanvasRenderingContext2D,
) => tintHeaderCell(left, 0, width, height, ctx),
afterRenderRowHeaderCell: (
_num: string,
_idx: number,
top: number,
width: number,
height: number,
ctx: CanvasRenderingContext2D,
) => tintHeaderCell(0, top, width, height, ctx),
}),
[afterRenderCell],
);
// Activate the cited sheet, bring the cell into view, and repaint the
// highlight. We only scroll when the cell is off screen (centering it);
// when it's already visible we leave the viewport put and force a redraw so
// the `afterRenderCell` hook repaints the new highlight (and clears the old
// one). Both paths must trigger a redraw: `scroll()` repaints because the
// position changes, and the synthetic "resize" repaints in place via
// Fortune-sheet's window resize handler.
useEffect(() => {
if (!sheets) return;
const target = highlightCells?.[0];
const sheetIndex = target?.sheet
? Math.max(
0,
sheets.findIndex((s) => s.name === target.sheet),
)
: 0;
const parsed = target?.cell ? parseRange(target.cell) : null;
const range = parsed
? expandRangeForMerges(sheets[sheetIndex], parsed)
: null;
highlightRef.current = range;
if (!range && !target?.sheet) return;
const timer = window.setTimeout(() => {
const inst = workbookRef.current;
if (!inst) return;
try {
inst.activateSheet({ index: sheetIndex });
if (!range) return;
const container = containerRef.current;
const sbX = container?.querySelector<HTMLElement>(
".luckysheet-scrollbar-x",
);
const sbY = container?.querySelector<HTMLElement>(
".luckysheet-scrollbar-y",
);
// Without the scrollbars we can't measure the viewport; fall back to
// corner-scrolling, which at least brings the cell in and repaints.
if (!sbX || !sbY) {
inst.scroll({
targetRow: range.row[0],
targetColumn: range.column[0],
});
return;
}
const rect = rangePixelRect(sheets[sheetIndex], range);
const curLeft = sbX.scrollLeft;
const curTop = sbY.scrollTop;
const viewW = sbX.clientWidth;
const viewH = sbY.clientHeight;
const visible =
rect.x >= curLeft &&
rect.x + rect.w <= curLeft + viewW &&
rect.y >= curTop &&
rect.y + rect.h <= curTop + viewH;
if (visible) {
// On screen: keep the viewport still, just repaint the highlight.
window.dispatchEvent(new Event("resize"));
} else {
// Off screen: center the cell. scroll() re-renders and repaints.
inst.scroll({
scrollLeft: Math.max(
0,
Math.round(rect.x - (viewW - rect.w) / 2),
),
scrollTop: Math.max(
0,
Math.round(rect.y - (viewH - rect.h) / 2),
),
});
}
} catch {
/* highlighting is best-effort */
}
}, 200);
return () => window.clearTimeout(timer);
}, [sheets, highlightCells, highlightKey]);
const frameClass = `fortune-sheet-viewer relative flex flex-col flex-1 min-h-0 overflow-hidden ${rounded ? "rounded-lg" : ""}`;
const message =
error ?? (fetchError ? "Failed to load spreadsheet." : null);
if (message) {
return (
<div className={frameClass}>
<div className="flex h-full items-center justify-center px-6 text-center text-sm text-gray-500">
{message}
</div>
</div>
);
}
if (!sheets || !WorkbookComponent) {
return (
<div className={frameClass}>
<div className="flex h-full items-center justify-center">
<Loader2 className="h-7 w-7 animate-spin text-gray-400" />
</div>
</div>
);
}
return (
<div ref={containerRef} className={frameClass}>
<div className="relative min-h-0 flex-1">
<WorkbookComponent
ref={workbookRef}
data={sheets}
hooks={hooks}
allowEdit={false}
showToolbar={false}
showFormulaBar={false}
/>
<style jsx global>{`
/* The row/col header strips are transparent overlays over
the label canvas leave them so the labels show. Only the
corner (no label) gets a matching gray background here; the
header strips are tinted on the canvas via render hooks. */
.fortune-sheet-viewer .fortune-left-top {
background-color: #eceef2;
}
.fortune-sheet-viewer .fortune-row-header-hover,
.fortune-sheet-viewer .fortune-col-header-hover {
background-color: rgba(209, 213, 219, 0.65);
}
.fortune-sheet-viewer .fortune-row-header-selected,
.fortune-sheet-viewer .fortune-col-header-selected {
background-color: rgba(156, 163, 175, 0.28);
}
`}</style>
</div>
</div>
);
}
export default SpreadsheetView;

View file

@ -0,0 +1,130 @@
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();
}
function toOrigPos(text: string, strippedPos: number): number {
let count = 0;
for (let k = 0; k < text.length; k++) {
if (/[a-zA-Z0-9]/.test(text[k])) {
if (count === strippedPos) return k;
count++;
}
}
return text.length;
}
function collectTextNodes(root: HTMLElement): Text[] {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node: Node) {
const p = node.parentElement;
if (!p) return NodeFilter.FILTER_REJECT;
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;
},
});
const out: Text[] = [];
let cur = walker.nextNode() as Text | null;
while (cur) {
out.push(cur);
cur = walker.nextNode() as Text | null;
}
return out;
}
export function clearDocxQuoteHighlights(root: HTMLElement): void {
root.querySelectorAll(`.${HIGHLIGHT_CLASS}`).forEach((span) => {
const parent = span.parentNode;
if (!parent) return;
while (span.firstChild) parent.insertBefore(span.firstChild, span);
parent.removeChild(span);
});
root.normalize();
}
/**
* Highlight the given quote text inside `root` (a docx-preview output).
* Quote is split on ellipsis variants; each segment is located via
* letters-only substring matching, so whitespace/punctuation differences
* between the LLM's quote and the rendered text don't break matching.
*
* Returns the first highlight span if any match was found, for
* scroll-into-view by the caller.
*/
export function highlightDocxQuote(
root: HTMLElement,
quote: string,
): HTMLElement | null {
clearDocxQuoteHighlights(root);
if (!quote) return null;
const segments = quote
.split(/\.{3}|…/)
.map(onlyLetters)
.filter((s) => s.length > 0);
if (segments.length === 0) return null;
const textNodes = collectTextNodes(root);
const nodeStartInFull: number[] = [];
const nodeStrippedLen: number[] = [];
let fullStripped = "";
for (const node of textNodes) {
const stripped = onlyLetters(node.data);
nodeStartInFull.push(fullStripped.length);
nodeStrippedLen.push(stripped.length);
fullStripped += stripped;
}
type Range = { nodeIdx: number; origStart: number; origEnd: number };
const ranges: Range[] = [];
for (const segment of segments) {
const searchKey = segment.slice(0, 30);
const matchPos = fullStripped.indexOf(searchKey);
if (matchPos < 0) continue;
const matchEnd = matchPos + segment.length;
for (let i = 0; i < textNodes.length; i++) {
const start = nodeStartInFull[i];
const end = start + nodeStrippedLen[i];
if (matchPos >= end || matchEnd <= start) continue;
const localStart = Math.max(0, matchPos - start);
const localEnd = Math.min(nodeStrippedLen[i], matchEnd - start);
const text = textNodes[i].data;
const origStart = toOrigPos(text, localStart);
const origEnd = toOrigPos(text, localEnd);
if (origStart >= origEnd) continue;
ranges.push({ nodeIdx: i, origStart, origEnd });
}
}
if (ranges.length === 0) return null;
// Apply in reverse document order so splits don't shift earlier ranges.
ranges.sort((a, b) => {
if (a.nodeIdx !== b.nodeIdx) return b.nodeIdx - a.nodeIdx;
return b.origStart - a.origStart;
});
const spans: HTMLElement[] = [];
for (const r of ranges) {
const node = textNodes[r.nodeIdx];
const mid = node.splitText(r.origStart);
mid.splitText(r.origEnd - r.origStart);
const span = document.createElement("span");
span.className = HIGHLIGHT_CLASS;
mid.parentNode?.insertBefore(span, mid);
span.appendChild(mid);
spans.push(span);
}
// Because we processed ranges in reverse order, the earliest-in-document
// highlight is the last one we pushed.
return spans[spans.length - 1] ?? null;
}

View file

@ -0,0 +1,124 @@
let pdfjsLib: typeof import("pdfjs-dist") | null = null;
export async function getPdfJs() {
if (pdfjsLib) return pdfjsLib;
pdfjsLib = await import("pdfjs-dist");
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
"pdfjs-dist/build/pdf.worker.min.mjs",
import.meta.url,
).toString();
return pdfjsLib;
}
export const STANDARD_FONT_DATA_URL =
"https://unpkg.com/pdfjs-dist@4.10.38/standard_fonts/";
const HIGHLIGHT_CLASS = "pdf-text-highlight";
const ORIGINAL_TEXT_ATTR = "data-original-text";
// Strip everything except alphanumerics (a-z A-Z 0-9) for robust matching
function onlyLetters(s: string): string {
return s.replace(/[^a-zA-Z0-9]/g, "").toLowerCase();
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
// Given a position in the stripped (letters-only) version of `original`,
// return the corresponding index in `original`.
function strippedPosToOriginal(original: string, strippedPos: number): number {
let count = 0;
for (let i = 0; i < original.length; i++) {
if (/[a-zA-Z0-9]/.test(original[i])) {
if (count === strippedPos) return i;
count++;
}
}
return original.length;
}
export function clearHighlights(textDivs: HTMLElement[]) {
for (const div of textDivs) {
if (div.hasAttribute(ORIGINAL_TEXT_ATTR)) {
div.textContent = div.getAttribute(ORIGINAL_TEXT_ATTR)!;
div.removeAttribute(ORIGINAL_TEXT_ATTR);
}
}
}
export async function highlightQuote(
textDivs: HTMLElement[],
quote: string,
): Promise<boolean> {
clearHighlights(textDivs);
// Split on ellipsis variants to highlight each segment separately
const segments = quote
.split(/\.{3}|…/)
.map((s) => onlyLetters(s))
.filter((s) => s.length > 0);
// Build the stripped full text and track each div's start position within it.
// Also keep original div texts for display.
const divOrigTexts: string[] = []; // original text for innerHTML slicing
const divStripped: string[] = []; // letters-only version for matching
const divStartInFull: number[] = []; // start index in fullStripped
let fullStripped = "";
for (let i = 0; i < textDivs.length; i++) {
const orig = textDivs[i].textContent ?? "";
divOrigTexts.push(orig);
const stripped = onlyLetters(orig);
divStripped.push(stripped);
divStartInFull.push(fullStripped.length);
fullStripped += stripped;
}
// Map: divIndex -> [strippedLocalStart, strippedLocalEnd]
const divHighlightRanges = new Map<number, [number, number]>();
for (const segment of segments) {
const searchKey = segment.slice(0, 30);
const matchPos = fullStripped.indexOf(searchKey);
if (matchPos === -1) {
continue;
}
const matchEnd = matchPos + segment.length;
for (let i = 0; i < textDivs.length; i++) {
const divStart = divStartInFull[i];
const divEnd = divStart + divStripped[i].length;
if (matchPos >= divEnd || matchEnd <= divStart) continue;
const localStart = Math.max(0, matchPos - divStart);
const localEnd = Math.min(
divStripped[i].length,
matchEnd - divStart,
);
divHighlightRanges.set(i, [localStart, localEnd]);
}
}
if (divHighlightRanges.size === 0) return false;
for (const [idx, [strStart, strEnd]] of divHighlightRanges) {
const div = textDivs[idx];
const orig = divOrigTexts[idx];
// Map stripped positions back to original character positions
const origStart = strippedPosToOriginal(orig, strStart);
const origEnd = strippedPosToOriginal(orig, strEnd);
div.setAttribute(ORIGINAL_TEXT_ATTR, orig);
div.innerHTML =
escapeHtml(orig.slice(0, origStart)) +
`<span class="${HIGHLIGHT_CLASS}">${escapeHtml(orig.slice(origStart, origEnd))}</span>` +
escapeHtml(orig.slice(origEnd));
}
return true;
}

View file

@ -1,5 +1,5 @@
import Link from "next/link";
import { MikeIcon } from "@/components/chat/mike-icon";
import { MikeIcon } from "@/app/components/chat/mike-icon";
interface SiteLogoProps {
size?: "sm" | "md" | "lg" | "xl";

View file

@ -1,21 +1,17 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { ChevronDown, Plus, X } from "lucide-react";
import type { ColumnConfig, ColumnFormat } from "../shared/types";
import { generateTabularColumnPrompt } from "@/app/lib/mikeApi";
import { FORMAT_OPTIONS, formatLabel, formatIcon } from "./columnFormat";
import { FORMAT_OPTIONS } from "./columnFormat";
import { TAG_COLORS } from "./pillUtils";
import { getPresetConfig, PROMPT_PRESETS } from "./columnPresets";
import { Modal } from "../modals/Modal";
import { ModalFieldLabel } from "../modals/ModalFieldLabel";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ModalSelect } from "../modals/ModalSelect";
import { ModalTextarea } from "../modals/ModalTextarea";
import { ModalTextInput } from "../modals/ModalTextInput";
interface ColumnDraft {
name: string;
@ -45,7 +41,9 @@ interface Props {
export function AddColumnModal({ open, existingCount, onClose, onAdd, editingColumn, onSave, onDelete }: Props) {
const isEditing = !!editingColumn;
const formId = "add-column-modal-form";
const [columns, setColumns] = useState<ColumnDraft[]>([{ ...EMPTY_DRAFT }]);
const [collapsedIndices, setCollapsedIndices] = useState<number[]>([]);
const [generatingIndices, setGeneratingIndices] = useState<number[]>([]);
const [presetsOpenIndex, setPresetsOpenIndex] = useState<number | null>(
null,
@ -65,6 +63,7 @@ export function AddColumnModal({ open, existingCount, onClose, onAdd, editingCol
} else {
setColumns([{ ...EMPTY_DRAFT }]);
}
setCollapsedIndices([]);
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
@ -86,6 +85,7 @@ export function AddColumnModal({ open, existingCount, onClose, onAdd, editingCol
function resetForm() {
setColumns([{ ...EMPTY_DRAFT }]);
setCollapsedIndices([]);
setGeneratingIndices([]);
}
@ -110,6 +110,24 @@ export function AddColumnModal({ open, existingCount, onClose, onAdd, editingCol
? [{ ...EMPTY_DRAFT }]
: prev.filter((_, i) => i !== index),
);
setCollapsedIndices((prev) =>
prev
.filter((collapsedIndex) => collapsedIndex !== index)
.map((collapsedIndex) =>
collapsedIndex > index
? collapsedIndex - 1
: collapsedIndex,
),
);
}
function toggleColumnCollapsed(index: number) {
setCollapsedIndices((prev) =>
prev.includes(index)
? prev.filter((collapsedIndex) => collapsedIndex !== index)
: [...prev, index],
);
setPresetsOpenIndex(null);
}
function commitTag(index: number) {
@ -191,35 +209,90 @@ export function AddColumnModal({ open, existingCount, onClose, onAdd, editingCol
onClose();
}
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">
<div className="flex items-center gap-1.5 text-xs text-gray-400">
<span>Tabular Review</span>
<span></span>
<span>{isEditing ? "Edit column" : "New column"}</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"
>
{/* Body */}
<div className="px-6 pt-3 pb-5 space-y-5 overflow-y-auto flex-1">
return (
<Modal
open={open}
onClose={handleClose}
breadcrumbs={[
"Tabular Review",
isEditing ? "Edit column" : "New column",
]}
primaryAction={{
label: isEditing ? "Save changes" : "Add columns",
type: "submit",
form: formId,
disabled: columns.some(
(col) => !col.name.trim() || !col.prompt.trim(),
),
}}
cancelAction={{ label: "Cancel", onClick: handleClose }}
secondaryAction={
isEditing && onDelete
? {
label: "Delete",
variant: "danger",
onClick: onDelete,
}
: undefined
}
>
<form
id={formId}
onSubmit={handleSubmit}
className="flex min-h-0 flex-1 flex-col"
>
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-3">
{columns.map((column, index) => (
<div
key={index}
className="rounded-xl border border-gray-200 p-4"
className="relative"
>
{(() => {
const nameInputId = `column-${index}-name`;
const formatInputId = `column-${index}-format`;
const tagInputId = `column-${index}-tag`;
const promptInputId = `column-${index}-prompt`;
const isCollapsed =
collapsedIndices.includes(index);
return (
<>
<div className="mb-4 flex items-center justify-between gap-3">
<button
type="button"
onClick={() =>
toggleColumnCollapsed(
index,
)
}
aria-expanded={!isCollapsed}
className="flex min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-lg text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-gray-300"
>
<ChevronDown
className={`h-4 w-4 shrink-0 text-gray-600 transition-transform ${isCollapsed ? "-rotate-90" : ""}`}
/>
<h3 className="font-serif text-2xl text-gray-950">
Column {index + 1}
</h3>
</button>
{columns.length > 1 && (
<button
type="button"
onClick={() =>
removeColumn(index)
}
className="rounded-lg p-1.5 text-gray-300 transition-colors hover:bg-gray-100 hover:text-gray-500"
aria-label="Remove column"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{!isCollapsed && (
<>
<ModalFieldLabel htmlFor={nameInputId}>
Column title
</ModalFieldLabel>
{/* Name row */}
<div className="flex items-start gap-2">
{/* Input + preset dropdown anchored to this wrapper */}
@ -231,8 +304,10 @@ export function AddColumnModal({ open, existingCount, onClose, onAdd, editingCol
: null
}
>
<input
<ModalTextInput
id={nameInputId}
type="text"
variant="minimal"
value={column.name}
onChange={(e) => {
const name = e.target.value;
@ -253,7 +328,7 @@ export function AddColumnModal({ open, existingCount, onClose, onAdd, editingCol
});
}}
placeholder="Column name"
className="flex-1 text-2xl font-serif text-gray-800 placeholder-gray-400 focus:outline-none bg-transparent"
className="flex-1"
autoFocus={index === 0}
/>
<button
@ -316,74 +391,39 @@ export function AddColumnModal({ open, existingCount, onClose, onAdd, editingCol
</div>
)}
</div>
{columns.length > 1 && (
<button
type="button"
onClick={() => removeColumn(index)}
className="mt-1.5 rounded-lg p-1.5 text-gray-300 transition-colors hover:bg-gray-100 hover:text-gray-500"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{/* Format */}
<div className="mt-4">
<ModalFieldLabel className="text-gray-500">
<ModalFieldLabel htmlFor={formatInputId}>
Format
</ModalFieldLabel>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="mt-1 flex items-center justify-between rounded-md border border-gray-200 bg-white px-2 py-1.5 text-sm text-gray-700 hover:border-gray-400 focus:outline-none">
<span className="flex items-center gap-2">
{(() => {
const Icon = formatIcon(
column.format,
);
return (
<Icon className="h-3.5 w-3.5 text-gray-400" />
);
})()}
{formatLabel(column.format)}
</span>
<ChevronDown className="h-3.5 w-3.5 text-gray-400" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="z-[200]"
>
<DropdownMenuRadioGroup
value={column.format}
onValueChange={(v) =>
updateColumn(index, {
format: v as ColumnFormat,
tags: [],
tagInput: "",
})
}
>
{FORMAT_OPTIONS.map((o) => (
<DropdownMenuRadioItem
key={o.value}
value={o.value}
>
<o.icon className="h-3.5 w-3.5 text-gray-400" />
{o.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<ModalSelect
id={formatInputId}
value={column.format}
options={FORMAT_OPTIONS.map((option) => ({
value: option.value,
label: option.label,
icon: option.icon,
iconClassName: option.iconClassName,
}))}
onChange={(value) =>
updateColumn(index, {
format: value as ColumnFormat,
tags: [],
tagInput: "",
})
}
/>
</div>
{/* Tag input */}
{column.format === "tag" && (
<div className="mt-3">
<ModalFieldLabel className="text-gray-500">
<ModalFieldLabel htmlFor={tagInputId}>
Tags
</ModalFieldLabel>
<div className="mt-1 flex flex-wrap gap-1.5 rounded-md border border-gray-200 px-2 py-1.5 focus-within:border-gray-400">
<div className="mt-1 flex flex-wrap gap-1.5 rounded-xl border border-white/70 bg-white/55 px-2 py-1.5 shadow-[0_3px_9px_rgba(15,23,42,0.052),inset_0_1px_0_rgba(255,255,255,0.86),inset_0_-1px_0_rgba(255,255,255,0.58)] backdrop-blur-xl">
{column.tags.map((tag, tagIdx) => (
<span
key={tag}
@ -410,8 +450,10 @@ export function AddColumnModal({ open, existingCount, onClose, onAdd, editingCol
</button>
</span>
))}
<input
<ModalTextInput
id={tagInputId}
type="text"
variant="minimal"
value={column.tagInput}
onChange={(e) =>
updateColumn(index, {
@ -424,7 +466,7 @@ export function AddColumnModal({ open, existingCount, onClose, onAdd, editingCol
}
onBlur={() => commitTag(index)}
placeholder="Add tag…"
className="min-w-[80px] flex-1 bg-transparent text-sm text-gray-700 placeholder-gray-400 focus:outline-none"
className="min-w-[80px] flex-1 bg-transparent font-sans text-sm text-gray-700 shadow-none placeholder:text-gray-400"
/>
</div>
<p className="mt-1 text-xs text-gray-400">
@ -435,7 +477,10 @@ export function AddColumnModal({ open, existingCount, onClose, onAdd, editingCol
{/* Prompt */}
<div className="mt-4 flex items-center justify-between">
<ModalFieldLabel className="mb-0 text-gray-500">
<ModalFieldLabel
htmlFor={promptInputId}
className="mb-0"
>
Prompt
</ModalFieldLabel>
<button
@ -457,7 +502,8 @@ export function AddColumnModal({ open, existingCount, onClose, onAdd, editingCol
Auto-Generate Prompt
</button>
</div>
<textarea
<ModalTextarea
id={promptInputId}
rows={6}
value={column.prompt}
onChange={(e) =>
@ -466,8 +512,13 @@ export function AddColumnModal({ open, existingCount, onClose, onAdd, editingCol
})
}
placeholder="Write the analysis prompt — describe what Mike should extract from each document for this column…"
className="mt-2 w-full rounded-md border border-gray-200 px-3 py-2 text-sm text-gray-700 placeholder-gray-400 focus:border-gray-400 focus:outline-none bg-transparent resize-none leading-relaxed"
className="mt-2 min-h-36"
/>
</>
)}
</>
);
})()}
</div>
))}
@ -481,43 +532,8 @@ export function AddColumnModal({ open, existingCount, onClose, onAdd, editingCol
Add another column
</button>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between border-t border-gray-100 px-6 py-4">
<div>
{isEditing && onDelete && (
<button
type="button"
onClick={onDelete}
className="rounded-lg px-4 py-2 text-sm text-red-500 hover:bg-red-50 transition-colors"
>
Delete
</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={columns.some(
(col) => !col.name.trim() || !col.prompt.trim(),
)}
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"
>
{isEditing ? "Save changes" : "Add columns"}
</button>
</div>
</div>
</form>
</div>
</div>,
document.body,
</div>
</form>
</Modal>
);
}

View file

@ -0,0 +1,465 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Loader2, Upload } from "lucide-react";
import type { Document, Project, Workflow } from "../shared/types";
import {
getProject,
listProjects,
listStandaloneDocuments,
listWorkflows,
uploadProjectDocument,
uploadStandaloneDocument,
} from "@/app/lib/mikeApi";
import { FileDirectory } from "../shared/FileDirectory";
import { Modal } from "../modals/Modal";
import { ModalFieldLabel } from "../modals/ModalFieldLabel";
import { ModalSelect } from "../modals/ModalSelect";
import { ModalTextInput } from "../modals/ModalTextInput";
const isDev = process.env.NODE_ENV !== "production";
const devLog = (...args: Parameters<typeof console.log>) => {
if (isDev) console.log(...args);
};
interface Props {
open: boolean;
onClose: () => void;
onAdd: (
title: string,
projectId?: string,
documentIds?: string[],
columnsConfig?: Workflow["columns_config"],
) => void;
projects?: Project[];
/** When provided, skip the project/directory picker and show only these docs */
projectDocs?: Document[];
projectName?: string;
projectCmNumber?: string | null;
}
export function NewTRModal({
open,
onClose,
onAdd,
projects = [],
projectDocs: fixedProjectDocs,
projectName,
projectCmNumber,
}: Props) {
const isProjectMode = fixedProjectDocs !== undefined;
const [step, setStep] = useState<"details" | "documents">("details");
const [title, setTitle] = useState("");
const [underProject, setUnderProject] = useState(false);
const [selectedProjectId, setSelectedProjectId] = useState("");
// Project-scoped docs (when underProject is true and no fixedProjectDocs)
const [projectDocs, setProjectDocs] = useState<Document[]>([]);
const [loadingDocs, setLoadingDocs] = useState(false);
// Full directory (when underProject is false)
const [standaloneDocs, setStandaloneDocs] = useState<Document[]>([]);
const [directoryProjects, setDirectoryProjects] = useState<Project[]>(
[],
);
const [loadingDirectory, setLoadingDirectory] = useState(false);
const [selectedDocIds, setSelectedDocIds] = useState<Set<string>>(
new Set(),
);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Workflow templates
const [workflows, setWorkflows] = useState<Workflow[]>([]);
const [loadingWorkflows, setLoadingWorkflows] = useState(false);
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null>(
null,
);
const formId = "new-tabular-review-modal-form";
useEffect(() => {
if (!open) return;
setLoadingWorkflows(true);
listWorkflows("tabular")
.then((workflows) => {
devLog("[workflows/ui:tabular-review-modal] loaded", {
workflowCount: workflows.length,
systemCount: workflows.filter((workflow) => workflow.is_system)
.length,
sample: workflows.slice(0, 5).map((workflow) => ({
id: workflow.id,
title: workflow.metadata.title,
type: workflow.metadata.type,
user_id: workflow.user_id,
is_system: workflow.is_system,
is_owner: workflow.is_owner,
})),
});
setWorkflows(workflows);
})
.catch((error) => {
devLog(
"[workflows/ui:tabular-review-modal] failed",
error,
);
setWorkflows([]);
})
.finally(() => setLoadingWorkflows(false));
if (isProjectMode) {
setSelectedDocIds(
new Set((fixedProjectDocs ?? []).map((d) => d.id)),
);
return;
}
setLoadingDirectory(true);
// /projects only returns counts, not the documents array — fetch
// each project in parallel so FileDirectory can render the docs
// when the user expands a folder.
Promise.all([listStandaloneDocuments(), listProjects()])
.then(async ([docs, projs]) => {
setStandaloneDocs(
[...docs].sort((a, b) =>
(b.created_at ?? "").localeCompare(a.created_at ?? ""),
),
);
const fullProjects = await Promise.all(
projs.map((p) => getProject(p.id)),
);
setDirectoryProjects(fullProjects);
})
.catch(() => {
setStandaloneDocs([]);
setDirectoryProjects([]);
})
.finally(() => setLoadingDirectory(false));
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
if (!open) return null;
function handleClose() {
setStep("details");
setTitle("");
setUnderProject(false);
setSelectedProjectId("");
setProjectDocs([]);
setStandaloneDocs([]);
setDirectoryProjects([]);
setSelectedDocIds(new Set());
setSelectedWorkflowId(null);
onClose();
}
function submitterValue(e: React.FormEvent<HTMLFormElement>) {
return (
(e.nativeEvent as SubmitEvent).submitter as
| HTMLButtonElement
| null
)?.value;
}
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!title.trim()) return;
if (underProject && !selectedProjectId) return;
if (step === "details" || submitterValue(e) !== "create-review") {
setStep("documents");
return;
}
const selectedWorkflow = workflows.find(
(w) => w.id === selectedWorkflowId,
);
onAdd(
title.trim(),
underProject ? selectedProjectId : undefined,
selectedDocIds.size > 0 ? [...selectedDocIds] : undefined,
selectedWorkflow?.columns_config ?? undefined,
);
handleClose();
}
async function handleSelectProject(projectId: string) {
setSelectedProjectId(projectId);
setProjectDocs([]);
setSelectedDocIds(new Set());
setLoadingDocs(true);
try {
const proj = await getProject(projectId);
const docs = (proj.documents ?? []).filter(
(d) => d.status === "ready",
);
setProjectDocs(docs);
setSelectedDocIds(new Set(docs.map((d) => d.id)));
} finally {
setLoadingDocs(false);
}
}
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(e.target.files ?? []);
if (!files.length) return;
setUploading(true);
try {
const uploaded = await Promise.all(
files.map((f) =>
underProject && selectedProjectId
? uploadProjectDocument(selectedProjectId, f)
: uploadStandaloneDocument(f),
),
);
if (underProject && selectedProjectId) {
setProjectDocs((prev) => [...uploaded, ...prev]);
} else {
setStandaloneDocs((prev) => [...uploaded, ...prev]);
}
uploaded.forEach((d) =>
setSelectedDocIds((prev) => new Set([...prev, d.id])),
);
} catch (err) {
console.error("Upload failed:", err);
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
}
const workflowOptions = [
{
value: "",
label: loadingWorkflows
? "Loading templates..."
: "No template - start from scratch",
},
...workflows.map((workflow) => ({
value: workflow.id,
label: workflow.metadata.title,
})),
];
const projectOptions = projects.length
? projects.map((project) => ({
value: project.id,
label:
project.name +
(project.cm_number ? ` (#${project.cm_number})` : ""),
}))
: [{ value: "", label: "No projects found" }];
// What to show in the directory depends on mode and toggle state
const directoryStandalone = isProjectMode
? (fixedProjectDocs ?? [])
: underProject
? []
: standaloneDocs;
const directoryFolders = isProjectMode
? []
: underProject
? []
: directoryProjects;
const flatProjectDocs: Document[] =
!isProjectMode && underProject ? projectDocs : [];
const directoryLoading = isProjectMode
? false
: underProject
? loadingDocs
: loadingDirectory;
const showDirectory = isProjectMode || !underProject || !!selectedProjectId;
const breadcrumbs =
isProjectMode && projectName
? [
"Projects",
`${projectName}${projectCmNumber ? ` (#${projectCmNumber})` : ""}`,
"New Tabular Review",
]
: ["Tabular Reviews", "New Tabular Review"];
return (
<Modal
open={open}
onClose={handleClose}
breadcrumbs={[
...breadcrumbs,
step === "details" ? "Details" : "Add Documents",
]}
secondaryAction={
step === "documents"
? {
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,
}
: undefined
}
cancelAction={
step === "documents"
? {
label: "Back",
onClick: () => setStep("details"),
disabled: uploading,
}
: undefined
}
primaryAction={
step === "details"
? {
label: "Next",
type: "button",
onClick: (event) => {
event.preventDefault();
setStep("documents");
},
disabled:
!title.trim() ||
(underProject && !selectedProjectId),
}
: {
label: "Create",
type: "submit",
form: formId,
name: "modalAction",
value: "create-review",
disabled:
!title.trim() ||
(underProject && !selectedProjectId),
}
}
>
<input
ref={fileInputRef}
type="file"
accept=".pdf,.docx,.doc,.xlsx,.xlsm,.xls,.pptx,.ppt"
multiple
className="hidden"
onChange={handleUpload}
/>
<form
id={formId}
onSubmit={handleSubmit}
className="flex flex-col min-h-0 flex-1"
>
{step === "details" ? (
<div className="space-y-6">
<div>
<ModalFieldLabel htmlFor="new-tr-title">
Review name
</ModalFieldLabel>
<ModalTextInput
id="new-tr-title"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Review name"
variant="minimal"
className="placeholder:text-gray-400"
autoFocus
/>
</div>
{/* Workflow template */}
<div>
<ModalFieldLabel as="p">
Workflow template
</ModalFieldLabel>
<ModalSelect
id="new-tr-workflow-template"
value={selectedWorkflowId ?? ""}
options={workflowOptions}
onChange={(value) =>
setSelectedWorkflowId(value || null)
}
disabled={loadingWorkflows}
/>
</div>
{/* Create under a project toggle */}
{!isProjectMode && (
<div className="space-y-3">
<ModalFieldLabel as="p">
Project
</ModalFieldLabel>
<button
type="button"
onClick={() => {
const next = !underProject;
setUnderProject(next);
if (!next) {
setSelectedProjectId("");
setProjectDocs([]);
setSelectedDocIds(new Set());
}
}}
className="flex w-fit items-center gap-2.5"
>
<span
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full transition-colors duration-200 ${underProject ? "bg-gray-900" : "bg-gray-100"}`}
>
<span
className={`absolute left-0.5 top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-transform duration-200 ${underProject ? "translate-x-4" : "translate-x-0"}`}
/>
</span>
<span className="text-sm text-gray-600">
Create under a project
</span>
</button>
{underProject && (
<ModalSelect
id="new-tr-project"
value={selectedProjectId}
options={projectOptions}
onChange={(value) => {
if (value) {
void handleSelectProject(value);
}
}}
placeholder="Select project..."
disabled={projects.length === 0}
/>
)}
</div>
)}
</div>
) : (
<div className="flex min-h-0 flex-1 flex-col">
{showDirectory && (
<FileDirectory
standaloneDocs={
isProjectMode
? directoryStandalone
: underProject
? flatProjectDocs
: directoryStandalone
}
directoryProjects={
isProjectMode
? []
: underProject
? []
: directoryFolders
}
loading={directoryLoading}
selectedIds={selectedDocIds}
onChange={setSelectedDocIds}
emptyMessage={
isProjectMode || underProject
? "No ready documents in this project"
: "No documents yet"
}
searchable
searchAutoFocus
showProjectTabs={!isProjectMode && !underProject}
/>
)}
</div>
)}
</form>
</Modal>
);
}

View file

@ -13,7 +13,7 @@ import {
ChevronLeft,
Trash2,
} from "lucide-react";
import { MikeIcon } from "@/components/chat/mike-icon";
import { MikeIcon } from "@/app/components/chat/mike-icon";
import {
streamTabularChat,
getTabularChats,
@ -27,14 +27,14 @@ import type { AssistantEvent, ColumnConfig, Document } from "../shared/types";
import { ModelToggle } from "../assistant/ModelToggle";
import { ApiKeyMissingPopup } from "../popups/ApiKeyMissingPopup";
import { PreResponseWrapper } from "../assistant/PreResponseWrapper";
import { useUserProfile } from "@/contexts/UserProfileContext";
import { useUserProfile } from "@/app/contexts/UserProfileContext";
import {
getModelProvider,
isModelAvailable,
type ModelProvider,
} from "@/app/lib/modelAvailability";
import type { ApiKeyState } from "@/app/lib/mikeApi";
import { cn } from "@/lib/utils";
import { cn } from "@/app/lib/utils";
// ---------------------------------------------------------------------------
// Types

View file

@ -1,7 +1,8 @@
"use client";
import { useEffect, useState } from "react";
import { ChevronDown, Loader2, MoreHorizontal, Plus, Trash2, X } from "lucide-react";
import { useEffect, useId, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { ChevronDown, Loader2, MoreHorizontal, Plus, X } from "lucide-react";
import type { ColumnConfig, ColumnFormat } from "../shared/types";
import { generateTabularColumnPrompt } from "@/app/lib/mikeApi";
import { FORMAT_OPTIONS, formatLabel, formatIcon } from "./columnFormat";
@ -12,10 +13,17 @@ import {
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
} from "@/app/components/ui/dropdown-menu";
import { PillButton } from "@/app/components/ui/pill-button";
// Liquid-glass field styling shared by the menu's inputs/controls, matching the
// modal's glass treatment (translucent white over the light-gray panel).
const GLASS_FIELD =
"border border-white/70 bg-white/55 shadow-[0_3px_9px_rgba(15,23,42,0.052),inset_0_1px_0_rgba(255,255,255,0.86),inset_0_-1px_0_rgba(255,255,255,0.58)] backdrop-blur-xl";
export interface TREditColumnMenuProps {
column: ColumnConfig;
closeSignal?: number;
disabled?: boolean;
onSave: (column: ColumnConfig) => void | Promise<void>;
onDelete: (columnIndex: number) => void | Promise<void>;
@ -23,11 +31,13 @@ export interface TREditColumnMenuProps {
export function TREditColumnMenu({
column,
closeSignal,
disabled,
onSave,
onDelete,
}: TREditColumnMenuProps) {
const [open, setOpen] = useState(false);
const menuId = useId();
const [name, setName] = useState(column.name);
const [prompt, setPrompt] = useState(column.prompt);
const [format, setFormat] = useState<ColumnFormat>(column.format ?? "text");
@ -36,6 +46,58 @@ export function TREditColumnMenu({
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const [generating, setGenerating] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
// Fixed-position coords for the portaled menu. The menu is rendered into
// document.body so it isn't clipped by the header's overflow-hidden (which
// exists for horizontal scroll-sync). We size/position it to span the column
// header cell (with a min width for usability) and keep it pinned as the
// table scrolls or the window resizes.
const [menuPos, setMenuPos] = useState<{
top: number;
left: number;
width: number;
} | null>(null);
useEffect(() => {
if (!open) {
setMenuPos(null);
return;
}
const update = () => {
const rect = buttonRef.current?.getBoundingClientRect();
if (!rect) return;
// Span this column's header cell so the panel's left/right edges line
// up with the column. Falls back to the trigger button when no column
// ancestor is found. A min width keeps the form usable on narrow
// columns (there it just extends leftward from the column's right
// edge). Positioning via left (same coordinate space as
// getBoundingClientRect) avoids scrollbar/innerWidth offsets.
const colRect = buttonRef.current
?.closest("[data-tr-col-header]")
?.getBoundingClientRect();
const rightEdge = colRect?.right ?? rect.right;
const width = Math.max(colRect?.width ?? 288, 288); // 288 = w-72
setMenuPos({
top: rect.bottom + 6, // mt-1.5
left: Math.max(8, rightEdge - width),
width,
});
};
update();
window.addEventListener("scroll", update, true);
window.addEventListener("resize", update);
return () => {
window.removeEventListener("scroll", update, true);
window.removeEventListener("resize", update);
};
}, [open]);
useEffect(() => {
if (closeSignal === undefined) return;
const timeout = window.setTimeout(() => setOpen(false), 0);
return () => window.clearTimeout(timeout);
}, [closeSignal]);
useEffect(() => {
if (!open) {
@ -47,6 +109,45 @@ export function TREditColumnMenu({
}
}, [column.name, column.prompt, column.format, column.tags, open]);
// Only one edit-column menu should be open at a time. Broadcast when this
// one opens and close ourselves when another one broadcasts.
useEffect(() => {
if (open) {
window.dispatchEvent(
new CustomEvent("tr-edit-column-menu-open", { detail: menuId }),
);
}
}, [open, menuId]);
useEffect(() => {
const onOtherOpen = (e: Event) => {
if ((e as CustomEvent<string>).detail !== menuId) setOpen(false);
};
window.addEventListener("tr-edit-column-menu-open", onOtherOpen);
return () =>
window.removeEventListener("tr-edit-column-menu-open", onOtherOpen);
}, [menuId]);
// Close on click outside the panel. Ignore the trigger (it toggles) and any
// Radix popper (e.g. the Format dropdown, which portals outside the panel).
useEffect(() => {
if (!open) return;
const onPointerDown = (e: MouseEvent) => {
const target = e.target as Node;
if (
panelRef.current?.contains(target) ||
buttonRef.current?.contains(target) ||
(target instanceof Element &&
target.closest("[data-radix-popper-content-wrapper]"))
) {
return;
}
setOpen(false);
};
document.addEventListener("mousedown", onPointerDown);
return () => document.removeEventListener("mousedown", onPointerDown);
}, [open]);
function commitTag() {
const tag = tagInput.trim();
if (!tag) {
@ -112,6 +213,7 @@ export function TREditColumnMenu({
return (
<div className="relative shrink-0" onClick={(e) => e.stopPropagation()}>
<button
ref={buttonRef}
onClick={(e) => {
e.stopPropagation();
if (disabled) return;
@ -127,19 +229,28 @@ export function TREditColumnMenu({
<MoreHorizontal className="h-4 w-4" />
</button>
{open && (
<div
className="absolute right-0 top-full z-20 mt-1.5 w-72 rounded-2xl border border-white/70 bg-white p-3 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)] backdrop-blur-2xl"
onClick={(e) => e.stopPropagation()}
>
{open &&
menuPos &&
createPortal(
<div
ref={panelRef}
className="fixed z-[40] rounded-3xl border border-white/70 bg-gray-50/95 p-3 shadow-[0_14px_40px_rgba(15,23,42,0.071),0_5px_14px_rgba(15,23,42,0.047)] backdrop-blur-3xl"
style={{
top: menuPos.top,
left: menuPos.left,
width: menuPos.width,
}}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-3">
<p className="text-sm font-medium text-gray-800">
<p className="font-serif text-lg font-medium text-gray-900">
Edit Column
</p>
<button
type="button"
onClick={() => setOpen(false)}
className="rounded p-0.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 transition-colors"
aria-label="Close"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-white/70 bg-white/55 text-gray-500 shadow-[inset_0_1px_0_rgba(255,255,255,0.75),inset_0_-1px_0_rgba(255,255,255,0.55),0_6px_18px_rgba(15,23,42,0.08)] backdrop-blur-xl transition-colors hover:bg-white/75 hover:text-gray-700"
>
<X className="h-3.5 w-3.5" />
</button>
@ -151,7 +262,7 @@ export function TREditColumnMenu({
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-1 w-full rounded-md border border-gray-200 px-2 py-1 text-gray-800 text-xs font-normal focus:border-gray-400 focus:outline-none"
className={`mt-1 w-full rounded-lg px-2 py-1 text-xs font-normal text-gray-800 transition-colors focus:bg-white/70 focus:outline-none ${GLASS_FIELD}`}
/>
{/* Format */}
@ -161,7 +272,9 @@ export function TREditColumnMenu({
</label>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="mt-1 flex w-full items-center justify-between rounded-md border border-gray-200 bg-white px-2 py-1 text-xs text-gray-700 hover:border-gray-400 focus:outline-none">
<button
className={`mt-1 flex w-full items-center justify-between rounded-lg px-2 py-1 text-xs text-gray-700 transition-colors hover:bg-white/75 focus:outline-none ${GLASS_FIELD}`}
>
<span className="flex items-center gap-1.5">
{(() => {
const Icon = formatIcon(format);
@ -176,6 +289,7 @@ export function TREditColumnMenu({
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="z-[50] border-white/70 bg-white/75 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)] backdrop-blur-2xl"
style={{
width: "var(--radix-dropdown-menu-trigger-width)",
}}
@ -206,7 +320,9 @@ export function TREditColumnMenu({
{/* Tag input */}
{format === "tag" && (
<div className="mt-2">
<div className="flex flex-wrap gap-1 rounded-md border border-gray-200 px-2 py-1 focus-within:border-gray-400 min-h-[28px]">
<div
className={`flex min-h-[28px] flex-wrap gap-1 rounded-lg px-2 py-1 transition-colors focus-within:bg-white/70 ${GLASS_FIELD}`}
>
{tags.map((tag, tagIdx) => (
<span
key={tag}
@ -269,20 +385,18 @@ export function TREditColumnMenu({
rows={6}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
className="mt-2 w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-normal text-gray-800 placeholder-gray-300 focus:border-gray-400 focus:outline-none resize-none leading-relaxed"
className={`mt-2 w-full resize-none rounded-lg px-3 py-2 text-xs font-normal leading-relaxed text-gray-800 placeholder-gray-300 transition-colors focus:bg-white/70 focus:outline-none ${GLASS_FIELD}`}
/>
</div>
<div className="mt-3 flex items-center justify-between gap-2">
<button
type="button"
<PillButton
tone="danger"
onClick={handleDelete}
disabled={deleting || saving}
className="inline-flex items-center gap-1.5 text-xs text-red-500 transition-colors hover:text-red-600 disabled:text-red-300"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</button>
</PillButton>
<button
type="button"
onClick={handleSave}
@ -298,8 +412,9 @@ export function TREditColumnMenu({
{saving ? "Saving…" : "Save"}
</button>
</div>
</div>
)}
</div>,
document.body,
)}
</div>
);
}

View file

@ -23,7 +23,7 @@ import { getPillClass } from "./pillUtils";
import { PdfView } from "../shared/views/PdfView";
import { SpreadsheetView } from "../shared/views/SpreadsheetView";
import { DocxView } from "../shared/views/DocxView";
import { cn } from "@/lib/utils";
import { cn } from "@/app/lib/utils";
function isDocxDocument(d: {
file_type?: string | null;

View file

@ -1,6 +1,6 @@
"use client";
import { forwardRef, useImperativeHandle, useRef } from "react";
import { forwardRef, useImperativeHandle, useRef, useState } from "react";
import { Loader2, Plus, Table2, Upload } from "lucide-react";
import type {
ColumnConfig,
@ -75,18 +75,31 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
const stickyCellBg = "bg-[#fafbfc]";
const scrollContainerRef = useRef<HTMLDivElement>(null);
const headerRef = useRef<HTMLDivElement>(null);
const lastScrollLeftRef = useRef(0);
const [scrollCloseSignal, setScrollCloseSignal] = useState(0);
function syncHeader() {
if (headerRef.current && scrollContainerRef.current) {
headerRef.current.scrollLeft = scrollContainerRef.current.scrollLeft;
}
}
function handleRowsScroll() {
syncHeader();
const container = scrollContainerRef.current;
if (!container) return;
if (container.scrollLeft !== lastScrollLeftRef.current) {
lastScrollLeftRef.current = container.scrollLeft;
setScrollCloseSignal((signal) => signal + 1);
}
}
const sortedColumns = [...columns].sort((a, b) => a.index - b.index);
const totalContentWidth =
DOC_COL_W_PX + sortedColumns.length * DATA_COL_W_PX + 32;
const skeletonContentWidth =
DOC_COL_W_PX + SKELETON_COLS * DATA_COL_W_PX + 32;
useImperativeHandle(ref, () => ({
scrollToCell(colIdx: number, rowIdx: number) {
const container = scrollContainerRef.current;
@ -269,12 +282,14 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
{columns.map((col) => (
<div
key={col.index}
data-tr-col-header
className={`${COL_W} border-b border-r border-gray-200 p-2 text-left text-xs font-medium text-gray-500 select-none`}
>
<div className="flex items-center justify-between gap-3">
<span className="truncate">{col.name}</span>
<TREditColumnMenu
column={col}
closeSignal={scrollCloseSignal}
disabled={savingColumn || savingColumnsConfig}
onSave={onUpdateColumn}
onDelete={onDeleteColumn}
@ -298,7 +313,7 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
<div
className="flex flex-1 flex-col overflow-auto min-h-0"
ref={scrollContainerRef}
onScroll={syncHeader}
onScroll={handleRowsScroll}
>
<div className="relative min-h-0 flex-1">
{dragOverFiles && (
@ -379,6 +394,7 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
<TabularCellComponent
cell={cell}
column={col}
closeSignal={scrollCloseSignal}
onExpand={() => onExpand(cell)}
onCitationClick={(
page,

View file

@ -12,6 +12,7 @@ import { SkeletonLine } from "../shared/TablePrimitive";
interface Props {
cell: TCell;
column?: ColumnConfig;
closeSignal?: number;
onExpand: () => void;
onCitationClick?: (page: number, quote: string) => void;
}
@ -158,12 +159,19 @@ function CellMarkdown({
export function TabularCell({
cell,
column,
closeSignal,
onExpand,
onCitationClick,
}: Props) {
const [inlineExpanded, setInlineExpanded] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (closeSignal === undefined) return;
const timeout = window.setTimeout(() => setInlineExpanded(false), 0);
return () => window.clearTimeout(timeout);
}, [closeSignal]);
useEffect(() => {
if (!inlineExpanded) return;
function handleClickOutside(e: MouseEvent) {
@ -240,7 +248,7 @@ export function TabularCell({
{/* Inline expanded overlay — absolutely positioned so it overlays without disrupting table layout */}
{inlineExpanded && (
<div className="absolute left-0 top-0 z-50 w-full bg-white border border-gray-200 shadow-lg rounded-sm">
<div className="absolute left-0 top-0 z-50 w-full rounded-xl bg-gray-50/95 shadow-[0_4px_12px_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)] backdrop-blur-2xl">
<div className="relative p-2 pr-4 text-xs text-gray-800 leading-relaxed">
{cell.content.flag && (
<span

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