From f39f175273e398ffa4781b7c4a604bec6de3126e Mon Sep 17 00:00:00 2001 From: willchen96 Date: Wed, 13 May 2026 02:32:26 +0800 Subject: [PATCH] Sync deployment and project page fixes --- backend/nixpacks.toml | 2 +- backend/schema.sql | 708 +-------------- backend/src/lib/llm/gemini.ts | 111 ++- backend/src/routes/projects.ts | 54 ++ backend/src/routes/tabular.ts | 81 +- backend/src/routes/workflows.ts | 11 +- .../projects/ProjectAssistantTab.tsx | 180 ++++ .../app/components/projects/ProjectPage.tsx | 808 +++++++----------- .../components/projects/ProjectPageParts.tsx | 454 ++++++++++ .../components/projects/ProjectReviewsTab.tsx | 205 +++++ .../components/projects/ProjectsOverview.tsx | 49 +- .../components/tabular/TabularReviewView.tsx | 81 +- frontend/src/app/lib/mikeApi.ts | 15 + 13 files changed, 1444 insertions(+), 1315 deletions(-) create mode 100644 frontend/src/app/components/projects/ProjectAssistantTab.tsx create mode 100644 frontend/src/app/components/projects/ProjectPageParts.tsx create mode 100644 frontend/src/app/components/projects/ProjectReviewsTab.tsx diff --git a/backend/nixpacks.toml b/backend/nixpacks.toml index 4d89cbf..9f20b0d 100644 --- a/backend/nixpacks.toml +++ b/backend/nixpacks.toml @@ -1,2 +1,2 @@ [phases.setup] -nixPkgs = ["libreoffice"] +nixPkgs = ["...", "libreoffice"] diff --git a/backend/schema.sql b/backend/schema.sql index cb505e6..cc9b9ce 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -25,18 +25,6 @@ create table if not exists public.user_profiles ( create index if not exists idx_user_profiles_user on public.user_profiles(user_id); -alter table public.user_profiles enable row level security; - -drop policy if exists "Users can view their own profile" on public.user_profiles; -create policy "Users can view their own profile" - on public.user_profiles for select - using (auth.uid() = user_id); - -drop policy if exists "Users can update their own profile" on public.user_profiles; -create policy "Users can update their own profile" - on public.user_profiles for update - using (auth.uid() = user_id); - create or replace function public.handle_new_user() returns trigger language plpgsql @@ -74,8 +62,6 @@ create table if not exists public.user_api_keys ( create index if not exists idx_user_api_keys_user on public.user_api_keys(user_id); -alter table public.user_api_keys enable row level security; - -- --------------------------------------------------------------------------- -- Projects and documents -- --------------------------------------------------------------------------- @@ -354,705 +340,13 @@ create table if not exists public.tabular_review_chat_messages ( create index if not exists tabular_review_chat_messages_chat_idx on public.tabular_review_chat_messages(chat_id, created_at); --- --------------------------------------------------------------------------- --- Row-level security --- --------------------------------------------------------------------------- - -create or replace function public.current_user_id_text() -returns text -language sql -stable -set search_path = public, auth -as $$ - select auth.uid()::text; -$$; - -create or replace function public.current_user_email() -returns text -language sql -stable -set search_path = public, auth -as $$ - select lower(coalesce(auth.jwt() ->> 'email', '')); -$$; - -create or replace function public.email_is_shared(shared_with jsonb) -returns boolean -language sql -stable -set search_path = public -as $$ - select public.current_user_email() <> '' - and exists ( - select 1 - from jsonb_array_elements_text(coalesce(shared_with, '[]'::jsonb)) as emails(email) - where lower(emails.email) = public.current_user_email() - ); -$$; - -create or replace function public.project_is_accessible(target_project_id uuid) -returns boolean -language sql -stable -security definer -set search_path = public, auth -as $$ - select exists ( - select 1 - from public.projects p - where p.id = target_project_id - and ( - p.user_id = public.current_user_id_text() - or public.email_is_shared(p.shared_with) - ) - ); -$$; - -create or replace function public.review_is_accessible(target_review_id uuid) -returns boolean -language sql -stable -security definer -set search_path = public, auth -as $$ - select exists ( - select 1 - from public.tabular_reviews r - where r.id = target_review_id - and ( - r.user_id = public.current_user_id_text() - or public.email_is_shared(r.shared_with) - or ( - r.project_id is not null - and public.project_is_accessible(r.project_id) - ) - ) - ); -$$; - -create or replace function public.workflow_can_view(target_workflow_id uuid) -returns boolean -language sql -stable -security definer -set search_path = public, auth -as $$ - select exists ( - select 1 - from public.workflows w - where w.id = target_workflow_id - and ( - w.is_system - or w.user_id = public.current_user_id_text() - or exists ( - select 1 - from public.workflow_shares s - where s.workflow_id = w.id - and s.shared_with_email = public.current_user_email() - ) - ) - ); -$$; - -create or replace function public.workflow_can_edit(target_workflow_id uuid) -returns boolean -language sql -stable -security definer -set search_path = public, auth -as $$ - select exists ( - select 1 - from public.workflows w - where w.id = target_workflow_id - and ( - w.user_id = public.current_user_id_text() - or exists ( - select 1 - from public.workflow_shares s - where s.workflow_id = w.id - and s.shared_with_email = public.current_user_email() - and s.allow_edit - ) - ) - ); -$$; - -alter table public.user_profiles enable row level security; -alter table public.user_api_keys enable row level security; -alter table public.projects enable row level security; -alter table public.project_subfolders enable row level security; -alter table public.documents enable row level security; -alter table public.document_versions enable row level security; -alter table public.document_edits enable row level security; -alter table public.workflows enable row level security; -alter table public.hidden_workflows enable row level security; -alter table public.workflow_shares enable row level security; -alter table public.chats enable row level security; -alter table public.chat_messages enable row level security; -alter table public.tabular_reviews enable row level security; -alter table public.tabular_cells enable row level security; -alter table public.tabular_review_chats enable row level security; -alter table public.tabular_review_chat_messages enable row level security; - -drop policy if exists "Users can insert their own profile" on public.user_profiles; -create policy "Users can insert their own profile" - on public.user_profiles for insert - with check (auth.uid() = user_id); - -drop policy if exists "Users can view their own profile" on public.user_profiles; -create policy "Users can view their own profile" - on public.user_profiles for select - using (auth.uid() = user_id); - -drop policy if exists "Users can update their own profile" on public.user_profiles; -create policy "Users can update their own profile" - on public.user_profiles for update - using (auth.uid() = user_id) - with check (auth.uid() = user_id); - --- user_api_keys is intentionally service-role only. The browser can only see --- key status through backend routes, never encrypted key material. - -drop policy if exists "Users can view accessible projects" on public.projects; -create policy "Users can view accessible projects" - on public.projects for select - using ( - user_id = public.current_user_id_text() - or public.email_is_shared(shared_with) - ); - -drop policy if exists "Users can insert their own projects" on public.projects; -create policy "Users can insert their own projects" - on public.projects for insert - with check (user_id = public.current_user_id_text()); - -drop policy if exists "Owners can update projects" on public.projects; -create policy "Owners can update projects" - on public.projects for update - using (user_id = public.current_user_id_text()) - with check (user_id = public.current_user_id_text()); - -drop policy if exists "Owners can delete projects" on public.projects; -create policy "Owners can delete projects" - on public.projects for delete - using (user_id = public.current_user_id_text()); - -drop policy if exists "Users can view accessible project folders" on public.project_subfolders; -create policy "Users can view accessible project folders" - on public.project_subfolders for select - using ( - user_id = public.current_user_id_text() - or public.project_is_accessible(project_id) - ); - -drop policy if exists "Users can insert their own project folders" on public.project_subfolders; -create policy "Users can insert their own project folders" - on public.project_subfolders for insert - with check ( - user_id = public.current_user_id_text() - and public.project_is_accessible(project_id) - ); - -drop policy if exists "Owners can update project folders" on public.project_subfolders; -create policy "Owners can update project folders" - on public.project_subfolders for update - using (user_id = public.current_user_id_text()) - with check (user_id = public.current_user_id_text()); - -drop policy if exists "Owners can delete project folders" on public.project_subfolders; -create policy "Owners can delete project folders" - on public.project_subfolders for delete - using (user_id = public.current_user_id_text()); - -drop policy if exists "Users can view accessible documents" on public.documents; -create policy "Users can view accessible documents" - on public.documents for select - using ( - user_id = public.current_user_id_text() - or ( - project_id is not null - and public.project_is_accessible(project_id) - ) - ); - -drop policy if exists "Users can insert their own documents" on public.documents; -create policy "Users can insert their own documents" - on public.documents for insert - with check ( - user_id = public.current_user_id_text() - and ( - project_id is null - or public.project_is_accessible(project_id) - ) - ); - -drop policy if exists "Owners can update documents" on public.documents; -create policy "Owners can update documents" - on public.documents for update - using (user_id = public.current_user_id_text()) - with check (user_id = public.current_user_id_text()); - -drop policy if exists "Owners can delete documents" on public.documents; -create policy "Owners can delete documents" - on public.documents for delete - using (user_id = public.current_user_id_text()); - -drop policy if exists "Users can view accessible document versions" on public.document_versions; -create policy "Users can view accessible document versions" - on public.document_versions for select - using ( - exists ( - select 1 - from public.documents d - where d.id = document_id - and ( - d.user_id = public.current_user_id_text() - or ( - d.project_id is not null - and public.project_is_accessible(d.project_id) - ) - ) - ) - ); - -drop policy if exists "Document owners can insert versions" on public.document_versions; -create policy "Document owners can insert versions" - on public.document_versions for insert - with check ( - exists ( - select 1 - from public.documents d - where d.id = document_id - and d.user_id = public.current_user_id_text() - ) - ); - -drop policy if exists "Document owners can update versions" on public.document_versions; -create policy "Document owners can update versions" - on public.document_versions for update - using ( - exists ( - select 1 - from public.documents d - where d.id = document_id - and d.user_id = public.current_user_id_text() - ) - ) - with check ( - exists ( - select 1 - from public.documents d - where d.id = document_id - and d.user_id = public.current_user_id_text() - ) - ); - -drop policy if exists "Document owners can delete versions" on public.document_versions; -create policy "Document owners can delete versions" - on public.document_versions for delete - using ( - exists ( - select 1 - from public.documents d - where d.id = document_id - and d.user_id = public.current_user_id_text() - ) - ); - -drop policy if exists "Users can view accessible document edits" on public.document_edits; -create policy "Users can view accessible document edits" - on public.document_edits for select - using ( - exists ( - select 1 - from public.documents d - where d.id = document_id - and ( - d.user_id = public.current_user_id_text() - or ( - d.project_id is not null - and public.project_is_accessible(d.project_id) - ) - ) - ) - ); - -drop policy if exists "Document owners can insert edits" on public.document_edits; -create policy "Document owners can insert edits" - on public.document_edits for insert - with check ( - exists ( - select 1 - from public.documents d - where d.id = document_id - and d.user_id = public.current_user_id_text() - ) - ); - -drop policy if exists "Document owners can update edits" on public.document_edits; -create policy "Document owners can update edits" - on public.document_edits for update - using ( - exists ( - select 1 - from public.documents d - where d.id = document_id - and d.user_id = public.current_user_id_text() - ) - ) - with check ( - exists ( - select 1 - from public.documents d - where d.id = document_id - and d.user_id = public.current_user_id_text() - ) - ); - -drop policy if exists "Document owners can delete edits" on public.document_edits; -create policy "Document owners can delete edits" - on public.document_edits for delete - using ( - exists ( - select 1 - from public.documents d - where d.id = document_id - and d.user_id = public.current_user_id_text() - ) - ); - -drop policy if exists "Users can view accessible workflows" on public.workflows; -create policy "Users can view accessible workflows" - on public.workflows for select - using (public.workflow_can_view(id)); - -drop policy if exists "Users can insert their own workflows" on public.workflows; -create policy "Users can insert their own workflows" - on public.workflows for insert - with check (user_id = public.current_user_id_text()); - -drop policy if exists "Workflow owners can update workflows" on public.workflows; -create policy "Workflow owners can update workflows" - on public.workflows for update - using (user_id = public.current_user_id_text()) - with check (user_id = public.current_user_id_text()); - -drop policy if exists "Workflow owners can delete workflows" on public.workflows; -create policy "Workflow owners can delete workflows" - on public.workflows for delete - using (user_id = public.current_user_id_text()); - -drop policy if exists "Users can manage their hidden workflows" on public.hidden_workflows; -create policy "Users can manage their hidden workflows" - on public.hidden_workflows for all - using (user_id = public.current_user_id_text()) - with check (user_id = public.current_user_id_text()); - -drop policy if exists "Users can view relevant workflow shares" on public.workflow_shares; -create policy "Users can view relevant workflow shares" - on public.workflow_shares for select - using ( - shared_by_user_id = public.current_user_id_text() - or shared_with_email = public.current_user_email() - ); - -drop policy if exists "Workflow owners can insert shares" on public.workflow_shares; -create policy "Workflow owners can insert shares" - on public.workflow_shares for insert - with check ( - shared_by_user_id = public.current_user_id_text() - and exists ( - select 1 - from public.workflows w - where w.id = workflow_id - and w.user_id = public.current_user_id_text() - ) - ); - -drop policy if exists "Workflow owners can update shares" on public.workflow_shares; -create policy "Workflow owners can update shares" - on public.workflow_shares for update - using (shared_by_user_id = public.current_user_id_text()) - with check (shared_by_user_id = public.current_user_id_text()); - -drop policy if exists "Workflow owners can delete shares" on public.workflow_shares; -create policy "Workflow owners can delete shares" - on public.workflow_shares for delete - using (shared_by_user_id = public.current_user_id_text()); - -drop policy if exists "Users can view accessible chats" on public.chats; -create policy "Users can view accessible chats" - on public.chats for select - using ( - user_id = public.current_user_id_text() - or ( - project_id is not null - and public.project_is_accessible(project_id) - ) - ); - -drop policy if exists "Users can insert their own chats" on public.chats; -create policy "Users can insert their own chats" - on public.chats for insert - with check ( - user_id = public.current_user_id_text() - and ( - project_id is null - or public.project_is_accessible(project_id) - ) - ); - -drop policy if exists "Chat owners can update chats" on public.chats; -create policy "Chat owners can update chats" - on public.chats for update - using (user_id = public.current_user_id_text()) - with check (user_id = public.current_user_id_text()); - -drop policy if exists "Chat owners can delete chats" on public.chats; -create policy "Chat owners can delete chats" - on public.chats for delete - using (user_id = public.current_user_id_text()); - -drop policy if exists "Users can view accessible chat messages" on public.chat_messages; -create policy "Users can view accessible chat messages" - on public.chat_messages for select - using ( - exists ( - select 1 - from public.chats c - where c.id = chat_id - and ( - c.user_id = public.current_user_id_text() - or ( - c.project_id is not null - and public.project_is_accessible(c.project_id) - ) - ) - ) - ); - -drop policy if exists "Chat owners can insert messages" on public.chat_messages; -create policy "Chat owners can insert messages" - on public.chat_messages for insert - with check ( - exists ( - select 1 - from public.chats c - where c.id = chat_id - and c.user_id = public.current_user_id_text() - ) - ); - -drop policy if exists "Chat owners can update messages" on public.chat_messages; -create policy "Chat owners can update messages" - on public.chat_messages for update - using ( - exists ( - select 1 - from public.chats c - where c.id = chat_id - and c.user_id = public.current_user_id_text() - ) - ) - with check ( - exists ( - select 1 - from public.chats c - where c.id = chat_id - and c.user_id = public.current_user_id_text() - ) - ); - -drop policy if exists "Chat owners can delete messages" on public.chat_messages; -create policy "Chat owners can delete messages" - on public.chat_messages for delete - using ( - exists ( - select 1 - from public.chats c - where c.id = chat_id - and c.user_id = public.current_user_id_text() - ) - ); - -drop policy if exists "Users can view accessible tabular reviews" on public.tabular_reviews; -create policy "Users can view accessible tabular reviews" - on public.tabular_reviews for select - using ( - user_id = public.current_user_id_text() - or public.email_is_shared(shared_with) - or ( - project_id is not null - and public.project_is_accessible(project_id) - ) - ); - -drop policy if exists "Users can insert their own tabular reviews" on public.tabular_reviews; -create policy "Users can insert their own tabular reviews" - on public.tabular_reviews for insert - with check ( - user_id = public.current_user_id_text() - and ( - project_id is null - or public.project_is_accessible(project_id) - ) - ); - -drop policy if exists "Review owners can update tabular reviews" on public.tabular_reviews; -create policy "Review owners can update tabular reviews" - on public.tabular_reviews for update - using (user_id = public.current_user_id_text()) - with check (user_id = public.current_user_id_text()); - -drop policy if exists "Review owners can delete tabular reviews" on public.tabular_reviews; -create policy "Review owners can delete tabular reviews" - on public.tabular_reviews for delete - using (user_id = public.current_user_id_text()); - -drop policy if exists "Users can view accessible tabular cells" on public.tabular_cells; -create policy "Users can view accessible tabular cells" - on public.tabular_cells for select - using (public.review_is_accessible(review_id)); - -drop policy if exists "Review owners can insert tabular cells" on public.tabular_cells; -create policy "Review owners can insert tabular cells" - on public.tabular_cells for insert - with check ( - exists ( - select 1 - from public.tabular_reviews r - where r.id = review_id - and r.user_id = public.current_user_id_text() - ) - ); - -drop policy if exists "Review owners can update tabular cells" on public.tabular_cells; -create policy "Review owners can update tabular cells" - on public.tabular_cells for update - using ( - exists ( - select 1 - from public.tabular_reviews r - where r.id = review_id - and r.user_id = public.current_user_id_text() - ) - ) - with check ( - exists ( - select 1 - from public.tabular_reviews r - where r.id = review_id - and r.user_id = public.current_user_id_text() - ) - ); - -drop policy if exists "Review owners can delete tabular cells" on public.tabular_cells; -create policy "Review owners can delete tabular cells" - on public.tabular_cells for delete - using ( - exists ( - select 1 - from public.tabular_reviews r - where r.id = review_id - and r.user_id = public.current_user_id_text() - ) - ); - -drop policy if exists "Users can view accessible tabular review chats" on public.tabular_review_chats; -create policy "Users can view accessible tabular review chats" - on public.tabular_review_chats for select - using ( - user_id = public.current_user_id_text() - or public.review_is_accessible(review_id) - ); - -drop policy if exists "Users can insert their own tabular review chats" on public.tabular_review_chats; -create policy "Users can insert their own tabular review chats" - on public.tabular_review_chats for insert - with check ( - user_id = public.current_user_id_text() - and public.review_is_accessible(review_id) - ); - -drop policy if exists "Tabular chat owners can update chats" on public.tabular_review_chats; -create policy "Tabular chat owners can update chats" - on public.tabular_review_chats for update - using (user_id = public.current_user_id_text()) - with check (user_id = public.current_user_id_text()); - -drop policy if exists "Tabular chat owners can delete chats" on public.tabular_review_chats; -create policy "Tabular chat owners can delete chats" - on public.tabular_review_chats for delete - using (user_id = public.current_user_id_text()); - -drop policy if exists "Users can view accessible tabular chat messages" on public.tabular_review_chat_messages; -create policy "Users can view accessible tabular chat messages" - on public.tabular_review_chat_messages for select - using ( - exists ( - select 1 - from public.tabular_review_chats c - where c.id = chat_id - and ( - c.user_id = public.current_user_id_text() - or public.review_is_accessible(c.review_id) - ) - ) - ); - -drop policy if exists "Tabular chat owners can insert messages" on public.tabular_review_chat_messages; -create policy "Tabular chat owners can insert messages" - on public.tabular_review_chat_messages for insert - with check ( - exists ( - select 1 - from public.tabular_review_chats c - where c.id = chat_id - and c.user_id = public.current_user_id_text() - ) - ); - -drop policy if exists "Tabular chat owners can update messages" on public.tabular_review_chat_messages; -create policy "Tabular chat owners can update messages" - on public.tabular_review_chat_messages for update - using ( - exists ( - select 1 - from public.tabular_review_chats c - where c.id = chat_id - and c.user_id = public.current_user_id_text() - ) - ) - with check ( - exists ( - select 1 - from public.tabular_review_chats c - where c.id = chat_id - and c.user_id = public.current_user_id_text() - ) - ); - -drop policy if exists "Tabular chat owners can delete messages" on public.tabular_review_chat_messages; -create policy "Tabular chat owners can delete messages" - on public.tabular_review_chat_messages for delete - using ( - exists ( - select 1 - from public.tabular_review_chats c - where c.id = chat_id - and c.user_id = public.current_user_id_text() - ) - ); - -- --------------------------------------------------------------------------- -- Direct client grant hardening -- --------------------------------------------------------------------------- -- -- The frontend uses Supabase directly only for authentication. Application -- data access goes through the backend API with the service role after the --- backend verifies the user's JWT. Keep RLS enabled and policies defined --- above as defense in depth, but do not grant the browser anon/authenticated +-- backend verifies the user's JWT. Do not grant the browser anon/authenticated -- roles direct table privileges for backend-owned data. revoke all on public.user_profiles from anon, authenticated; diff --git a/backend/src/lib/llm/gemini.ts b/backend/src/lib/llm/gemini.ts index 57e62d7..dd7c4d7 100644 --- a/backend/src/lib/llm/gemini.ts +++ b/backend/src/lib/llm/gemini.ts @@ -28,9 +28,64 @@ type GeminiContent = { parts: GeminiPart[]; }; +const RETRYABLE_STATUSES = new Set([429, 500, 502, 503, 504]); +const MAX_GEMINI_ATTEMPTS = 3; + +function apiKey(override?: string | null): string { + const key = override?.trim() || process.env.GEMINI_API_KEY?.trim() || ""; + if (!key) { + throw new Error( + "Gemini API key is not configured. Set GEMINI_API_KEY or add a user Gemini key.", + ); + } + return key; +} + function client(override?: string | null): GoogleGenAI { - const apiKey = override?.trim() || process.env.GEMINI_API_KEY || ""; - return new GoogleGenAI({ apiKey }); + return new GoogleGenAI({ apiKey: apiKey(override) }); +} + +function geminiStatus(err: unknown): number | null { + const status = (err as { status?: unknown })?.status; + return typeof status === "number" ? status : null; +} + +function isRetryableGeminiError(err: unknown): boolean { + const status = geminiStatus(err); + if (status != null && RETRYABLE_STATUSES.has(status)) return true; + + const message = + err instanceof Error ? err.message : typeof err === "string" ? err : ""; + return /UNAVAILABLE|Service Unavailable|high demand|try again later/i.test( + message, + ); +} + +function retryDelayMs(attempt: number): number { + return 400 * 2 ** attempt; +} + +async function sleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function withGeminiRetries(operation: () => Promise): Promise { + let lastError: unknown; + for (let attempt = 0; attempt < MAX_GEMINI_ATTEMPTS; attempt++) { + try { + return await operation(); + } catch (err) { + lastError = err; + const isLastAttempt = attempt === MAX_GEMINI_ATTEMPTS - 1; + if (isLastAttempt || !isRetryableGeminiError(err)) throw err; + console.warn("[gemini] transient error; retrying", { + attempt: attempt + 1, + status: geminiStatus(err), + }); + await sleep(retryDelayMs(attempt)); + } + } + throw lastError; } function toNativeContents(messages: StreamChatParams["messages"]): GeminiContent[] { @@ -52,23 +107,25 @@ export async function streamGemini( let fullText = ""; for (let iter = 0; iter < maxIter; iter++) { - const stream = await ai.models.generateContentStream({ - model, - contents: contents as never, - config: { - systemInstruction: systemPrompt, - tools: functionDeclarations.length - ? [{ functionDeclarations } as never] - : undefined, - // When enabled, ask Gemini to surface thought summaries. - // When disabled, explicitly zero the thinking budget so the - // model skips thinking entirely (saves tokens and latency - // for bulk extraction jobs). - thinkingConfig: enableThinking - ? { includeThoughts: true } - : { thinkingBudget: 0 }, - }, - }); + const stream = await withGeminiRetries(() => + ai.models.generateContentStream({ + model, + contents: contents as never, + config: { + systemInstruction: systemPrompt, + tools: functionDeclarations.length + ? [{ functionDeclarations } as never] + : undefined, + // When enabled, ask Gemini to surface thought summaries. + // When disabled, explicitly zero the thinking budget so the + // model skips thinking entirely (saves tokens and latency + // for bulk extraction jobs). + thinkingConfig: enableThinking + ? { includeThoughts: true } + : { thinkingBudget: 0 }, + }, + }), + ); // Per-iteration accumulators. const textParts: string[] = []; @@ -150,12 +207,14 @@ export async function completeGeminiText(params: { apiKeys?: { gemini?: string | null }; }): Promise { const ai = client(params.apiKeys?.gemini); - const resp = await ai.models.generateContent({ - model: params.model, - contents: [{ role: "user", parts: [{ text: params.user }] }], - config: params.systemPrompt - ? { systemInstruction: params.systemPrompt } - : undefined, - }); + const resp = await withGeminiRetries(() => + ai.models.generateContent({ + model: params.model, + contents: [{ role: "user", parts: [{ text: params.user }] }], + config: params.systemPrompt + ? { systemInstruction: params.systemPrompt } + : undefined, + }), + ); return resp.text ?? ""; } diff --git a/backend/src/routes/projects.ts b/backend/src/routes/projects.ts index d6c7864..58de3c0 100644 --- a/backend/src/routes/projects.ts +++ b/backend/src/routes/projects.ts @@ -14,6 +14,15 @@ import { singleFileUpload } from "../lib/upload"; export const projectsRouter = Router(); const ALLOWED_TYPES = new Set(["pdf", "docx", "doc"]); +function normalizeDocumentFilename(nextName: unknown, currentName: string) { + if (typeof nextName !== "string") return null; + const trimmed = nextName.trim().slice(0, 200); + if (!trimmed) return null; + if (/\.[a-z0-9]{1,6}$/i.test(trimmed)) return trimmed; + const ext = currentName.match(/\.[a-z0-9]{1,6}$/i)?.[0] ?? ""; + return `${trimmed}${ext}`; +} + // GET /projects projectsRouter.get("/", requireAuth, async (req, res) => { const userId = res.locals.userId as string; @@ -437,6 +446,51 @@ projectsRouter.post( }, ); +// PATCH /projects/:projectId/documents/:documentId — rename a project document +projectsRouter.patch("/:projectId/documents/:documentId", requireAuth, async (req, res) => { + const userId = res.locals.userId as string; + const userEmail = res.locals.userEmail as string | undefined; + const { projectId, documentId } = req.params; + const db = createServerSupabase(); + + const access = await checkProjectAccess(projectId, userId, userEmail, db); + if (!access.ok) + return void res.status(404).json({ detail: "Project not found" }); + + const { data: doc } = await db + .from("documents") + .select("id, filename, current_version_id") + .eq("id", documentId) + .eq("project_id", projectId) + .single(); + if (!doc) + return void res.status(404).json({ detail: "Document not found" }); + + const filename = normalizeDocumentFilename(req.body?.filename, doc.filename as string); + if (!filename) + return void res.status(400).json({ detail: "filename is required" }); + + const { data: updated, error } = await db + .from("documents") + .update({ filename, updated_at: new Date().toISOString() }) + .eq("id", documentId) + .eq("project_id", projectId) + .select("*") + .single(); + if (error || !updated) + return void res.status(404).json({ detail: "Document not found" }); + + if (doc.current_version_id) { + await db + .from("document_versions") + .update({ display_name: filename }) + .eq("id", doc.current_version_id) + .eq("document_id", documentId); + } + + res.json(updated); +}); + // POST /projects/:projectId/documents projectsRouter.post( "/:projectId/documents", diff --git a/backend/src/routes/tabular.ts b/backend/src/routes/tabular.ts index 446031a..b7efff6 100644 --- a/backend/src/routes/tabular.ts +++ b/backend/src/routes/tabular.ts @@ -10,8 +10,14 @@ import { type ChatMessage, type TabularCellStore, } from "../lib/chatTools"; -import { completeText, streamChatWithTools } from "../lib/llm"; -import { getUserApiKeys, getUserModelSettings } from "../lib/userSettings"; +import { + completeText, + providerForModel, + streamChatWithTools, + type Provider, + type UserApiKeys, +} from "../lib/llm"; +import { getUserModelSettings } from "../lib/userSettings"; import { checkProjectAccess, ensureReviewAccess, @@ -46,6 +52,22 @@ function formatPromptSuffix(format?: string, tags?: string[]): string { export const tabularRouter = Router(); +function providerLabel(provider: Provider): string { + if (provider === "claude") return "Anthropic"; + if (provider === "openai") return "OpenAI"; + return "Gemini"; +} + +function missingModelApiKey(model: string, apiKeys: UserApiKeys) { + const provider = providerForModel(model); + if (apiKeys[provider]?.trim()) return null; + return { + provider, + model, + detail: `${providerLabel(provider)} API key is required to use ${model}. Add an API key or select a different tabular review model.`, + }; +} + // GET /tabular-review tabularRouter.get("/", requireAuth, async (req, res) => { const userId = res.locals.userId as string; @@ -105,7 +127,7 @@ tabularRouter.get("/", requireAuth, async (req, res) => { ? db .from("tabular_reviews") .select("*") - .contains("shared_with", JSON.stringify([userEmail])) + .filter("shared_with", "cs", JSON.stringify([userEmail])) .neq("user_id", userId) .order("created_at", { ascending: false }) : Promise.resolve({ @@ -697,6 +719,18 @@ tabularRouter.post( return void res.status(404).json({ detail: "Document not found" }); const docActive = await loadActiveVersion(document_id, db); + const { tabular_model, api_keys } = await getUserModelSettings( + userId, + db, + ); + const missingKey = missingModelApiKey(tabular_model, api_keys); + if (missingKey) { + return void res.status(422).json({ + code: "missing_api_key", + ...missingKey, + }); + } + await db .from("tabular_cells") .update({ status: "generating", content: null }) @@ -722,11 +756,7 @@ tabularRouter.post( } } - const { tabular_model, api_keys } = await getUserModelSettings( - userId, - db, - ); - const result = await queryGemini( + const result = await queryTabularCell( tabular_model, doc.filename as string, markdown, @@ -818,6 +848,13 @@ tabularRouter.post("/:reviewId/generate", requireAuth, async (req, res) => { } const { tabular_model, api_keys } = await getUserModelSettings(userId, db); + const missingKey = missingModelApiKey(tabular_model, api_keys); + if (missingKey) { + return void res.status(422).json({ + code: "missing_api_key", + ...missingKey, + }); + } res.setHeader("Content-Type", "text/event-stream"); res.setHeader("Cache-Control", "no-cache"); @@ -883,7 +920,7 @@ tabularRouter.post("/:reviewId/generate", requireAuth, async (req, res) => { // Single LLM call for all columns, streaming one JSON line per column const receivedColumns = new Set(); try { - await queryGeminiAllColumns( + await queryTabularAllColumns( tabular_model, filename, markdown, @@ -907,7 +944,7 @@ tabularRouter.post("/:reviewId/generate", requireAuth, async (req, res) => { ); } catch (err) { console.error( - `[tabular/generate] queryGeminiAllColumns error doc=${docId}`, + `[tabular/generate] queryTabularAllColumns error doc=${docId}`, err, ); } @@ -1209,6 +1246,15 @@ tabularRouter.post("/:reviewId/chat", requireAuth, async (req, res) => { ), }; + const { tabular_model, api_keys } = await getUserModelSettings(userId, db); + const missingKey = missingModelApiKey(tabular_model, api_keys); + if (missingKey) { + return void res.status(422).json({ + code: "missing_api_key", + ...missingKey, + }); + } + // Create or verify chat record let chatId = existingChatId ?? null; let chatTitle: string | null = null; @@ -1266,8 +1312,6 @@ tabularRouter.post("/:reviewId/chat", requireAuth, async (req, res) => { write(`data: ${JSON.stringify({ type: "chat_id", chatId })}\n\n`); } - const apiKeys = await getUserApiKeys(userId, db); - try { const { fullText, events } = await runLLMStream({ apiMessages, @@ -1280,7 +1324,8 @@ tabularRouter.post("/:reviewId/chat", requireAuth, async (req, res) => { tabularStore, buildCitations: (text) => extractTabularAnnotations(text, tabularStore), - apiKeys, + model: tabular_model, + apiKeys: api_keys, }); const annotations = extractTabularAnnotations(fullText, tabularStore); @@ -1308,7 +1353,7 @@ tabularRouter.post("/:reviewId/chat", requireAuth, async (req, res) => { reviewTitle: clientReviewTitle ?? review.title ?? null, projectName: clientProjectName ?? null, }, - apiKeys, + api_keys, ); if (title) { await db @@ -1379,7 +1424,7 @@ function parseCellContent( return null; } -async function queryGemini( +async function queryTabularCell( model: string, filename: string, documentText: string, @@ -1408,7 +1453,7 @@ The "summary" field must contain only the extracted value with inline citations apiKeys, }); } catch (err) { - console.error("[queryGemini] completion failed", err); + console.error("[queryTabularCell] completion failed", err); return null; } try { @@ -1534,7 +1579,7 @@ type Column = { tags?: string[]; }; -async function queryGeminiAllColumns( +async function queryTabularAllColumns( model: string, filename: string, documentText: string, @@ -1619,7 +1664,7 @@ Rules: }, }); } catch (err) { - console.error("[queryGeminiAllColumns] stream failed", err); + console.error("[queryTabularAllColumns] stream failed", err); } if (contentBuffer.trim()) pending.push(processLine(contentBuffer)); diff --git a/backend/src/routes/workflows.ts b/backend/src/routes/workflows.ts index 2ea9728..5f365b3 100644 --- a/backend/src/routes/workflows.ts +++ b/backend/src/routes/workflows.ts @@ -1,16 +1,7 @@ import { Router } from "express"; -import { createClient } from "@supabase/supabase-js"; import { requireAuth } from "../middleware/auth"; import { createServerSupabase } from "../lib/supabase"; -function getAdminClient() { - return createClient( - process.env.NEXT_PUBLIC_SUPABASE_URL ?? "", - process.env.SUPABASE_SECRET_KEY ?? "", - { auth: { autoRefreshToken: false, persistSession: false } }, - ); -} - export const workflowsRouter = Router(); type Db = ReturnType; @@ -113,7 +104,7 @@ workflowsRouter.get("/", requireAuth, async (req, res) => { : { data: [] }; // Fetch sharer emails via admin client - const admin = getAdminClient(); + const admin = createServerSupabase(); const { data: authData } = await admin.auth.admin.listUsers({ perPage: 1000 }); const authUsers = authData?.users ?? []; diff --git a/frontend/src/app/components/projects/ProjectAssistantTab.tsx b/frontend/src/app/components/projects/ProjectAssistantTab.tsx new file mode 100644 index 0000000..1c2212b --- /dev/null +++ b/frontend/src/app/components/projects/ProjectAssistantTab.tsx @@ -0,0 +1,180 @@ +"use client"; + +import { type Dispatch, type SetStateAction } from "react"; +import { MessageSquare } from "lucide-react"; +import { RowActions } from "@/app/components/shared/RowActions"; +import type { MikeChat } from "@/app/components/shared/types"; +import { CHECK_W, formatDate, NAME_COL_W } from "./ProjectPageParts"; + +export function ProjectAssistantTab({ + chats, + filteredChats, + selectedChatIds, + allChatsSelected, + someChatsSelected, + renamingChatId, + renameChatValue, + currentUserId, + onCreateChat, + onOpenChat, + onDeleteChat, + onOwnerOnlyAction, + submitChatRename, + setSelectedChatIds, + setRenamingChatId, + setRenameChatValue, +}: { + chats: MikeChat[]; + filteredChats: MikeChat[]; + selectedChatIds: string[]; + allChatsSelected: boolean; + someChatsSelected: boolean; + renamingChatId: string | null; + renameChatValue: string; + currentUserId?: string | null; + onCreateChat: () => void; + onOpenChat: (chatId: string) => void; + onDeleteChat: (chat: MikeChat) => Promise | void; + onOwnerOnlyAction: (action: string) => void; + submitChatRename: (chatId: string) => Promise | void; + setSelectedChatIds: Dispatch>; + setRenamingChatId: Dispatch>; + setRenameChatValue: Dispatch>; +}) { + return ( + <> +
+
+ { + if (el) el.indeterminate = someChatsSelected; + }} + onChange={() => { + if (allChatsSelected) setSelectedChatIds([]); + else setSelectedChatIds(filteredChats.map((c) => c.id)); + }} + className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black" + /> +
+
+ Chats +
+
Created
+
+
+ {chats.length === 0 ? ( +
+ +

+ Assistant +

+

+ Ask questions and get answers grounded in the documents + in this project. +

+ +
+ ) : ( +
+ {filteredChats.map((chat) => ( +
{ + if (renamingChatId === chat.id) return; + onOpenChat(chat.id); + }} + className="group flex items-center h-10 pr-8 border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors" + > +
e.stopPropagation()} + > + + setSelectedChatIds((prev) => + prev.includes(chat.id) + ? prev.filter((x) => x !== chat.id) + : [...prev, chat.id], + ) + } + className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black" + /> +
+
+ {renamingChatId === chat.id ? ( + + setRenameChatValue(e.target.value) + } + onKeyDown={(e) => { + if (e.key === "Enter") + void submitChatRename(chat.id); + if (e.key === "Escape") + setRenamingChatId(null); + }} + onBlur={() => void submitChatRename(chat.id)} + onClick={(e) => e.stopPropagation()} + className="w-full text-sm text-gray-800 bg-transparent outline-none" + /> + ) : ( + + {chat.title ?? "Untitled Chat"} + + )} +
+
+ {formatDate(chat.created_at)} +
+
e.stopPropagation()} + > + { + if ( + currentUserId && + chat.user_id !== currentUserId + ) { + onOwnerOnlyAction("rename this chat"); + return; + } + setRenameChatValue( + chat.title ?? "Untitled Chat", + ); + setRenamingChatId(chat.id); + }} + onDelete={() => onDeleteChat(chat)} + /> +
+
+ ))} +
+ )} + + ); +} diff --git a/frontend/src/app/components/projects/ProjectPage.tsx b/frontend/src/app/components/projects/ProjectPage.tsx index 690aa3e..5f228ec 100644 --- a/frontend/src/app/components/projects/ProjectPage.tsx +++ b/frontend/src/app/components/projects/ProjectPage.tsx @@ -1,26 +1,17 @@ "use client"; -import { type CSSProperties, useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { Upload, - Plus, Loader2, - FileText, - File, AlertCircle, ChevronDown, ChevronRight, - Download, Folder, FolderOpen, FolderPlus, - MessageSquare, - Pencil, - Table2, - Users, } from "lucide-react"; -import { HeaderSearchBtn } from "@/app/components/shared/HeaderSearchBtn"; import { getProject, deleteDocument, @@ -39,6 +30,7 @@ import { deleteProjectFolder, moveDocumentToFolder, moveSubfolderToFolder, + renameProjectDocument, listDocumentVersions, uploadDocumentVersion, renameDocumentVersion, @@ -53,7 +45,6 @@ import type { TabularReview, } from "@/app/components/shared/types"; import { ToolbarTabs } from "@/app/components/shared/ToolbarTabs"; -import { RenameableTitle } from "@/app/components/shared/RenameableTitle"; import { closeRowActionMenus, RowActionMenuItems, @@ -67,238 +58,26 @@ import { UploadNewVersionModal } from "@/app/components/shared/UploadNewVersionM import { DocViewModal } from "@/app/components/shared/DocViewModal"; import { AddNewTRModal } from "@/app/components/tabular/AddNewTRModal"; import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext"; +import { + CHECK_W, + DOC_NAME_COL_W, + DocIcon, + DocVersionHistory, + formatBytes, + formatDate, + ProjectPageHeader, + ProjectPageSkeleton, + treeControlCellStyle, + treeNameCellStyle, + type ProjectContextMenu, + type ProjectTab, +} from "./ProjectPageParts"; +import { ProjectAssistantTab } from "./ProjectAssistantTab"; +import { ProjectReviewsTab } from "./ProjectReviewsTab"; interface Props { projectId: string; - initialTab?: Tab; -} - -type Tab = "documents" | "assistant" | "reviews"; - -type ContextMenu = { - x: number; - y: number; - docId?: string | null; - folderId: string | null; // null = right-clicked on root/empty space - showFolderActions: boolean; // true when right-clicked on a specific folder row -}; - -const CHECK_W = "w-8 shrink-0"; -const NAME_COL_W = "w-[300px] shrink-0"; -const TREE_CONTROL_WIDTH_PX = 32; -const TREE_NAME_PADDING_PX = 8; - -function treeControlWidth(depth: number) { - return TREE_CONTROL_WIDTH_PX * (Math.max(0, depth) + 1); -} - -function treeControlCellStyle(depth: number): CSSProperties | undefined { - if (depth <= 0) return undefined; - const width = treeControlWidth(depth); - return { - justifyContent: "flex-start", - minWidth: width, - paddingLeft: TREE_NAME_PADDING_PX + depth * TREE_CONTROL_WIDTH_PX, - width, - }; -} - -function treeNameCellStyle(depth: number): CSSProperties | undefined { - if (depth <= 0) return undefined; - return { left: treeControlWidth(depth) }; -} - -function formatBytes(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; -} - -function formatDate(iso: string) { - return new Date(iso).toLocaleDateString(undefined, { - day: "numeric", - month: "short", - year: "numeric", - }); -} - -function DocIcon({ fileType }: { fileType: string | null }) { - if (fileType === "pdf") - return ; - if (fileType === "docx" || fileType === "doc") - return ; - return ; -} - -/** - * Stacked rows rendered beneath a doc row when its Version column is - * expanded. Each row shows a past (or current) version with its number, - * source, date, and a download button that fetches that specific version. - */ -function DocVersionHistory({ - docId, - filename, - loading, - versions, - depth = 0, - onDownloadVersion, - onOpenVersion, - onRenameVersion, -}: { - docId: string; - filename: string; - loading: boolean; - versions: MikeDocumentVersion[]; - depth?: number; - onDownloadVersion: ( - docId: string, - versionId: string, - filename: string, - ) => void; - onOpenVersion?: ( - versionId: string, - versionLabel: string, - ) => void; - onRenameVersion?: ( - versionId: string, - displayName: string | null, - ) => Promise | void; -}) { - const [editingVersionId, setEditingVersionId] = useState( - null, - ); - const [editingValue, setEditingValue] = useState(""); - - const commit = async (versionId: string) => { - const trimmed = editingValue.trim(); - setEditingVersionId(null); - // Empty string → clear override (falls back to V{n}) - const next = trimmed.length > 0 ? trimmed : null; - await onRenameVersion?.(versionId, next); - }; - if (loading && versions.length === 0) { - return ( -
-
-
-
- - Loading versions… -
-
-
- ); - } - if (versions.length === 0) { - return ( -
-
-
-
- No version history. -
-
-
- ); - } - // Most recent version first. - const ordered = [...versions].reverse(); - return ( - <> - {ordered.map((v) => { - const numberLabel = - typeof v.version_number === "number" && v.version_number >= 1 - ? `${v.version_number}` - : v.source === "upload" - ? "Original" - : "—"; - const displayLabel = v.display_name?.trim() || numberLabel; - const dt = new Date(v.created_at); - const dateLabel = Number.isNaN(dt.valueOf()) - ? "" - : dt.toLocaleString(undefined, { - month: "short", - day: "numeric", - year: "numeric", - hour: "numeric", - minute: "2-digit", - }); - const isEditing = editingVersionId === v.id; - return ( -
{ - if (isEditing) return; - onOpenVersion?.(v.id, displayLabel); - }} - className="group flex items-center h-9 pr-8 border-b border-gray-50 bg-gray-50/60 text-xs text-gray-600 cursor-pointer hover:bg-gray-100/80 transition-colors" - > -
-
-
- - {isEditing ? ( - e.stopPropagation()} - onChange={(e) => - setEditingValue(e.target.value) - } - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - void commit(v.id); - } else if (e.key === "Escape") { - setEditingVersionId(null); - } - }} - onBlur={() => void commit(v.id)} - className="min-w-0 flex-1 max-w-[240px] border-b border-gray-300 bg-transparent text-xs text-gray-800 outline-none focus:border-gray-500" - /> - ) : ( - - {displayLabel} - - )} - {!isEditing && onRenameVersion && ( - - )} - {dateLabel} - · - {v.source} -
-
-
-
-
-
- -
-
- ); - })} - - ); + initialTab?: ProjectTab; } export function ProjectPage({ projectId, initialTab = "documents" }: Props) { @@ -309,7 +88,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { const [loading, setLoading] = useState(true); const searchParams = useSearchParams(); const tabParam = searchParams.get("tab"); - const tab: Tab = + const tab: ProjectTab = tabParam === "assistant" || tabParam === "reviews" ? tabParam : initialTab; @@ -471,6 +250,8 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { const [renameChatValue, setRenameChatValue] = useState(""); const [renamingReviewId, setRenamingReviewId] = useState(null); const [renameReviewValue, setRenameReviewValue] = useState(""); + const [renamingDocumentId, setRenamingDocumentId] = useState(null); + const [renameDocumentValue, setRenameDocumentValue] = useState(""); // Folder state const [expandedFolderIds, setExpandedFolderIds] = useState>(new Set()); @@ -479,7 +260,8 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { const [newFolderName, setNewFolderName] = useState(""); const [renamingFolderId, setRenamingFolderId] = useState(null); const [renameFolderValue, setRenameFolderValue] = useState(""); - const [contextMenu, setContextMenu] = useState(null); + const [contextMenu, setContextMenu] = + useState(null); const contextMenuRef = useRef(null); const newFolderInputRef = useRef(null); const [dragOverFolderId, setDragOverFolderId] = useState(null); @@ -493,7 +275,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { const router = useRouter(); const { saveChat } = useChatHistoryContext(); - function handleTabChange(newTab: Tab) { + function handleTabChange(newTab: ProjectTab) { const base = `/projects/${projectId}`; const url = newTab === "documents" ? base : `${base}?tab=${newTab}`; router.push(url); @@ -657,6 +439,56 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { await moveDocumentToFolder(projectId, docId, null); } + async function submitDocumentRename(docId: string) { + const trimmed = renameDocumentValue.trim(); + setRenamingDocumentId(null); + if (!trimmed) return; + const previous = project?.documents?.find((d) => d.id === docId); + if (!previous || trimmed === previous.filename) return; + + setProject((prev) => + prev + ? { + ...prev, + documents: (prev.documents ?? []).map((d) => + d.id === docId + ? { + ...d, + filename: trimmed, + updated_at: new Date().toISOString(), + } + : d, + ), + } + : prev, + ); + try { + const updated = await renameProjectDocument(projectId, docId, trimmed); + setProject((prev) => + prev + ? { + ...prev, + documents: (prev.documents ?? []).map((d) => + d.id === docId ? { ...d, ...updated } : d, + ), + } + : prev, + ); + } catch (e) { + console.error("renameProjectDocument failed", e); + setProject((prev) => + prev && previous + ? { + ...prev, + documents: (prev.documents ?? []).map((d) => + d.id === docId ? previous : d, + ), + } + : prev, + ); + } + } + async function handleRemoveDoc(docId: string) { const doc = project?.documents?.find((d) => d.id === docId); // Backend only lets the doc creator delete. Warn the requester @@ -836,6 +668,24 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { } } + async function handleDeleteChatRow(chat: MikeChat) { + if (user?.id && chat.user_id !== user.id) { + setOwnerOnlyAction("delete this chat"); + return; + } + await deleteChat(chat.id); + setChats((prev) => prev.filter((c) => c.id !== chat.id)); + } + + async function handleDeleteReviewRow(review: TabularReview) { + if (user?.id && review.user_id !== user.id) { + setOwnerOnlyAction("delete this tabular review"); + return; + } + await deleteTabularReview(review.id); + setProjectReviews((prev) => prev.filter((r) => r.id !== review.id)); + } + // ── Drag & drop ─────────────────────────────────────────────────────────── function wouldCreateCycle(movingId: string, targetId: string): boolean { @@ -849,7 +699,16 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { return false; } + function hasMovePayload(dt: DataTransfer): boolean { + return Array.from(dt.types).some( + (type) => + type === "application/mike-doc" || + type === "application/mike-folder", + ); + } + async function handleDropOnFolder(targetFolderId: string | null, dt: DataTransfer) { + if (!hasMovePayload(dt)) return; const docId = dt.getData("application/mike-doc"); const subFolderId = dt.getData("application/mike-folder"); if (docId) { @@ -890,7 +749,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
@@ -938,8 +797,12 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { return (
{ + if (renamingDocumentId === doc.id) { + e.preventDefault(); + return; + } e.dataTransfer.setData("application/mike-doc", doc.id); e.dataTransfer.effectAllowed = "move"; }} @@ -985,7 +848,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black" />
-
+
{isProcessing ? ( @@ -994,7 +857,40 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { ) : ( )} - {doc.filename} + {renamingDocumentId === doc.id ? ( + e.stopPropagation()} + onDragStart={(e) => { + e.preventDefault(); + e.stopPropagation(); + }} + onChange={(e) => + setRenameDocumentValue( + e.target.value, + ) + } + onKeyDown={(e) => { + if (e.key === "Enter") + void submitDocumentRename( + doc.id, + ); + if (e.key === "Escape") { + setRenamingDocumentId(null); + setRenameDocumentValue(""); + } + }} + onBlur={() => + void submitDocumentRename( + doc.id, + ) + } + /> + ) : ( + {doc.filename} + )}
@@ -1032,6 +928,11 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
{!isProcessing && ( { + setRenameDocumentValue(doc.filename); + setRenamingDocumentId(doc.id); + }} + renameLabel="Rename document" onDownload={() => downloadDoc(doc.id)} onShowAllVersions={ hasVersions && !isVersionsOpen @@ -1078,15 +979,31 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { return (
{ + if (isRenaming) { + e.preventDefault(); + return; + } e.dataTransfer.setData("application/mike-folder", folder.id); e.dataTransfer.effectAllowed = "move"; e.stopPropagation(); }} - onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); setDragOverFolderId(folder.id); }} + onDragOver={(e) => { + if (!hasMovePayload(e.dataTransfer)) return; + e.preventDefault(); + e.stopPropagation(); + setDragOverFolderId(folder.id); + }} onDragLeave={(e) => { e.stopPropagation(); setDragOverFolderId(null); }} - onDrop={async (e) => { e.preventDefault(); e.stopPropagation(); setDragOverFolderId(null); setDragOverRoot(false); await handleDropOnFolder(folder.id, e.dataTransfer); }} + onDrop={async (e) => { + if (!hasMovePayload(e.dataTransfer)) return; + e.preventDefault(); + e.stopPropagation(); + setDragOverFolderId(null); + setDragOverRoot(false); + await handleDropOnFolder(folder.id, e.dataTransfer); + }} onClick={() => toggleFolder(folder.id)} onContextMenu={(e) => { e.preventDefault(); @@ -1094,7 +1011,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { closeRowActionMenus(); setContextMenu({ x: e.clientX, y: e.clientY, folderId: folder.id, showFolderActions: true }); }} - className={`group flex items-center h-10 pr-8 border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors select-none ${dragOverFolderId === folder.id ? "bg-blue-50 ring-1 ring-inset ring-blue-200" : ""}`} + className={`group flex items-center h-10 pr-8 border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors ${isRenaming ? "" : "select-none"} ${dragOverFolderId === folder.id ? "bg-blue-50 ring-1 ring-inset ring-blue-200" : ""}`} >
{isExpanded @@ -1102,7 +1019,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { : }
-
+
{isExpanded ? @@ -1113,6 +1030,10 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { autoFocus className="flex-1 min-w-0 text-sm text-gray-800 bg-transparent outline-none" value={renameFolderValue} + onDragStart={(e) => { + e.preventDefault(); + e.stopPropagation(); + }} onChange={(e) => setRenameFolderValue(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") void handleRenameFolder(folder.id); @@ -1157,44 +1078,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { // ── Loading skeleton ────────────────────────────────────────────────────── - if (loading) { - return ( -
-
-
- Projects - -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {[1, 2, 3, 4, 5].map((i) => ( -
-
-
-
-
-
-
- ))} -
- ); - } + if (loading) return ; if (!project) { return ( @@ -1291,79 +1175,21 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { return (
- {/* Page header */} -
-
-
- - - {tab !== "documents" ? ( - - ) : ( - (#{project.cm_number}) : null} - /> - )} - {tab !== "documents" && ( - <> - - {tab === "assistant" ? "Assistant" : "Tabular Reviews"} - - )} -
-
-
- - -
- -
-
- - {docs.length === 0 && ( -
- Upload a document first -
- )} -
-
-
+ router.push("/projects")} + onOpenDocuments={() => router.push(`/projects/${projectId}`)} + onTitleCommit={handleTitleCommit} + onSearchChange={setSearch} + onOpenPeople={() => setPeopleModalOpen(true)} + onNewChat={handleNewChat} + onNewReview={handleNewReview} + />
-
+
Name
Type
@@ -1436,13 +1262,18 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { setContextMenu({ x: e.clientX, y: e.clientY, folderId: null, showFolderActions: false }); }} onClick={() => setContextMenu(null)} - onDragOver={(e) => { e.preventDefault(); setDragOverRoot(true); }} + onDragOver={(e) => { + if (!hasMovePayload(e.dataTransfer)) return; + e.preventDefault(); + setDragOverRoot(true); + }} onDragLeave={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node)) { setDragOverRoot(false); } }} onDrop={async (e) => { + if (!hasMovePayload(e.dataTransfer)) return; e.preventDefault(); setDragOverRoot(false); setDragOverFolderId(null); @@ -1487,10 +1318,43 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black" />
-
+
{isProcessing ? : isError ? : } - {doc.filename} + {renamingDocumentId === doc.id ? ( + e.stopPropagation()} + onDragStart={(e) => { + e.preventDefault(); + e.stopPropagation(); + }} + onChange={(e) => + setRenameDocumentValue( + e.target.value, + ) + } + onKeyDown={(e) => { + if (e.key === "Enter") + void submitDocumentRename( + doc.id, + ); + if (e.key === "Escape") { + setRenamingDocumentId(null); + setRenameDocumentValue(""); + } + }} + onBlur={() => + void submitDocumentRename( + doc.id, + ) + } + /> + ) : ( + {doc.filename} + )}
{doc.file_type ?? }
@@ -1524,6 +1388,11 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
{!isProcessing && ( { + setRenameDocumentValue(doc.filename); + setRenamingDocumentId(doc.id); + }} + renameLabel="Rename document" onDownload={() => downloadDoc(doc.id)} onShowAllVersions={ hasVersions && !isVersionsOpen @@ -1588,6 +1457,15 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { {menuDoc ? ( setContextMenu(null)} + onRename={() => { + setRenameDocumentValue( + menuDoc.filename, + ); + setRenamingDocumentId( + menuDoc.id, + ); + }} + renameLabel="Rename document" onDownload={() => downloadDoc(menuDoc.id)} onShowAllVersions={ menuDocHasVersions && !menuDocVersionsOpen @@ -1674,160 +1552,56 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { {/* Tab: Assistant */} {tab === "assistant" && ( - <> -
-
- { if (el) el.indeterminate = someChatsSelected; }} - onChange={() => { - if (allChatsSelected) setSelectedChatIds([]); - else setSelectedChatIds(filteredChats.map((c) => c.id)); - }} - className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black" - /> -
-
- Chats -
-
Created
-
-
- {chats.length === 0 ? ( -
- -

Assistant

-

Ask questions and get answers grounded in the documents in this project.

- -
- ) : ( -
- {filteredChats.map((chat) => ( -
{ if (renamingChatId === chat.id) return; router.push(`/projects/${projectId}/assistant/chat/${chat.id}`); }} - className="group flex items-center h-10 pr-8 border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors" - > -
e.stopPropagation()}> - setSelectedChatIds((prev) => prev.includes(chat.id) ? prev.filter((x) => x !== chat.id) : [...prev, chat.id])} className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black" /> -
-
- {renamingChatId === chat.id ? ( - setRenameChatValue(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") submitChatRename(chat.id); if (e.key === "Escape") setRenamingChatId(null); }} onBlur={() => submitChatRename(chat.id)} onClick={(e) => e.stopPropagation()} className="w-full text-sm text-gray-800 bg-transparent outline-none" /> - ) : ( - {chat.title ?? "Untitled Chat"} - )} -
-
{formatDate(chat.created_at)}
-
e.stopPropagation()}> - { - if (user?.id && chat.user_id !== user.id) { - setOwnerOnlyAction("rename this chat"); - return; - } - setRenameChatValue(chat.title ?? "Untitled Chat"); - setRenamingChatId(chat.id); - }} - onDelete={async () => { - if (user?.id && chat.user_id !== user.id) { - setOwnerOnlyAction("delete this chat"); - return; - } - await deleteChat(chat.id); - setChats((prev) => prev.filter((c) => c.id !== chat.id)); - }} - /> -
-
- ))} -
- )} - + + router.push( + `/projects/${projectId}/assistant/chat/${chatId}`, + ) + } + onDeleteChat={handleDeleteChatRow} + onOwnerOnlyAction={setOwnerOnlyAction} + submitChatRename={submitChatRename} + setSelectedChatIds={setSelectedChatIds} + setRenamingChatId={setRenamingChatId} + setRenameChatValue={setRenameChatValue} + /> )} {/* Tab: Reviews */} {tab === "reviews" && ( - <> -
-
- { if (el) el.indeterminate = someReviewsSelected; }} - onChange={() => { - if (allReviewsSelected) setSelectedReviewIds([]); - else setSelectedReviewIds(filteredReviews.map((r) => r.id)); - }} - className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black" - /> -
-
- Name -
-
Columns
-
Documents
-
Created
-
-
- {projectReviews.length === 0 ? ( -
- -

Tabular Reviews

-

Extract data from project documents into tables using AI.

- -
- ) : ( -
- {filteredReviews.map((review) => ( -
{ if (renamingReviewId === review.id) return; router.push(`/projects/${projectId}/tabular-reviews/${review.id}`); }} - className="group flex items-center h-10 pr-8 border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors" - > -
e.stopPropagation()}> - setSelectedReviewIds((prev) => prev.includes(review.id) ? prev.filter((x) => x !== review.id) : [...prev, review.id])} className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black" /> -
-
- {renamingReviewId === review.id ? ( - setRenameReviewValue(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") submitReviewRename(review.id); if (e.key === "Escape") setRenamingReviewId(null); }} onBlur={() => submitReviewRename(review.id)} onClick={(e) => e.stopPropagation()} className="w-full text-sm text-gray-800 bg-transparent outline-none" /> - ) : ( - {review.title ?? "Untitled Review"} - )} -
-
{review.columns_config?.length ?? 0}
-
{review.document_count ?? 0}
-
{review.created_at ? formatDate(review.created_at) : }
-
e.stopPropagation()}> - { - if (user?.id && review.user_id !== user.id) { - setOwnerOnlyAction("rename this tabular review"); - return; - } - setRenameReviewValue(review.title ?? "Untitled Review"); - setRenamingReviewId(review.id); - }} - onDelete={async () => { - if (user?.id && review.user_id !== user.id) { - setOwnerOnlyAction("delete this tabular review"); - return; - } - await deleteTabularReview(review.id); - setProjectReviews((prev) => prev.filter((r) => r.id !== review.id)); - }} - /> -
-
- ))} -
- )} - + + router.push( + `/projects/${projectId}/tabular-reviews/${reviewId}`, + ) + } + onDeleteReview={handleDeleteReviewRow} + onOwnerOnlyAction={setOwnerOnlyAction} + submitReviewRename={submitReviewRename} + setSelectedReviewIds={setSelectedReviewIds} + setRenamingReviewId={setRenamingReviewId} + setRenameReviewValue={setRenameReviewValue} + /> )}
diff --git a/frontend/src/app/components/projects/ProjectPageParts.tsx b/frontend/src/app/components/projects/ProjectPageParts.tsx new file mode 100644 index 0000000..3de9cab --- /dev/null +++ b/frontend/src/app/components/projects/ProjectPageParts.tsx @@ -0,0 +1,454 @@ +"use client"; + +import { type CSSProperties, useState } from "react"; +import { + Download, + File, + FileText, + Loader2, + Pencil, + Plus, + Users, +} from "lucide-react"; +import { HeaderSearchBtn } from "@/app/components/shared/HeaderSearchBtn"; +import { RenameableTitle } from "@/app/components/shared/RenameableTitle"; +import type { MikeProject } from "@/app/components/shared/types"; +import type { MikeDocumentVersion } from "@/app/lib/mikeApi"; + +export type ProjectTab = "documents" | "assistant" | "reviews"; + +export type ProjectContextMenu = { + x: number; + y: number; + docId?: string | null; + folderId: string | null; + showFolderActions: boolean; +}; + +export const CHECK_W = "w-8 shrink-0"; +export const NAME_COL_W = "w-[300px] shrink-0"; +export const DOC_NAME_COL_W = + "w-[260px] sm:w-[300px] md:w-[360px] lg:w-[420px] xl:w-[500px] 2xl:w-[560px] shrink-0"; + +const TREE_CONTROL_WIDTH_PX = 32; +const TREE_NAME_PADDING_PX = 8; + +function treeControlWidth(depth: number) { + return TREE_CONTROL_WIDTH_PX * (Math.max(0, depth) + 1); +} + +export function treeControlCellStyle( + depth: number, +): CSSProperties | undefined { + if (depth <= 0) return undefined; + const width = treeControlWidth(depth); + return { + justifyContent: "flex-start", + minWidth: width, + paddingLeft: TREE_NAME_PADDING_PX + depth * TREE_CONTROL_WIDTH_PX, + width, + }; +} + +export function treeNameCellStyle(depth: number): CSSProperties | undefined { + if (depth <= 0) return undefined; + return { left: treeControlWidth(depth) }; +} + +export function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +export function formatDate(iso: string) { + return new Date(iso).toLocaleDateString(undefined, { + day: "numeric", + month: "short", + year: "numeric", + }); +} + +export function DocIcon({ fileType }: { fileType: string | null }) { + if (fileType === "pdf") + return ; + if (fileType === "docx" || fileType === "doc") + return ; + return ; +} + +export function DocVersionHistory({ + docId, + filename, + loading, + versions, + depth = 0, + onDownloadVersion, + onOpenVersion, + onRenameVersion, +}: { + docId: string; + filename: string; + loading: boolean; + versions: MikeDocumentVersion[]; + depth?: number; + onDownloadVersion: ( + docId: string, + versionId: string, + filename: string, + ) => void; + onOpenVersion?: (versionId: string, versionLabel: string) => void; + onRenameVersion?: ( + versionId: string, + displayName: string | null, + ) => Promise | void; +}) { + const [editingVersionId, setEditingVersionId] = useState( + null, + ); + const [editingValue, setEditingValue] = useState(""); + + const commit = async (versionId: string) => { + const trimmed = editingValue.trim(); + setEditingVersionId(null); + const next = trimmed.length > 0 ? trimmed : null; + await onRenameVersion?.(versionId, next); + }; + + if (loading && versions.length === 0) { + return ( +
+
+
+
+ + Loading versions… +
+
+
+ ); + } + + if (versions.length === 0) { + return ( +
+
+
+
No version history.
+
+
+ ); + } + + const ordered = [...versions].reverse(); + return ( + <> + {ordered.map((v) => { + const numberLabel = + typeof v.version_number === "number" && v.version_number >= 1 + ? `${v.version_number}` + : v.source === "upload" + ? "Original" + : "—"; + const displayLabel = v.display_name?.trim() || numberLabel; + const dt = new Date(v.created_at); + const dateLabel = Number.isNaN(dt.valueOf()) + ? "" + : dt.toLocaleString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", + }); + const isEditing = editingVersionId === v.id; + + return ( +
{ + if (isEditing) return; + onOpenVersion?.(v.id, displayLabel); + }} + className="group flex items-center h-9 pr-8 border-b border-gray-50 bg-gray-50/60 text-xs text-gray-600 cursor-pointer hover:bg-gray-100/80 transition-colors" + > +
+
+
+ + {isEditing ? ( + e.stopPropagation()} + onChange={(e) => + setEditingValue(e.target.value) + } + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void commit(v.id); + } else if (e.key === "Escape") { + setEditingVersionId(null); + } + }} + onBlur={() => void commit(v.id)} + className="min-w-0 flex-1 max-w-[240px] border-b border-gray-300 bg-transparent text-xs text-gray-800 outline-none focus:border-gray-500" + /> + ) : ( + + {displayLabel} + + )} + {!isEditing && onRenameVersion && ( + + )} + + {dateLabel} + + · + + {v.source} + +
+
+
+
+
+
+ +
+
+ ); + })} + + ); +} + +export function ProjectPageSkeleton() { + return ( +
+
+
+ Projects + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {[1, 2, 3, 4, 5].map((i) => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ ); +} + +export function ProjectPageHeader({ + project, + tab, + search, + creatingChat, + creatingReview, + docsCount, + onBackToProjects, + onOpenDocuments, + onTitleCommit, + onSearchChange, + onOpenPeople, + onNewChat, + onNewReview, +}: { + project: MikeProject; + tab: ProjectTab; + search: string; + creatingChat: boolean; + creatingReview: boolean; + docsCount: number; + onBackToProjects: () => void; + onOpenDocuments: () => void; + onTitleCommit: (newName: string) => void | Promise; + onSearchChange: (search: string) => void; + onOpenPeople: () => void; + onNewChat: () => void; + onNewReview: () => void; +}) { + return ( +
+
+
+ + + {tab !== "documents" ? ( + + ) : ( + + (#{project.cm_number}) + + ) : null + } + /> + )} + {tab !== "documents" && ( + <> + + + {tab === "assistant" + ? "Assistant" + : "Tabular Reviews"} + + + )} +
+
+
+ + +
+ +
+
+ + {docsCount === 0 && ( +
+ Upload a document first +
+ )} +
+
+
+ ); +} diff --git a/frontend/src/app/components/projects/ProjectReviewsTab.tsx b/frontend/src/app/components/projects/ProjectReviewsTab.tsx new file mode 100644 index 0000000..b9dc4b6 --- /dev/null +++ b/frontend/src/app/components/projects/ProjectReviewsTab.tsx @@ -0,0 +1,205 @@ +"use client"; + +import { type Dispatch, type SetStateAction } from "react"; +import { Table2 } from "lucide-react"; +import { RowActions } from "@/app/components/shared/RowActions"; +import type { MikeDocument, TabularReview } from "@/app/components/shared/types"; +import { CHECK_W, formatDate, NAME_COL_W } from "./ProjectPageParts"; + +export function ProjectReviewsTab({ + docs, + reviews, + filteredReviews, + selectedReviewIds, + allReviewsSelected, + someReviewsSelected, + renamingReviewId, + renameReviewValue, + creatingReview, + currentUserId, + onCreateReview, + onOpenReview, + onDeleteReview, + onOwnerOnlyAction, + submitReviewRename, + setSelectedReviewIds, + setRenamingReviewId, + setRenameReviewValue, +}: { + docs: MikeDocument[]; + reviews: TabularReview[]; + filteredReviews: TabularReview[]; + selectedReviewIds: string[]; + allReviewsSelected: boolean; + someReviewsSelected: boolean; + renamingReviewId: string | null; + renameReviewValue: string; + creatingReview: boolean; + currentUserId?: string | null; + onCreateReview: () => void; + onOpenReview: (reviewId: string) => void; + onDeleteReview: (review: TabularReview) => Promise | void; + onOwnerOnlyAction: (action: string) => void; + submitReviewRename: (reviewId: string) => Promise | void; + setSelectedReviewIds: Dispatch>; + setRenamingReviewId: Dispatch>; + setRenameReviewValue: Dispatch>; +}) { + return ( + <> +
+
+ { + if (el) el.indeterminate = someReviewsSelected; + }} + onChange={() => { + if (allReviewsSelected) setSelectedReviewIds([]); + else + setSelectedReviewIds( + filteredReviews.map((r) => r.id), + ); + }} + className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black" + /> +
+
+ Name +
+
Columns
+
Documents
+
Created
+
+
+ {reviews.length === 0 ? ( +
+ +

+ Tabular Reviews +

+

+ Extract data from project documents into tables using AI. +

+ +
+ ) : ( +
+ {filteredReviews.map((review) => ( +
{ + if (renamingReviewId === review.id) return; + onOpenReview(review.id); + }} + className="group flex items-center h-10 pr-8 border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors" + > +
e.stopPropagation()} + > + + setSelectedReviewIds((prev) => + prev.includes(review.id) + ? prev.filter( + (x) => x !== review.id, + ) + : [...prev, review.id], + ) + } + className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black" + /> +
+
+ {renamingReviewId === review.id ? ( + + setRenameReviewValue(e.target.value) + } + onKeyDown={(e) => { + if (e.key === "Enter") + void submitReviewRename(review.id); + if (e.key === "Escape") + setRenamingReviewId(null); + }} + onBlur={() => + void submitReviewRename(review.id) + } + onClick={(e) => e.stopPropagation()} + className="w-full text-sm text-gray-800 bg-transparent outline-none" + /> + ) : ( + + {review.title ?? "Untitled Review"} + + )} +
+
+ {review.columns_config?.length ?? 0} +
+
+ {review.document_count ?? 0} +
+
+ {review.created_at ? ( + formatDate(review.created_at) + ) : ( + + )} +
+
e.stopPropagation()} + > + { + if ( + currentUserId && + review.user_id !== currentUserId + ) { + onOwnerOnlyAction( + "rename this tabular review", + ); + return; + } + setRenameReviewValue( + review.title ?? "Untitled Review", + ); + setRenamingReviewId(review.id); + }} + onDelete={() => onDeleteReview(review)} + /> +
+
+ ))} +
+ )} + + ); +} diff --git a/frontend/src/app/components/projects/ProjectsOverview.tsx b/frontend/src/app/components/projects/ProjectsOverview.tsx index b84d32e..756f839 100644 --- a/frontend/src/app/components/projects/ProjectsOverview.tsx +++ b/frontend/src/app/components/projects/ProjectsOverview.tsx @@ -28,6 +28,7 @@ const NAME_COL_W = "w-[300px] shrink-0"; export function ProjectsOverview() { const [projects, setProjects] = useState([]); const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); const [modalOpen, setModalOpen] = useState(false); const [activeTab, setActiveTab] = useState("all"); const [renamingId, setRenamingId] = useState(null); @@ -40,14 +41,42 @@ export function ProjectsOverview() { const [ownerOnlyAction, setOwnerOnlyAction] = useState(null); const actionsRef = useRef(null); const router = useRouter(); - const { user } = useAuth(); + const { user, isAuthenticated, authLoading } = useAuth(); useEffect(() => { + if (authLoading) { + setLoading(true); + return; + } + if (!isAuthenticated) { + setProjects([]); + setLoadError(null); + setLoading(false); + return; + } + + let cancelled = false; + setLoading(true); + setLoadError(null); listProjects() - .then(setProjects) - .catch(() => setProjects([])) - .finally(() => setLoading(false)); - }, []); + .then((loaded) => { + if (!cancelled) setProjects(loaded); + }) + .catch((err) => { + console.error("[projects] failed to load projects", err); + if (!cancelled) { + setProjects([]); + setLoadError("Could not load projects."); + } + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [authLoading, isAuthenticated, user?.id]); useEffect(() => { setSelectedIds([]); @@ -263,6 +292,16 @@ export function ProjectsOverview() {
))}
+ ) : loadError ? ( +
+ +

+ Projects +

+

+ {loadError} +

+
) : filtered.length === 0 ? (
{activeTab === "all" || activeTab === "mine" ? ( diff --git a/frontend/src/app/components/tabular/TabularReviewView.tsx b/frontend/src/app/components/tabular/TabularReviewView.tsx index 2ac834a..4bdaecb 100644 --- a/frontend/src/app/components/tabular/TabularReviewView.tsx +++ b/frontend/src/app/components/tabular/TabularReviewView.tsx @@ -189,6 +189,11 @@ export function TRView({ reviewId, projectId }: Props) { } async function handleRegenerateCell(docId: string, colIndex: number) { + if (apiKeys && !isModelAvailable(tabularModel, apiKeys)) { + setApiKeyModalProvider(getModelProvider(tabularModel)); + return; + } + setCells((prev) => prev.map((c) => c.document_id === docId && c.column_index === colIndex @@ -247,41 +252,55 @@ export function TRView({ reviewId, projectId }: Props) { setGenerating(true); - // Optimistically set empty/pending/error cells to generating (skip done cells) - setCells((prev) => - documents.flatMap((doc) => - columns.map((col) => { - const existing = prev.find( - (c) => - c.document_id === doc.id && - c.column_index === col.index, - ); - if (existing?.status === "done" && existing?.content) { - return existing; - } - return existing - ? { - ...existing, - status: "generating" as const, - content: null, - } - : { - id: `${doc.id}-${col.index}`, - review_id: reviewId, - document_id: doc.id, - column_index: col.index, - content: null, - status: "generating" as const, - created_at: new Date().toISOString(), - }; - }), - ), - ); - try { const response = await streamTabularGeneration(reviewId); + if (!response.ok) { + const payload = await response.json().catch(() => null); + const provider = + payload && + ["claude", "gemini", "openai"].includes(payload.provider) + ? (payload.provider as ModelProvider) + : getModelProvider(tabularModel); + if (payload?.code === "missing_api_key" && provider) { + setApiKeyModalProvider(provider); + } + throw new Error( + payload?.detail ?? `Generation failed: ${response.status}`, + ); + } if (!response.body) throw new Error("No body"); + // Optimistically set empty/pending/error cells to generating (skip done cells) + setCells((prev) => + documents.flatMap((doc) => + columns.map((col) => { + const existing = prev.find( + (c) => + c.document_id === doc.id && + c.column_index === col.index, + ); + if (existing?.status === "done" && existing?.content) { + return existing; + } + return existing + ? { + ...existing, + status: "generating" as const, + content: null, + } + : { + id: `${doc.id}-${col.index}`, + review_id: reviewId, + document_id: doc.id, + column_index: col.index, + content: null, + status: "generating" as const, + created_at: new Date().toISOString(), + }; + }), + ), + ); + const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; diff --git a/frontend/src/app/lib/mikeApi.ts b/frontend/src/app/lib/mikeApi.ts index d574fea..eeea891 100644 --- a/frontend/src/app/lib/mikeApi.ts +++ b/frontend/src/app/lib/mikeApi.ts @@ -268,6 +268,21 @@ export async function moveDocumentToFolder( ); } +export async function renameProjectDocument( + projectId: string, + documentId: string, + filename: string, +): Promise { + return apiRequest( + `/projects/${projectId}/documents/${documentId}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ filename }), + }, + ); +} + export async function addDocumentToProject( projectId: string, documentId: string,