diff --git a/backend/migrations/20260724_02_tabular_folder_rows.sql b/backend/migrations/20260724_02_tabular_folder_rows.sql new file mode 100644 index 000000000..cc6dd07b7 --- /dev/null +++ b/backend/migrations/20260724_02_tabular_folder_rows.sql @@ -0,0 +1,233 @@ +-- Support one tabular-review row per project or library folder, with multiple source documents. +alter table public.tabular_reviews + add column if not exists document_grouping text not null default 'document' + check (document_grouping in ('document', 'folder')); + +create table if not exists public.tabular_review_rows ( + id uuid primary key default gen_random_uuid(), + review_id uuid not null references public.tabular_reviews(id) on delete cascade, + label text not null, + row_type text not null check (row_type in ('document', 'folder')), + folder_id uuid references public.project_subfolders(id) on delete set null, + library_folder_id uuid references public.library_folders(id) on delete set null, + document_id uuid references public.documents(id) on delete set null, + sort_index integer not null default 0, + created_at timestamptz not null default now() +); +create index if not exists idx_tabular_review_rows_review + on public.tabular_review_rows(review_id, sort_index); +alter table public.tabular_review_rows enable row level security; + +alter table public.tabular_review_rows + add column if not exists library_folder_id uuid + references public.library_folders(id) on delete set null; + +create table if not exists public.tabular_review_row_sources ( + row_id uuid not null references public.tabular_review_rows(id) on delete cascade, + document_id uuid not null references public.documents(id) on delete cascade, + sort_index integer not null default 0, + created_at timestamptz not null default now(), + primary key (row_id, document_id) +); +create index if not exists idx_tabular_review_row_sources_document + on public.tabular_review_row_sources(document_id); +alter table public.tabular_review_row_sources enable row level security; + +alter table public.tabular_cells + add column if not exists row_id uuid references public.tabular_review_rows(id) on delete cascade; +alter table public.tabular_cells + alter column document_id drop not null; +create index if not exists idx_tabular_cells_review_row + on public.tabular_cells(review_id, row_id, column_index); + +-- Preserve every existing document row when upgrading an active database. +with document_rows as ( + select distinct cell.review_id, cell.document_id + from public.tabular_cells cell + join public.tabular_reviews review on review.id = cell.review_id + where cell.row_id is null + and review.document_grouping = 'document' + union + select review.id, source.document_id::uuid + from public.tabular_reviews review + cross join lateral jsonb_array_elements_text( + coalesce(review.document_ids, '[]'::jsonb) + ) source(document_id) + join public.documents document on document.id = source.document_id::uuid + where review.document_grouping = 'document' + or ( + review.document_grouping = 'folder' + and document.folder_id is null + and document.library_folder_id is null + ) +) +insert into public.tabular_review_rows (review_id, label, row_type, document_id, sort_index) +select + document_row.review_id, + coalesce(nullif(btrim(active_version.filename), ''), 'Untitled document'), + 'document', + document_row.document_id, + (row_number() over ( + partition by document_row.review_id + order by document_row.document_id + ) - 1)::integer +from document_rows document_row +join public.documents document on document.id = document_row.document_id +left join lateral ( + select version.filename + from public.document_versions version + where version.document_id = document.id + and version.deleted_at is null + order by (version.id = document.current_version_id) desc, version.created_at desc + limit 1 +) active_version on true +where not exists ( + select 1 from public.tabular_review_rows row + where row.review_id = document_row.review_id + and row.document_id = document_row.document_id + ); + +with folder_rows as ( + select distinct + review.id as review_id, + document.folder_id, + coalesce(nullif(btrim(folder.name), ''), 'Unknown folder') as label + from public.tabular_reviews review + cross join lateral jsonb_array_elements_text( + coalesce(review.document_ids, '[]'::jsonb) + ) source(document_id) + join public.documents document on document.id = source.document_id::uuid + left join public.project_subfolders folder on folder.id = document.folder_id + where review.document_grouping = 'folder' + and review.project_id is not null + and document.folder_id is not null +) +insert into public.tabular_review_rows ( + review_id, + label, + row_type, + folder_id, + sort_index +) +select + folder_row.review_id, + folder_row.label, + 'folder', + folder_row.folder_id, + row_number() over ( + partition by folder_row.review_id + order by folder_row.label, folder_row.folder_id + )::integer - 1 +from folder_rows folder_row +where not exists ( + select 1 + from public.tabular_review_rows existing + where existing.review_id = folder_row.review_id + and existing.row_type = 'folder' + and existing.folder_id = folder_row.folder_id +); + +with library_folder_rows as ( + select distinct + review.id as review_id, + document.library_folder_id, + coalesce(nullif(btrim(folder.name), ''), 'Unknown folder') as label + from public.tabular_reviews review + cross join lateral jsonb_array_elements_text( + coalesce(review.document_ids, '[]'::jsonb) + ) source(document_id) + join public.documents document on document.id = source.document_id::uuid + left join public.library_folders folder + on folder.id = document.library_folder_id + where review.document_grouping = 'folder' + and document.library_folder_id is not null +) +insert into public.tabular_review_rows ( + review_id, + label, + row_type, + library_folder_id, + sort_index +) +select + folder_row.review_id, + folder_row.label, + 'folder', + folder_row.library_folder_id, + row_number() over ( + partition by folder_row.review_id + order by folder_row.label, folder_row.library_folder_id + )::integer - 1 +from library_folder_rows folder_row +where not exists ( + select 1 + from public.tabular_review_rows existing + where existing.review_id = folder_row.review_id + and existing.row_type = 'folder' + and existing.library_folder_id = folder_row.library_folder_id +); + +insert into public.tabular_review_row_sources (row_id, document_id) +select row.id, row.document_id +from public.tabular_review_rows row +where row.document_id is not null +on conflict (row_id, document_id) do nothing; + +insert into public.tabular_review_row_sources (row_id, document_id, sort_index) +select + row.id, + source.document_id::uuid, + (source.ordinality - 1)::integer +from public.tabular_review_rows row +join public.tabular_reviews review on review.id = row.review_id +cross join lateral jsonb_array_elements_text( + coalesce(review.document_ids, '[]'::jsonb) +) with ordinality source(document_id, ordinality) +join public.documents document on document.id = source.document_id::uuid +where row.row_type = 'folder' + and ( + document.folder_id = row.folder_id + or document.library_folder_id = row.library_folder_id + ) +on conflict (row_id, document_id) do nothing; + +update public.tabular_cells cell +set row_id = row.id +from public.tabular_review_rows row +where cell.row_id is null + and row.review_id = cell.review_id + and row.document_id = cell.document_id; + +insert into public.tabular_cells ( + review_id, + row_id, + document_id, + column_index, + status +) +select + row.review_id, + row.id, + row.document_id, + (column_config ->> 'index')::integer, + 'pending' +from public.tabular_review_rows row +join public.tabular_reviews review on review.id = row.review_id +cross join lateral jsonb_array_elements( + coalesce(review.columns_config, '[]'::jsonb) +) column_config +where not exists ( + select 1 + from public.tabular_cells cell + where cell.row_id = row.id + and cell.column_index = (column_config ->> 'index')::integer +); + +alter table public.tabular_cells + alter column row_id set not null; + +revoke all on public.tabular_review_rows from anon, authenticated; +revoke all on public.tabular_review_row_sources from anon, authenticated; + +grant select, insert, update, delete on public.tabular_review_rows to service_role; +grant select, insert, update, delete on public.tabular_review_row_sources to service_role; diff --git a/backend/schema.sql b/backend/schema.sql index afbba4cbd..97ad9672e 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -590,6 +590,7 @@ create table if not exists public.tabular_reviews ( document_ids jsonb, workflow_id uuid references public.workflows(id) on delete set null, practice text, + document_grouping text not null default 'document' check (document_grouping in ('document', 'folder')), shared_with jsonb not null default '[]'::jsonb, created_at timestamptz not null default now(), updated_at timestamptz not null default now() @@ -685,10 +686,41 @@ as $$ order by vp.created_at desc; $$; -create table if not exists public.tabular_cells ( +create table if not exists public.tabular_review_rows ( id uuid primary key default gen_random_uuid(), review_id uuid not null references public.tabular_reviews(id) on delete cascade, + label text not null, + row_type text not null check (row_type in ('document', 'folder')), + folder_id uuid references public.project_subfolders(id) on delete set null, + library_folder_id uuid references public.library_folders(id) on delete set null, + document_id uuid references public.documents(id) on delete set null, + sort_index integer not null default 0, + created_at timestamptz not null default now() +); + +create index if not exists idx_tabular_review_rows_review + on public.tabular_review_rows(review_id, sort_index); + +alter table public.tabular_review_rows enable row level security; + +create table if not exists public.tabular_review_row_sources ( + row_id uuid not null references public.tabular_review_rows(id) on delete cascade, document_id uuid not null references public.documents(id) on delete cascade, + sort_index integer not null default 0, + created_at timestamptz not null default now(), + primary key (row_id, document_id) +); + +create index if not exists idx_tabular_review_row_sources_document + on public.tabular_review_row_sources(document_id); + +alter table public.tabular_review_row_sources enable row level security; + +create table if not exists public.tabular_cells ( + id uuid primary key default gen_random_uuid(), + review_id uuid not null references public.tabular_reviews(id) on delete cascade, + row_id uuid not null references public.tabular_review_rows(id) on delete cascade, + document_id uuid references public.documents(id) on delete cascade, column_index integer not null, content text, citations jsonb, @@ -699,9 +731,8 @@ create table if not exists public.tabular_cells ( create index if not exists idx_tabular_cells_review on public.tabular_cells(review_id, document_id, column_index); -drop function if exists public.get_tabular_reviews_overview( - text, text, text, integer, integer, text, text, text -); +create index if not exists idx_tabular_cells_review_row + on public.tabular_cells(review_id, row_id, column_index); create or replace function public.get_tabular_reviews_overview( p_user_id text, @@ -1073,6 +1104,8 @@ revoke all on public.chats from anon, authenticated; revoke all on public.chat_messages from anon, authenticated; revoke all on public.tabular_reviews from anon, authenticated; revoke all on public.tabular_cells from anon, authenticated; +revoke all on public.tabular_review_rows from anon, authenticated; +revoke all on public.tabular_review_row_sources from anon, authenticated; revoke all on public.tabular_review_chats from anon, authenticated; revoke all on public.tabular_review_chat_messages from anon, authenticated; revoke all on public.user_api_keys from anon, authenticated; diff --git a/backend/src/__tests__/integration/projects.routes.test.ts b/backend/src/__tests__/integration/projects.routes.test.ts index 75a57f4ab..9955c98a3 100644 --- a/backend/src/__tests__/integration/projects.routes.test.ts +++ b/backend/src/__tests__/integration/projects.routes.test.ts @@ -145,6 +145,46 @@ describe("projects.routes", () => { expect(res.body).toEqual([{ id: "p1", name: "Alpha" }]); }); + it("includes documents and subfolders in the batched directory response", async () => { + supabaseState.rpc = { + data: [{ id: "p1", name: "Alpha" }], + error: null, + }; + supabaseState.tables.documents = { + data: [ + { + id: "d1", + project_id: "p1", + folder_id: "f1", + filename: "Agreement.pdf", + }, + ], + error: null, + }; + supabaseState.tables.project_subfolders = { + data: [ + { + id: "f1", + project_id: "p1", + parent_folder_id: null, + name: "Closing", + }, + ], + error: null, + }; + + const res = await request(app) + .get("/projects?include=documents") + .set(...AUTH); + + expect(res.status).toBe(200); + expect(res.body[0]).toMatchObject({ + id: "p1", + documents: [{ id: "d1", folder_id: "f1" }], + folders: [{ id: "f1", name: "Closing" }], + }); + }); + it("returns 500 with detail when the RPC errors", async () => { supabaseState.rpc = { data: null, error: { message: "boom" } }; diff --git a/backend/src/__tests__/integration/tabular.routes.test.ts b/backend/src/__tests__/integration/tabular.routes.test.ts index 6ee9db10c..ee198ee28 100644 --- a/backend/src/__tests__/integration/tabular.routes.test.ts +++ b/backend/src/__tests__/integration/tabular.routes.test.ts @@ -13,13 +13,11 @@ const { checkProjectAccess, filterAccessibleDocumentIds, getUserModelSettings, - loadActiveVersion, } = vi.hoisted(() => ({ ensureReviewAccess: vi.fn(), checkProjectAccess: vi.fn(), filterAccessibleDocumentIds: vi.fn(), getUserModelSettings: vi.fn(), - loadActiveVersion: vi.fn(), })); // --------------------------------------------------------------------------- @@ -111,12 +109,11 @@ vi.mock("../../lib/userSettings", () => ({ getUserApiKeys: vi.fn(async () => ({})), })); -// Version-path enrichment + active-version resolution hit the DB in real life; -// no-op them so route responses are driven purely by the table stubs. +// Version-path enrichment hits the DB in real life; no-op it so route +// responses are driven purely by the table stubs. vi.mock("../../lib/documentVersions", () => ({ attachActiveVersionPaths: vi.fn(async () => {}), attachLatestVersionNumbers: vi.fn(async () => {}), - loadActiveVersion: (...args: unknown[]) => loadActiveVersion(...args), })); import { app } from "../../app"; @@ -144,7 +141,6 @@ describe("tabular.routes", () => { legal_research_us: false, api_keys: { claude: "sk-test" }, }); - loadActiveVersion.mockResolvedValue(null); }); // ── GET /tabular-review (overview) ──────────────────────────────────── @@ -178,6 +174,31 @@ describe("tabular.routes", () => { data: { id: "r9", title: "Gamma", document_ids: ["d1"] }, error: null, }; + supabaseState.tables.documents = { + data: [ + { + id: "d1", + filename: "Agreement.pdf", + file_type: "pdf", + folder_id: null, + }, + ], + error: null, + }; + supabaseState.tables.tabular_review_rows = { + data: [ + { + id: "row-1", + review_id: "r9", + label: "Agreement.pdf", + row_type: "document", + folder_id: null, + document_id: "d1", + sort_index: 0, + }, + ], + error: null, + }; // d2 is not accessible — it must be filtered out of the insert. filterAccessibleDocumentIds.mockResolvedValue(["d1"]); @@ -199,13 +220,14 @@ describe("tabular.routes", () => { expect(reviewInsert?.payload).toMatchObject({ document_ids: ["d1"], }); - // Cells are created for accessible docs × columns only (1 × 1). + // Cells are created for accessible review rows × columns only (1 × 1). const cellInsert = supabaseState.inserts.find( (i) => i.table === "tabular_cells", ); expect(cellInsert?.payload).toEqual([ { review_id: "r9", + row_id: "row-1", document_id: "d1", column_index: 0, status: "pending", @@ -213,6 +235,205 @@ describe("tabular.routes", () => { ]); }); + it("groups project-folder documents into one review row", async () => { + supabaseState.tables.tabular_reviews = { + data: { id: "r10", title: "Grouped", document_ids: ["d1", "d2", "d3"] }, + error: null, + }; + supabaseState.tables.documents = { + data: [ + { id: "d1", filename: "A.pdf", file_type: "pdf", project_id: "p1", folder_id: "f1" }, + { id: "d2", filename: "B.pdf", file_type: "pdf", project_id: "p1", folder_id: "f1" }, + { id: "d3", filename: "Loose.pdf", file_type: "pdf", project_id: "p1", folder_id: null }, + ], + error: null, + }; + supabaseState.tables.project_subfolders = { + data: [{ id: "f1", name: "Contracts", parent_folder_id: null }], + error: null, + }; + supabaseState.tables.tabular_review_rows = { + data: [ + { + id: "row-folder", + review_id: "r10", + label: "Contracts", + row_type: "folder", + folder_id: "f1", + library_folder_id: null, + document_id: null, + sort_index: 0, + }, + { + id: "row-document", + review_id: "r10", + label: "Loose.pdf", + row_type: "document", + folder_id: null, + library_folder_id: null, + document_id: "d3", + sort_index: 1, + }, + ], + error: null, + }; + + const res = await request(app) + .post("/tabular-review") + .set(...AUTH) + .send({ + title: "Grouped", + project_id: "p1", + document_ids: ["d1", "d2", "d3"], + document_grouping: "folder", + columns_config: [{ index: 0, name: "Col", prompt: "p" }], + }); + + expect(res.status).toBe(201); + expect(supabaseState.inserts.find((i) => i.table === "tabular_reviews")?.payload) + .toMatchObject({ document_grouping: "folder" }); + expect(supabaseState.inserts.find((i) => i.table === "tabular_review_rows")?.payload) + .toEqual([ + { + review_id: "r10", + label: "Contracts", + row_type: "folder", + folder_id: "f1", + library_folder_id: null, + document_id: null, + sort_index: 0, + }, + { + review_id: "r10", + label: "Loose.pdf", + row_type: "document", + folder_id: null, + library_folder_id: null, + document_id: "d3", + sort_index: 1, + }, + ]); + expect(supabaseState.inserts.find((i) => i.table === "tabular_review_row_sources")?.payload) + .toEqual([ + { row_id: "row-folder", document_id: "d1", sort_index: 0 }, + { row_id: "row-folder", document_id: "d2", sort_index: 1 }, + { row_id: "row-document", document_id: "d3", sort_index: 0 }, + ]); + expect(supabaseState.inserts.find((i) => i.table === "tabular_cells")?.payload) + .toEqual([ + { + review_id: "r10", + row_id: "row-folder", + document_id: null, + column_index: 0, + status: "pending", + }, + { + review_id: "r10", + row_id: "row-document", + document_id: "d3", + column_index: 0, + status: "pending", + }, + ]); + }); + + it("groups library file-folder documents into one review row", async () => { + supabaseState.tables.tabular_reviews = { + data: { id: "r11", title: "Library grouped" }, + error: null, + }; + supabaseState.tables.documents = { + data: [ + { + id: "d1", + filename: "A.pdf", + file_type: "pdf", + project_id: null, + folder_id: null, + library_folder_id: "lf1", + }, + { + id: "d2", + filename: "B.pdf", + file_type: "pdf", + project_id: null, + folder_id: null, + library_folder_id: "lf1", + }, + ], + error: null, + }; + supabaseState.tables.library_folders = { + data: [ + { + id: "lf1", + name: "Precedents", + parent_folder_id: null, + }, + ], + error: null, + }; + supabaseState.tables.tabular_review_rows = { + data: [ + { + id: "row-library-folder", + review_id: "r11", + label: "Precedents", + row_type: "folder", + folder_id: null, + library_folder_id: "lf1", + document_id: null, + sort_index: 0, + }, + ], + error: null, + }; + + const res = await request(app) + .post("/tabular-review") + .set(...AUTH) + .send({ + title: "Library grouped", + document_ids: ["d1", "d2"], + document_grouping: "folder", + columns_config: [{ index: 0, name: "Col", prompt: "p" }], + }); + + expect(res.status).toBe(201); + expect( + supabaseState.inserts.find( + (insert) => insert.table === "tabular_review_rows", + )?.payload, + ).toEqual([ + { + review_id: "r11", + label: "Precedents", + row_type: "folder", + folder_id: null, + library_folder_id: "lf1", + document_id: null, + sort_index: 0, + }, + ]); + expect( + supabaseState.inserts.find( + (insert) => insert.table === "tabular_review_row_sources", + )?.payload, + ).toEqual([ + { + row_id: "row-library-folder", + document_id: "d1", + sort_index: 0, + }, + { + row_id: "row-library-folder", + document_id: "d2", + sort_index: 1, + }, + ]); + }); + it("returns 404 when project access is denied", async () => { checkProjectAccess.mockResolvedValue({ ok: false }); @@ -398,14 +619,14 @@ describe("tabular.routes", () => { // ── POST /tabular-review/:reviewId/clear-cells ──────────────────────── describe("POST /tabular-review/:reviewId/clear-cells", () => { - it("returns 400 when document_ids is missing", async () => { + it("returns 400 when row_ids is missing", async () => { const res = await request(app) .post("/tabular-review/r1/clear-cells") .set(...AUTH) .send({}); expect(res.status).toBe(400); - expect(res.body.detail).toBe("document_ids is required"); + expect(res.body.detail).toBe("row_ids is required"); }); it("returns 404 when review access is denied", async () => { @@ -418,7 +639,7 @@ describe("tabular.routes", () => { const res = await request(app) .post("/tabular-review/r1/clear-cells") .set(...AUTH) - .send({ document_ids: ["d1"] }); + .send({ row_ids: ["row-1"] }); expect(res.status).toBe(404); expect(res.body.detail).toBe("Review not found"); @@ -433,7 +654,7 @@ describe("tabular.routes", () => { const res = await request(app) .post("/tabular-review/r1/clear-cells") .set(...AUTH) - .send({ document_ids: ["d1"] }); + .send({ row_ids: ["row-1"] }); expect(res.status).toBe(204); }); @@ -441,7 +662,7 @@ describe("tabular.routes", () => { // ── POST /tabular-review/:reviewId/regenerate-cell ──────────────────── describe("POST /tabular-review/:reviewId/regenerate-cell", () => { - it("returns 400 when document_id / column_index are missing", async () => { + it("returns 400 when row_id / column_index are missing", async () => { const res = await request(app) .post("/tabular-review/r1/regenerate-cell") .set(...AUTH) @@ -449,7 +670,7 @@ describe("tabular.routes", () => { expect(res.status).toBe(400); expect(res.body.detail).toBe( - "document_id and column_index are required", + "row_id and column_index are required", ); }); @@ -463,7 +684,7 @@ describe("tabular.routes", () => { const res = await request(app) .post("/tabular-review/r1/regenerate-cell") .set(...AUTH) - .send({ document_id: "d1", column_index: 0 }); + .send({ row_id: "row-1", column_index: 0 }); expect(res.status).toBe(404); expect(res.body.detail).toBe("Review not found"); @@ -483,13 +704,13 @@ describe("tabular.routes", () => { const res = await request(app) .post("/tabular-review/r1/regenerate-cell") .set(...AUTH) - .send({ document_id: "d1", column_index: 0 }); + .send({ row_id: "row-1", column_index: 0 }); expect(res.status).toBe(400); expect(res.body.detail).toBe("Column not found"); }); - it("returns 404 when the document is not accessible", async () => { + it("returns 404 when a row source document is not accessible", async () => { supabaseState.tables.tabular_reviews = { data: { id: "r1", @@ -499,15 +720,37 @@ describe("tabular.routes", () => { }, error: null, }; + supabaseState.tables.tabular_review_rows = { + data: [ + { + id: "row-forbidden", + review_id: "r1", + label: "Forbidden", + row_type: "document", + document_id: "d-forbidden", + sort_index: 0, + }, + ], + error: null, + }; + supabaseState.tables.tabular_review_row_sources = { + data: [ + { + row_id: "row-forbidden", + document_id: "d-forbidden", + }, + ], + error: null, + }; filterAccessibleDocumentIds.mockResolvedValue([]); const res = await request(app) .post("/tabular-review/r1/regenerate-cell") .set(...AUTH) - .send({ document_id: "d-forbidden", column_index: 0 }); + .send({ row_id: "row-forbidden", column_index: 0 }); expect(res.status).toBe(404); - expect(res.body.detail).toBe("Document not found"); + expect(res.body.detail).toBe("Review row not found"); }); it("returns 422 with missing_api_key when the model key is absent", async () => { @@ -520,8 +763,21 @@ describe("tabular.routes", () => { }, error: null, }; - supabaseState.tables.documents = { - data: { id: "d1", current_version_id: null }, + supabaseState.tables.tabular_review_rows = { + data: [ + { + id: "row-1", + review_id: "r1", + label: "Document", + row_type: "document", + document_id: "d1", + sort_index: 0, + }, + ], + error: null, + }; + supabaseState.tables.tabular_review_row_sources = { + data: [{ row_id: "row-1", document_id: "d1" }], error: null, }; getUserModelSettings.mockResolvedValue({ @@ -534,7 +790,7 @@ describe("tabular.routes", () => { const res = await request(app) .post("/tabular-review/r1/regenerate-cell") .set(...AUTH) - .send({ document_id: "d1", column_index: 0 }); + .send({ row_id: "row-1", column_index: 0 }); expect(res.status).toBe(422); expect(res.body.code).toBe("missing_api_key"); diff --git a/backend/src/routes/projects.ts b/backend/src/routes/projects.ts index 3f1819940..ed5750b29 100644 --- a/backend/src/routes/projects.ts +++ b/backend/src/routes/projects.ts @@ -181,16 +181,26 @@ projectsRouter.get("/", requireAuth, async (req, res) => { return void res.json(projects); } - const { data: docs, error: docsError } = await db - .from("documents") - .select("*") - .in( - "project_id", - projects.map((p) => p.id), - ) - .order("created_at", { ascending: true }); + const projectIds = projects.map((p) => p.id); + const [ + { data: docs, error: docsError }, + { data: folders, error: foldersError }, + ] = await Promise.all([ + db + .from("documents") + .select("*") + .in("project_id", projectIds) + .order("created_at", { ascending: true }), + db + .from("project_subfolders") + .select("*") + .in("project_id", projectIds) + .order("created_at", { ascending: true }), + ]); if (docsError) return void res.status(500).json({ detail: docsError.message }); + if (foldersError) + return void res.status(500).json({ detail: foldersError.message }); const docsTyped = (docs ?? []) as unknown as { id: string; @@ -209,10 +219,18 @@ projectsRouter.get("/", requireAuth, async (req, res) => { if (bucket) bucket.push(doc); else docsByProject.set(doc.project_id, [doc]); } + const foldersByProject = new Map>(); + for (const folder of folders ?? []) { + const projectId = folder.project_id as string; + const bucket = foldersByProject.get(projectId); + if (bucket) bucket.push(folder); + else foldersByProject.set(projectId, [folder]); + } res.json( projects.map((p) => ({ ...p, documents: docsByProject.get(p.id) ?? [], + folders: foldersByProject.get(p.id) ?? [], })), ); }); diff --git a/backend/src/routes/tabular.ts b/backend/src/routes/tabular.ts index db0ad51d4..5e88320d8 100644 --- a/backend/src/routes/tabular.ts +++ b/backend/src/routes/tabular.ts @@ -2,10 +2,7 @@ import { Router } from "express"; import { requireAuth } from "../middleware/auth"; import { createServerSupabase } from "../lib/supabase"; import { downloadFile } from "../lib/storage"; -import { - attachActiveVersionPaths, - loadActiveVersion, -} from "../lib/documentVersions"; +import { attachActiveVersionPaths } from "../lib/documentVersions"; import { docxToPdf, normalizeDocxZipPaths } from "../lib/convert"; import { isPresentationDocumentType, @@ -64,12 +61,12 @@ function formatPromptSuffix(format?: string, tags?: string[]): string { case "currency": return ' The "summary" field in your JSON response must contain only the currency code(s). Wrap each code in double square brackets, e.g. [[USD]] or [[EUR]]. No other text.'; case "yes_no": - return ' The "summary" field in your JSON response must be [[Yes]] or [[No]] only. The "reasoning" field MUST include an inline citation [[page:N||quote:verbatim excerpt ≤25 words]] pointing to the exact language in the document that supports the Yes/No answer.'; + return ' The "summary" field in your JSON response must be [[Yes]] or [[No]] only. The "reasoning" field MUST include an inline citation [[document:SOURCE_DOCUMENT_ID||page:N||quote:verbatim excerpt ≤25 words]] pointing to the exact language in the document that supports the Yes/No answer.'; case "date": - return ' The "summary" field in your JSON response must be the date only in DD Month YYYY format (e.g. 1 January 2024). If a range, give both dates separated by an em dash. The "reasoning" field MUST include an inline citation [[page:N||quote:verbatim excerpt ≤25 words]] pointing to the exact place in the document where the date is found.'; + return ' The "summary" field in your JSON response must be the date only in DD Month YYYY format (e.g. 1 January 2024). If a range, give both dates separated by an em dash. The "reasoning" field MUST include an inline citation [[document:SOURCE_DOCUMENT_ID||page:N||quote:verbatim excerpt ≤25 words]] pointing to the exact place in the document where the date is found.'; case "tag": return tags?.length - ? ` The \"summary\" field in your JSON response must contain exactly one tag wrapped in double square brackets. Available tags: ${tags.map((t) => `[[${t}]]`).join(", ")}. No other text. The \"reasoning\" field MUST include an inline citation [[page:N||quote:verbatim excerpt ≤25 words]] pointing to the exact language in the document that supports the chosen tag.` + ? ` The \"summary\" field in your JSON response must contain exactly one tag wrapped in double square brackets. Available tags: ${tags.map((t) => `[[${t}]]`).join(", ")}. No other text. The \"reasoning\" field MUST include an inline citation [[document:SOURCE_DOCUMENT_ID||page:N||quote:verbatim excerpt ≤25 words]] pointing to the exact language in the document that supports the chosen tag.` : ""; default: return ""; @@ -78,6 +75,397 @@ function formatPromptSuffix(format?: string, tags?: string[]): string { export const tabularRouter = Router(); +type DocumentGrouping = "document" | "folder"; +type ReviewRow = { + id: string; + review_id: string; + label: string; + row_type: "document" | "folder"; + folder_id: string | null; + library_folder_id: string | null; + document_id: string | null; + sort_index: number; + source_document_ids?: string[]; +}; +type SourceDocument = { + id: string; + filename: string; + file_type: string | null; + current_version_id?: string | null; + project_id?: string | null; + folder_id?: string | null; + library_folder_id?: string | null; +}; +type SupabaseDb = ReturnType; + +function normalizeGrouping(value: unknown): DocumentGrouping { + return value === "folder" ? "folder" : "document"; +} + +async function fetchSourceDocuments( + db: SupabaseDb, + documentIds: string[], +): Promise { + if (documentIds.length === 0) return []; + const { data, error } = await db + .from("documents") + .select( + "id, current_version_id, project_id, folder_id, library_folder_id", + ) + .in("id", documentIds); + if (error) throw new Error(error.message); + const docs = (data ?? []) as (Omit< + SourceDocument, + "filename" | "file_type" + > & { + filename?: string | null; + file_type?: string | null; + })[]; + await attachActiveVersionPaths(db, docs); + const position = new Map(documentIds.map((id, index) => [id, index])); + return docs + .map((doc) => ({ + ...doc, + filename: doc.filename?.trim() || "Untitled document", + file_type: doc.file_type ?? null, + })) + .sort((a, b) => (position.get(a.id) ?? 0) - (position.get(b.id) ?? 0)); +} + +function buildFolderPathMap( + folders: { + id: string; + name: string; + parent_folder_id: string | null; + }[], +): Map { + const byId = new Map(folders.map((folder) => [folder.id, folder])); + const paths = new Map(); + const resolve = (id: string): string => { + const existing = paths.get(id); + if (existing) return existing; + const folder = byId.get(id); + if (!folder) return "Unknown folder"; + const path = folder.parent_folder_id + ? `${resolve(folder.parent_folder_id)} / ${folder.name}` + : folder.name; + paths.set(id, path); + return path; + }; + for (const folder of folders) resolve(folder.id); + return paths; +} + +async function getFolderPathMaps( + db: SupabaseDb, + userId: string, + docs: SourceDocument[], +): Promise<{ + project: Map; + library: Map; +}> { + const projectIds = [ + ...new Set( + docs + .map((doc) => doc.project_id) + .filter((id): id is string => !!id), + ), + ]; + const [projectResult, libraryResult] = await Promise.all([ + projectIds.length + ? db + .from("project_subfolders") + .select("id, name, parent_folder_id") + .in("project_id", projectIds) + : Promise.resolve({ data: [] }), + db + .from("library_folders") + .select("id, name, parent_folder_id") + .eq("user_id", userId), + ]); + return { + project: buildFolderPathMap(projectResult.data ?? []), + library: buildFolderPathMap(libraryResult.data ?? []), + }; +} + +async function createRowsForReview( + db: SupabaseDb, + reviewId: string, + userId: string, + documentIds: string[], + columns: Column[], + grouping: DocumentGrouping, +): Promise { + const docs = await fetchSourceDocuments(db, documentIds); + const folderPaths = await getFolderPathMaps(db, userId, docs); + const inputs: { + label: string; + row_type: "document" | "folder"; + folder_id: string | null; + library_folder_id: string | null; + document_id: string | null; + sourceIds: string[]; + }[] = []; + + if (grouping === "folder") { + const byFolder = new Map< + string, + { + folder_id: string | null; + library_folder_id: string | null; + docs: SourceDocument[]; + } + >(); + for (const doc of docs) { + const folderKey = doc.folder_id + ? `project:${doc.folder_id}` + : doc.library_folder_id + ? `library:${doc.library_folder_id}` + : null; + if (!folderKey) { + inputs.push({ + label: doc.filename, + row_type: "document", + folder_id: null, + library_folder_id: null, + document_id: doc.id, + sourceIds: [doc.id], + }); + continue; + } + const existing = byFolder.get(folderKey); + if (existing) { + existing.docs.push(doc); + } else { + byFolder.set(folderKey, { + folder_id: doc.folder_id ?? null, + library_folder_id: doc.library_folder_id ?? null, + docs: [doc], + }); + } + } + for (const folder of byFolder.values()) { + const label = folder.folder_id + ? folderPaths.project.get(folder.folder_id) + : folder.library_folder_id + ? folderPaths.library.get(folder.library_folder_id) + : null; + inputs.push({ + label: label ?? "Unknown folder", + row_type: "folder", + folder_id: folder.folder_id, + library_folder_id: folder.library_folder_id, + document_id: null, + sourceIds: folder.docs.map((doc) => doc.id), + }); + } + } else { + for (const doc of docs) { + inputs.push({ + label: doc.filename, + row_type: "document", + folder_id: null, + library_folder_id: null, + document_id: doc.id, + sourceIds: [doc.id], + }); + } + } + + inputs.sort((a, b) => a.label.localeCompare(b.label)); + if (inputs.length === 0) return; + + const { data, error } = await db + .from("tabular_review_rows") + .insert( + inputs.map((input, sort_index) => ({ + review_id: reviewId, + label: input.label, + row_type: input.row_type, + folder_id: input.folder_id, + library_folder_id: input.library_folder_id, + document_id: input.document_id, + sort_index, + })), + ) + .select("*"); + if (error) throw new Error(error.message); + const rows = ((data ?? []) as ReviewRow[]).sort( + (a, b) => a.sort_index - b.sort_index, + ); + const sources = rows.flatMap((row) => + (inputs[row.sort_index]?.sourceIds ?? []).map( + (document_id, sort_index) => ({ + row_id: row.id, + document_id, + sort_index, + }), + ), + ); + if (sources.length) { + const { error: sourceError } = await db + .from("tabular_review_row_sources") + .insert(sources); + if (sourceError) throw new Error(sourceError.message); + } + const cells = rows.flatMap((row) => + columns.map((column) => ({ + review_id: reviewId, + row_id: row.id, + document_id: row.document_id, + column_index: column.index, + status: "pending", + })), + ); + if (cells.length) { + const { error: cellError } = await db + .from("tabular_cells") + .insert(cells); + if (cellError) throw new Error(cellError.message); + } +} + +async function rebuildRowsForReview( + db: SupabaseDb, + reviewId: string, + userId: string, + documentIds: string[], + columns: Column[], + grouping: DocumentGrouping, +): Promise { + const { error } = await db + .from("tabular_review_rows") + .delete() + .eq("review_id", reviewId); + if (error) throw new Error(error.message); + await createRowsForReview( + db, + reviewId, + userId, + documentIds, + columns, + grouping, + ); +} + +async function syncCellsForReviewRows( + db: SupabaseDb, + reviewId: string, + columns: Column[], +): Promise { + const { data: rows, error: rowsError } = await db + .from("tabular_review_rows") + .select("id,document_id") + .eq("review_id", reviewId); + if (rowsError) throw new Error(rowsError.message); + const { data: cells, error: cellsError } = await db + .from("tabular_cells") + .select("id,row_id,column_index") + .eq("review_id", reviewId); + if (cellsError) throw new Error(cellsError.message); + + const activeColumnIndexes = new Set(columns.map((column) => column.index)); + const staleCellIds = (cells ?? []) + .filter((cell) => !activeColumnIndexes.has(cell.column_index)) + .map((cell) => cell.id); + if (staleCellIds.length) { + const { error } = await db + .from("tabular_cells") + .delete() + .in("id", staleCellIds); + if (error) throw new Error(error.message); + } + + const existingKeys = new Set( + (cells ?? []) + .filter((cell) => activeColumnIndexes.has(cell.column_index)) + .map((cell) => `${cell.row_id}:${cell.column_index}`), + ); + const missingCells = (rows ?? []).flatMap((row) => + columns + .filter((column) => !existingKeys.has(`${row.id}:${column.index}`)) + .map((column) => ({ + review_id: reviewId, + row_id: row.id, + document_id: row.document_id, + column_index: column.index, + status: "pending", + })), + ); + if (missingCells.length) { + const { error } = await db.from("tabular_cells").insert(missingCells); + if (error) throw new Error(error.message); + } +} + +async function loadReviewRows( + db: SupabaseDb, + reviewId: string, +): Promise { + const { data, error } = await db + .from("tabular_review_rows") + .select("*") + .eq("review_id", reviewId) + .order("sort_index", { ascending: true }); + if (error) throw new Error(error.message); + const rows = (data ?? []) as ReviewRow[]; + if (!rows.length) return rows; + const { data: sources, error: sourceError } = await db + .from("tabular_review_row_sources") + .select("row_id, document_id") + .in("row_id", rows.map((row) => row.id)) + .order("sort_index", { ascending: true }); + if (sourceError) throw new Error(sourceError.message); + const byRow = new Map(); + for (const source of sources ?? []) { + byRow.set(source.row_id, [ + ...(byRow.get(source.row_id) ?? []), + source.document_id, + ]); + } + return rows.map((row) => ({ + ...row, + source_document_ids: + byRow.get(row.id) ?? (row.document_id ? [row.document_id] : []), + })); +} + +async function loadRowDocumentText( + db: SupabaseDb, + row: ReviewRow, +): Promise { + const sourceIds = + row.source_document_ids ?? (row.document_id ? [row.document_id] : []); + const docs = await fetchSourceDocuments(db, sourceIds); + const sections: string[] = []; + for (const doc of docs) { + const storagePath = (doc as SourceDocument & { storage_path?: string }) + .storage_path; + let markdown = ""; + if (storagePath) { + const buf = await downloadFile(storagePath); + if (buf) { + try { + markdown = await extractDocumentMarkdown( + buf, + doc.file_type, + ); + } catch (error) { + console.error( + `[tabular] extraction error doc=${doc.id}`, + safeErrorLog(error), + ); + } + } + } + sections.push( + `## Source document: ${doc.filename}\nSource document ID: ${doc.id}\n\n${markdown}`, + ); + } + return sections.join("\n\n---\n\n"); +} + function providerLabel(provider: Provider): string { if (provider === "claude") return "Anthropic"; if (provider === "openai") return "OpenAI"; @@ -184,14 +572,21 @@ tabularRouter.get("/ids", requireAuth, async (req, res) => { tabularRouter.post("/", requireAuth, async (req, res) => { const userId = res.locals.userId as string; const userEmail = res.locals.userEmail as string | undefined; - const { title, document_ids, columns_config, workflow_id, project_id } = - req.body as { - title?: string; - document_ids: string[]; - columns_config: { index: number; name: string; prompt: string }[]; - workflow_id?: string; - project_id?: string; - }; + const { + title, + document_ids, + columns_config, + workflow_id, + project_id, + document_grouping, + } = req.body as { + title?: string; + document_ids: string[]; + columns_config: { index: number; name: string; prompt: string }[]; + workflow_id?: string; + project_id?: string; + document_grouping?: DocumentGrouping; + }; const db = createServerSupabase(); if (project_id) { @@ -205,13 +600,9 @@ tabularRouter.post("/", requireAuth, async (req, res) => { return void res.status(404).json({ detail: "Project not found" }); } const allowedDocumentIds = Array.isArray(document_ids) - ? await filterAccessibleDocumentIds( - document_ids, - userId, - userEmail, - db, - ) + ? await filterAccessibleDocumentIds(document_ids, userId, userEmail, db) : []; + const grouping = normalizeGrouping(document_grouping); const { data: review, error } = await db .from("tabular_reviews") .insert({ @@ -221,6 +612,7 @@ tabularRouter.post("/", requireAuth, async (req, res) => { document_ids: allowedDocumentIds, project_id: project_id ?? null, workflow_id: workflow_id ?? null, + document_grouping: grouping, }) .select("*") .single(); @@ -229,15 +621,24 @@ tabularRouter.post("/", requireAuth, async (req, res) => { .status(500) .json({ detail: error?.message ?? "Failed to create review" }); - const cells = allowedDocumentIds.flatMap((docId) => - columns_config.map((col) => ({ - review_id: review.id, - document_id: docId, - column_index: col.index, - status: "pending", - })), - ); - if (cells.length) await db.from("tabular_cells").insert(cells); + try { + await createRowsForReview( + db, + review.id, + userId, + allowedDocumentIds, + columns_config, + grouping, + ); + } catch (error) { + await db.from("tabular_reviews").delete().eq("id", review.id); + return void res.status(500).json({ + detail: + error instanceof Error + ? error.message + : "Failed to create review rows", + }); + } res.status(201).json(review); }); @@ -331,19 +732,17 @@ tabularRouter.get("/:reviewId", requireAuth, async (req, res) => { if (!access.ok) return void res.status(404).json({ detail: "Review not found" }); - const { data: cells } = await db + const { data: cells, error: cellsError } = await db .from("tabular_cells") .select("*") .eq("review_id", reviewId); - const cellDocIds = [...new Set((cells ?? []).map((c) => c.document_id))]; - const hasExplicitDocIds = Array.isArray(review.document_ids); - const explicitDocIds = hasExplicitDocIds + if (cellsError) + return void res.status(500).json({ detail: cellsError.message }); + const rows = await loadReviewRows(db, reviewId); + const rowDocIds = rows.flatMap((row) => row.source_document_ids ?? []); + const docIds = Array.isArray(review.document_ids) ? (review.document_ids as string[]) - : []; - const docIds = - hasExplicitDocIds - ? explicitDocIds - : cellDocIds; + : rowDocIds; const docsResult = docIds.length > 0 ? await db.from("documents").select("*").in("id", docIds) @@ -360,6 +759,7 @@ tabularRouter.get("/:reviewId", requireAuth, async (req, res) => { ...cell, content: parseCellContent(cell.content), })), + rows, documents: docs, }); }); @@ -469,7 +869,9 @@ tabularRouter.patch("/:reviewId", requireAuth, async (req, res) => { if (!access.ok) return void res.status(404).json({ detail: "Review not found" }); if ( - (req.body.title != null || req.body.document_ids != null) && + (req.body.title != null || + req.body.document_ids != null || + req.body.document_grouping != null) && !access.isOwner ) { return void res.status(403).json({ @@ -484,6 +886,25 @@ tabularRouter.patch("/:reviewId", requireAuth, async (req, res) => { } updates.columns_config = req.body.columns_config; } + if (req.body.document_grouping != null) { + if ( + req.body.document_grouping !== "document" && + req.body.document_grouping !== "folder" + ) { + return void res.status(400).json({ + detail: "document_grouping must be document or folder", + }); + } + updates.document_grouping = req.body.document_grouping; + } + if (Array.isArray(req.body.document_ids)) { + updates.document_ids = await filterAccessibleDocumentIds( + req.body.document_ids, + userId, + userEmail, + db, + ); + } if (sharedWithUpdate !== undefined) { if (!access.isOwner) return void res @@ -533,116 +954,34 @@ tabularRouter.patch("/:reviewId", requireAuth, async (req, res) => { detail: updateError?.message ?? "Failed to update review", }); - let persistedDocumentIds: string[] | undefined; - if ( - Array.isArray(req.body.columns_config) || - Array.isArray(req.body.document_ids) - ) { - const { data: existingCells } = await db - .from("tabular_cells") - .select("document_id,column_index") - .eq("review_id", reviewId); - const existingKeys = new Set( - (existingCells ?? []).map( - (cell) => `${cell.document_id}:${cell.column_index}`, - ), - ); - - let documentIds: string[]; - - if (Array.isArray(req.body.document_ids)) { - // document_ids is the new source of truth — delete removed docs' cells - const requestedDocIds = req.body.document_ids as string[]; - const existingDocIds = (existingCells ?? []).map( - (cell) => cell.document_id, - ); - const existingDocIdSet = new Set(existingDocIds); - const newDocCandidates = requestedDocIds.filter( - (id) => !existingDocIdSet.has(id), - ); - const newDocAllowed = await filterAccessibleDocumentIds( - newDocCandidates, - userId, - userEmail, + const rowShapeChanged = + Array.isArray(req.body.document_ids) || + req.body.document_grouping != null || + projectIdUpdateProvided; + try { + const activeColumns = (updatedReview.columns_config ?? []) as Column[]; + if (rowShapeChanged) { + await rebuildRowsForReview( db, + reviewId, + userId, + (updatedReview.document_ids ?? []) as string[], + activeColumns, + normalizeGrouping(updatedReview.document_grouping), ); - const newDocAllowedSet = new Set(newDocAllowed); - const newDocIds = requestedDocIds.filter( - (id) => existingDocIdSet.has(id) || newDocAllowedSet.has(id), - ); - const removedDocIds = existingDocIds.filter( - (id) => !newDocIds.includes(id), - ); - - if (removedDocIds.length > 0) { - const { error: deleteError } = await db - .from("tabular_cells") - .delete() - .eq("review_id", reviewId) - .in("document_id", removedDocIds); - if (deleteError) - return void res - .status(500) - .json({ detail: deleteError.message }); - } - - documentIds = newDocIds; - } else { - // No document change — derive from existing cells - documentIds = [ - ...new Set( - (existingCells ?? []).map((cell) => cell.document_id), - ), - ]; - } - - if (Array.isArray(req.body.document_ids)) { - persistedDocumentIds = documentIds; - const { error: documentIdsError } = await db - .from("tabular_reviews") - .update({ - document_ids: documentIds, - updated_at: new Date().toISOString(), - }) - .eq("id", reviewId); - if (documentIdsError) - return void res.status(500).json({ - detail: documentIdsError.message, - }); - } - - const activeColumns = Array.isArray(req.body.columns_config) - ? req.body.columns_config - : (updatedReview.columns_config ?? []); - const newCells = documentIds.flatMap((documentId) => - activeColumns - .filter( - (column: { index: number }) => - !existingKeys.has(`${documentId}:${column.index}`), - ) - .map((column: { index: number }) => ({ - review_id: reviewId, - document_id: documentId, - column_index: column.index, - status: "pending", - })), - ); - - if (newCells.length > 0) { - const { error: insertError } = await db - .from("tabular_cells") - .insert(newCells); - if (insertError) - return void res - .status(500) - .json({ detail: insertError.message }); + } else if (Array.isArray(req.body.columns_config)) { + await syncCellsForReviewRows(db, reviewId, activeColumns); } + } catch (error) { + return void res.status(500).json({ + detail: + error instanceof Error + ? error.message + : "Failed to synchronize review rows", + }); } - res.json({ - ...updatedReview, - ...(persistedDocumentIds ? { document_ids: persistedDocumentIds } : {}), - }); + res.json(updatedReview); }); // DELETE /tabular-review/:reviewId @@ -660,18 +999,16 @@ tabularRouter.delete("/:reviewId", requireAuth, async (req, res) => { }); // POST /tabular-review/:reviewId/clear-cells -// Reset cells to an empty/pending state for the given document_ids. Does not +// Reset cells to an empty/pending state for the given row_ids. Does not // delete the rows — it blanks `content` and sets `status` back to "pending". tabularRouter.post("/:reviewId/clear-cells", requireAuth, async (req, res) => { const userId = res.locals.userId as string; const userEmail = res.locals.userEmail as string | undefined; const { reviewId } = req.params; - const { document_ids } = req.body as { document_ids?: string[] }; + const { row_ids } = req.body as { row_ids?: string[] }; - if (!Array.isArray(document_ids) || document_ids.length === 0) - return void res - .status(400) - .json({ detail: "document_ids is required" }); + if (!Array.isArray(row_ids) || row_ids.length === 0) + return void res.status(400).json({ detail: "row_ids is required" }); const db = createServerSupabase(); const { data: review, error: reviewError } = await db @@ -689,7 +1026,7 @@ tabularRouter.post("/:reviewId/clear-cells", requireAuth, async (req, res) => { .from("tabular_cells") .update({ content: null, status: "pending" }) .eq("review_id", reviewId) - .in("document_id", document_ids); + .in("row_id", row_ids); if (error) return void res.status(500).json({ detail: error.message }); res.status(204).send(); }); @@ -702,15 +1039,15 @@ tabularRouter.post( const userId = res.locals.userId as string; const userEmail = res.locals.userEmail as string | undefined; const { reviewId } = req.params; - const { document_id, column_index } = req.body as { - document_id: string; + const { row_id, column_index } = req.body as { + row_id?: string; column_index: number; }; - if (!document_id || column_index == null) + if (!row_id || column_index == null) return void res .status(400) - .json({ detail: "document_id and column_index are required" }); + .json({ detail: "row_id and column_index are required" }); const db = createServerSupabase(); const { data: review, error: reviewError } = await db @@ -736,22 +1073,23 @@ tabularRouter.post( if (!column) return void res.status(400).json({ detail: "Column not found" }); - const docAllowed = await filterAccessibleDocumentIds( - [document_id], + const rows = await loadReviewRows(db, reviewId); + const row = rows.find((candidate) => candidate.id === row_id); + if (!row) + return void res + .status(404) + .json({ detail: "Review row not found" }); + const sourceIds = row.source_document_ids ?? []; + const allowedSourceIds = await filterAccessibleDocumentIds( + sourceIds, userId, userEmail, db, ); - if (docAllowed.length === 0) - return void res.status(404).json({ detail: "Document not found" }); - const { data: doc } = await db - .from("documents") - .select("id, current_version_id") - .eq("id", document_id) - .single(); - if (!doc) - return void res.status(404).json({ detail: "Document not found" }); - const docActive = await loadActiveVersion(document_id, db); + if (allowedSourceIds.length !== sourceIds.length) + return void res + .status(404) + .json({ detail: "Review row not found" }); const { tabular_model, api_keys } = await getUserModelSettings( userId, @@ -769,30 +1107,14 @@ tabularRouter.post( .from("tabular_cells") .update({ status: "generating", content: null }) .eq("review_id", reviewId) - .eq("document_id", document_id) + .eq("row_id", row.id) .eq("column_index", column_index); - let markdown = ""; - if (docActive) { - const buf = await downloadFile(docActive.storage_path); - if (buf) { - try { - markdown = await extractDocumentMarkdown( - buf, - docActive.file_type, - ); - } catch (err) { - console.error( - `[regenerate-cell] extraction error doc=${document_id}`, - err, - ); - } - } - } + const markdown = await loadRowDocumentText(db, row); const result = await queryTabularCell( tabular_model, - docActive?.filename?.trim() || "Untitled document", + row.label, markdown, column.prompt, column.format, @@ -805,7 +1127,7 @@ tabularRouter.post( .from("tabular_cells") .update({ status: "error" }) .eq("review_id", reviewId) - .eq("document_id", document_id) + .eq("row_id", row.id) .eq("column_index", column_index); return void res.status(500).json({ detail: "Generation failed" }); } @@ -814,7 +1136,7 @@ tabularRouter.post( .from("tabular_cells") .update({ content: JSON.stringify(result), status: "done" }) .eq("review_id", reviewId) - .eq("document_id", document_id) + .eq("row_id", row.id) .eq("column_index", column_index); res.json(result); @@ -849,43 +1171,26 @@ tabularRouter.post("/:reviewId/generate", requireAuth, async (req, res) => { if (columns.length === 0) return void res.status(400).json({ detail: "No columns configured" }); - const { data: cells } = await db + let rows = await loadReviewRows(db, reviewId); + + const { data: cells, error: cellsError } = await db .from("tabular_cells") .select("*") .eq("review_id", reviewId); + if (cellsError) + return void res.status(500).json({ detail: cellsError.message }); const cellMap = new Map>(); for (const cell of cells ?? []) - cellMap.set(`${cell.document_id}:${cell.column_index}`, cell); + cellMap.set(`${cell.row_id}:${cell.column_index}`, cell); - const docIds = [...new Set((cells ?? []).map((c) => c.document_id))]; - const allowedDocIds = new Set( - await filterAccessibleDocumentIds(docIds, userId, userEmail, db), + const sourceIds = [ + ...new Set(rows.flatMap((row) => row.source_document_ids ?? [])), + ]; + const allowedSourceIds = new Set( + await filterAccessibleDocumentIds(sourceIds, userId, userEmail, db), ); - let docs: Record[] = []; - if (docIds.length > 0) { - const filteredIds = docIds.filter((id) => allowedDocIds.has(id)); - const { data } = - filteredIds.length > 0 - ? await db - .from("documents") - .select("id, current_version_id") - .in("id", filteredIds) - : { data: [] as Record[] }; - docs = data ?? []; - } else if (review.project_id) { - const { data } = await db - .from("documents") - .select("id, current_version_id") - .eq("project_id", review.project_id) - .order("created_at", { ascending: true }); - docs = data ?? []; - } - await attachActiveVersionPaths( - db, - docs as { - id: string; - current_version_id?: string | null; - }[], + rows = rows.filter((row) => + (row.source_document_ids ?? []).every((id) => allowedSourceIds.has(id)), ); const { tabular_model, api_keys } = await getUserModelSettings(userId, db); @@ -907,38 +1212,15 @@ tabularRouter.post("/:reviewId/generate", requireAuth, async (req, res) => { try { await Promise.all( - docs.map(async (doc) => { - const docId = doc.id as string; - let markdown = ""; - - const filename = - (typeof doc.filename === "string" && doc.filename.trim() - ? doc.filename.trim() - : "Untitled document"); - const storagePath = - typeof doc.storage_path === "string" ? doc.storage_path : ""; - const fileType = - typeof doc.file_type === "string" ? doc.file_type : ""; - if (storagePath) { - const buf = await downloadFile(storagePath); - if (buf) { - try { - markdown = await extractDocumentMarkdown( - buf, - fileType, - ); - } catch (err) { - console.error( - `[tabular/generate] extraction error doc=${docId}`, - err, - ); - } - } - } + rows.map(async (row) => { + const markdown = await loadRowDocumentText( + db, + row, + ); // Filter to only columns that need processing const columnsToProcess = columns.filter((col) => { - const cell = cellMap.get(`${docId}:${col.index}`); + const cell = cellMap.get(`${row.id}:${col.index}`); return !(cell?.status === "done" && cell?.content); }); if (columnsToProcess.length === 0) return; @@ -946,9 +1228,9 @@ tabularRouter.post("/:reviewId/generate", requireAuth, async (req, res) => { // Mark all as generating upfront for (const col of columnsToProcess) { write( - `data: ${JSON.stringify({ type: "cell_update", document_id: docId, column_index: col.index, content: null, status: "generating" })}\n\n`, + `data: ${JSON.stringify({ type: "cell_update", row_id: row.id, column_index: col.index, content: null, status: "generating" })}\n\n`, ); - const existingCell = cellMap.get(`${docId}:${col.index}`); + const existingCell = cellMap.get(`${row.id}:${col.index}`); if (existingCell) { await db .from("tabular_cells") @@ -957,7 +1239,8 @@ tabularRouter.post("/:reviewId/generate", requireAuth, async (req, res) => { } else { await db.from("tabular_cells").insert({ review_id: reviewId, - document_id: docId, + row_id: row.id, + document_id: row.document_id, column_index: col.index, status: "generating", }); @@ -969,7 +1252,7 @@ tabularRouter.post("/:reviewId/generate", requireAuth, async (req, res) => { try { await queryTabularAllColumns( tabular_model, - filename, + row.label, markdown, columnsToProcess, async (columnIndex, result) => { @@ -981,17 +1264,17 @@ tabularRouter.post("/:reviewId/generate", requireAuth, async (req, res) => { status: "done", }) .eq("review_id", reviewId) - .eq("document_id", docId) + .eq("row_id", row.id) .eq("column_index", columnIndex); write( - `data: ${JSON.stringify({ type: "cell_update", document_id: docId, column_index: columnIndex, content: result, status: "done" })}\n\n`, + `data: ${JSON.stringify({ type: "cell_update", row_id: row.id, column_index: columnIndex, content: result, status: "done" })}\n\n`, ); }, api_keys, ); } catch (err) { console.error( - `[tabular/generate] queryTabularAllColumns error doc=${docId}`, + `[tabular/generate] queryTabularAllColumns error row=${row.id}`, safeErrorLog(err), ); } @@ -1003,10 +1286,10 @@ tabularRouter.post("/:reviewId/generate", requireAuth, async (req, res) => { .from("tabular_cells") .update({ status: "error" }) .eq("review_id", reviewId) - .eq("document_id", docId) + .eq("row_id", row.id) .eq("column_index", col.index); write( - `data: ${JSON.stringify({ type: "cell_update", document_id: docId, column_index: col.index, content: null, status: "error" })}\n\n`, + `data: ${JSON.stringify({ type: "cell_update", row_id: row.id, column_index: col.index, content: null, status: "error" })}\n\n`, ); } } @@ -1282,39 +1565,12 @@ tabularRouter.post("/:reviewId/chat", requireAuth, async (req, res) => { if (!reviewAccess.ok) return void res.status(404).json({ detail: "Review not found" }); - // Fetch all cells and documents for this review + // Fetch all cells and logical review rows for this review. const { data: cells } = await db .from("tabular_cells") .select("*") .eq("review_id", reviewId); - - const docIds = [ - ...new Set((cells ?? []).map((c: any) => c.document_id as string)), - ]; - let docs: { - id: string; - filename: string; - current_version_id?: string | null; - }[] = []; - if (docIds.length > 0) { - const { data } = await db - .from("documents") - .select("id, current_version_id") - .in("id", docIds) - .order("created_at", { ascending: true }); - const attachedDocs = (data ?? []) as { - id: string; - current_version_id?: string | null; - filename?: string | null; - }[]; - await attachActiveVersionPaths(db, attachedDocs); - docs = attachedDocs.map((doc) => ({ - ...doc, - filename: - (typeof doc.filename === "string" && doc.filename.trim()) || - "Untitled document", - })); - } + const rows = await loadReviewRows(db, reviewId); const sortedColumns = ( (review.columns_config ?? []) as { index: number; name: string }[] @@ -1322,10 +1578,13 @@ tabularRouter.post("/:reviewId/chat", requireAuth, async (req, res) => { const tabularStore: TabularCellStore = { columns: sortedColumns, - documents: docs, + documents: rows.map((row) => ({ + id: row.id, + filename: row.label, + })), cells: new Map( (cells ?? []).map((c: any) => [ - `${c.column_index}:${c.document_id}`, + `${c.column_index}:${c.row_id}`, parseCellContent(c.content), ]), ), @@ -1596,14 +1855,14 @@ async function queryTabularCell( The "summary" and "reasoning" field values may use markdown formatting (bullets, bold, italics, etc.) — the values are still plain JSON strings (escape newlines as \\n), but the text inside will be rendered as markdown in the UI. -The "summary" field must contain only the extracted value with inline citations — no explanation or reasoning. Every factual claim in "summary" must be followed immediately by a citation in the format [[page:N||quote:exact quoted text]], where N is the page number and the quote is a short verbatim excerpt (≤ 25 words). The quote must be narrowly scoped to the specific claim it supports — extract only the exact words that support that statement, not the surrounding sentence or paragraph. Do not have multiple claims share the same long quote; if two different statements need different evidence, give each its own short, narrowly-scoped quote. All reasoning and explanation belongs in "reasoning" only, which may also contain citations.`; +The "summary" field must contain only the extracted value with inline citations — no explanation or reasoning. Every factual claim in "summary" must be followed immediately by a citation in the format [[document:SOURCE_DOCUMENT_ID||page:N||quote:exact quoted text]], using the exact source document ID shown before the supporting document. For spreadsheets, use [[document:SOURCE_DOCUMENT_ID||sheet:SHEET_NAME||cell:A1||quote:exact cell text]]. The quote must be a short verbatim excerpt (≤ 25 words) narrowly scoped to the specific claim. Do not have multiple claims share the same long quote; if two different statements need different evidence, give each its own short, precise quote. All reasoning and explanation belongs in "reasoning" only, which may also contain citations.`; let raw: string; try { raw = await completeText({ model, systemPrompt: EXTRACTION_SYSTEM, - user: `Document: ${filename}\n\n${documentText.slice(0, 120_000)}\n\n---\nInstruction: ${fullPrompt}`, + user: `Document: ${filename}\n\n${documentText}\n\n---\nInstruction: ${fullPrompt}`, maxTokens: 2048, apiKeys, }); @@ -1673,54 +1932,6 @@ async function generateChatTitle( } } -function buildTabularContext( - columns: any[], - docs: any[], - cells: any[], -): string { - const lines: string[] = [ - "# Tabular Review Context\n", - "Columns (0-based index):", - ]; - columns.forEach((col: any, i: number) => - lines.push(`- COL:${i} → "${col.name}"`), - ); - lines.push("", "Documents (0-based row index):"); - docs.forEach((doc: any, i: number) => - lines.push(`- ROW:${i} → "${doc.filename}"`), - ); - lines.push("", "## Table Data\n"); - lines.push(`| Document | ${columns.map((c: any) => c.name).join(" | ")} |`); - lines.push(`|---|${columns.map(() => "---").join("|")}|`); - docs.forEach((doc: any, rowIdx: number) => { - const rowCells = columns.map((col: any, colPos: number) => { - const cell = cells.find( - (c: any) => - c.document_id === doc.id && c.column_index === col.index, - ) as any; - if ( - !cell || - cell.status === "pending" || - cell.status === "generating" - ) { - return `(pending) [[COL:${colPos}||ROW:${rowIdx}]]`; - } - if (cell.status === "error") { - return `(error) [[COL:${colPos}||ROW:${rowIdx}]]`; - } - const content = parseCellContent(cell.content); - const summary = content?.summary?.trim() || "(not yet generated)"; - const truncated = - summary.length > 400 ? summary.slice(0, 400) + "…" : summary; - return `${truncated} [[COL:${colPos}||ROW:${rowIdx}]]`; - }); - lines.push( - `| ROW:${rowIdx} ${doc.filename} | ${rowCells.join(" | ")} |`, - ); - }); - return lines.join("\n"); -} - type CellResult = { summary: string; flag: "green" | "grey" | "yellow" | "red"; @@ -1758,13 +1969,13 @@ Line format: {"column_index": , "summary": , "flag": <"green"|"grey"|"yellow"|"red">, "reasoning": } Rules: -- "summary": the extracted value with inline citations [[page:N||quote:verbatim excerpt ≤25 words]] after every factual claim. No explanation or reasoning here. Quotes must be narrowly scoped to the specific claim — extract only the exact supporting words, not the full surrounding sentence. Do not reuse one long quote across multiple statements; give each claim its own short, precise quote. +- "summary": the extracted value with inline citations [[document:SOURCE_DOCUMENT_ID||page:N||quote:verbatim excerpt ≤25 words]] after every factual claim, using the exact source document ID shown before the supporting document. For spreadsheets, use [[document:SOURCE_DOCUMENT_ID||sheet:SHEET_NAME||cell:A1||quote:exact cell text]]. No explanation or reasoning here. Quotes must be narrowly scoped to the specific claim — extract only the exact supporting words, not the full surrounding sentence. Do not reuse one long quote across multiple statements; give each claim its own short, precise quote. - "flag": green = standard/favorable, yellow = needs attention, red = problematic/unfavorable, grey = neutral/not found - "reasoning": brief explanation of the extraction - The "summary" and "reasoning" string VALUES may use markdown (bullets, bold, italics, etc.) — escape newlines as \\n inside the JSON string. This markdown is rendered in the UI. - Output ONLY the JSON lines themselves. Do NOT wrap the response in markdown code fences (e.g. \`\`\`json), and do not add any preamble or summary.`; - const USER = `Document: ${filename}\n\n${documentText.slice(0, 120_000)}\n\n---\nColumns to extract:\n${columnsDesc}`; + const USER = `Document: ${filename}\n\n${documentText}\n\n---\nColumns to extract:\n${columnsDesc}`; let contentBuffer = ""; const pending: Promise[] = []; diff --git a/frontend/src/app/(pages)/tabular-reviews/page.tsx b/frontend/src/app/(pages)/tabular-reviews/page.tsx index 550bbc995..d62fb23a9 100644 --- a/frontend/src/app/(pages)/tabular-reviews/page.tsx +++ b/frontend/src/app/(pages)/tabular-reviews/page.tsx @@ -212,6 +212,7 @@ export default function TabularReviewsPage() { columnsConfig?: | import("@/app/components/shared/types").ColumnConfig[] | null, + documentGrouping?: "document" | "folder", ) => { setCreating(true); try { @@ -219,6 +220,7 @@ export default function TabularReviewsPage() { title, document_ids: documentIds ?? [], columns_config: columnsConfig ?? [], + document_grouping: documentGrouping, ...(projectId && { project_id: projectId }), }); router.push( diff --git a/frontend/src/app/components/assistant/InitialView.tsx b/frontend/src/app/components/assistant/InitialView.tsx index e31b80ddf..de3fa7626 100644 --- a/frontend/src/app/components/assistant/InitialView.tsx +++ b/frontend/src/app/components/assistant/InitialView.tsx @@ -115,11 +115,13 @@ export function InitialView({ onSubmit }: InitialViewProps) { projectId?: string, documentIds?: string[], columnsConfig?: Workflow["columns_config"], + documentGrouping?: "document" | "folder", ) { const review = await createTabularReview({ title, document_ids: documentIds ?? [], columns_config: columnsConfig ?? [], + document_grouping: documentGrouping, ...(projectId && { project_id: projectId }), }); setNewTROpen(false); diff --git a/frontend/src/app/components/projects/ProjectWorkspace.tsx b/frontend/src/app/components/projects/ProjectWorkspace.tsx index 094a7aeac..207e36afe 100644 --- a/frontend/src/app/components/projects/ProjectWorkspace.tsx +++ b/frontend/src/app/components/projects/ProjectWorkspace.tsx @@ -251,6 +251,7 @@ export function ProjectWorkspaceProvider({ _projectId?: string, documentIds?: string[], columnsConfig?: ColumnConfig[] | null, + documentGrouping?: "document" | "folder", ) { setCreatingReview(true); try { @@ -260,6 +261,7 @@ export function ProjectWorkspaceProvider({ title: title || undefined, document_ids: documentIds ?? readyDocs.map((d) => d.id), columns_config: columnsConfig ?? [], + document_grouping: documentGrouping, project_id: projectId, }); router.push(`/projects/${projectId}/tabular-reviews/${review.id}`); @@ -401,6 +403,7 @@ export function ProjectWorkspaceProvider({ projectDocs={project?.documents?.filter( (d) => d.status === "ready", )} + projectFolders={folders} projectName={project?.name} projectCmNumber={project?.cm_number} /> diff --git a/frontend/src/app/components/shared/FileDirectory.test.tsx b/frontend/src/app/components/shared/FileDirectory.test.tsx new file mode 100644 index 000000000..20364bd63 --- /dev/null +++ b/frontend/src/app/components/shared/FileDirectory.test.tsx @@ -0,0 +1,170 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { Document, Folder } from "./types"; +import { FileDirectory } from "./FileDirectory"; + +vi.mock("./useDirectoryData", () => ({ + useDirectoryData: () => ({ + loadingTabs: {}, + standaloneDocuments: [ + { + id: "library-document-1", + filename: "Library agreement.pdf", + file_type: "pdf", + library_folder_id: "library-folder-1", + }, + ], + templateDocuments: [ + { + id: "template-document-1", + filename: "NDA template.docx", + file_type: "docx", + library_folder_id: "template-folder-1", + }, + ], + fileFolders: [ + { + id: "library-folder-1", + name: "Matter files", + parent_folder_id: null, + created_at: "2026-08-03T00:00:00.000Z", + }, + ], + templateFolders: [ + { + id: "template-folder-1", + name: "NDAs", + parent_folder_id: null, + created_at: "2026-08-03T00:00:00.000Z", + }, + ], + projects: [ + { + id: "project-1", + name: "Acquisition", + cm_number: null, + created_at: "2026-08-03T00:00:00.000Z", + documents: [ + { + id: "project-document-1", + filename: "Disclosure letter.docx", + file_type: "docx", + folder_id: "project-folder-1", + }, + ], + folders: [ + { + id: "project-folder-1", + name: "Disclosure", + parent_folder_id: null, + created_at: "2026-08-03T00:00:00.000Z", + }, + ], + }, + ], + loadTab: vi.fn(), + }), +})); + +describe("FileDirectory", () => { + it("renders supplied project folders and reveals their documents", () => { + const folder = { + id: "folder-1", + name: "Closing documents", + parent_folder_id: null, + created_at: "2026-08-03T00:00:00.000Z", + } as Folder; + const document = { + id: "document-1", + filename: "Agreement.pdf", + file_type: "pdf", + folder_id: folder.id, + } as Document; + + render( + , + ); + + expect(screen.getByText("Closing documents")).toBeInTheDocument(); + expect(screen.queryByText("Agreement.pdf")).not.toBeInTheDocument(); + const nameHeader = screen.getByText("Name"); + expect(nameHeader.parentElement?.firstElementChild).toBe(nameHeader); + + const folderRow = screen.getByText("Closing documents").closest("button"); + expect(folderRow).toHaveStyle({ paddingLeft: "8px" }); + fireEvent.click(folderRow!); + + expect(screen.getByText("Agreement.pdf")).toBeInTheDocument(); + expect(screen.getByText("Agreement.pdf").closest("button")).toHaveStyle({ + paddingLeft: "30px", + }); + }); + + it("renders folders inside projects on the Projects tab", () => { + render( + , + ); + + fireEvent.click(screen.getByText("Acquisition")); + expect(screen.getByText("Disclosure")).toBeInTheDocument(); + expect( + screen.queryByText("Disclosure letter.docx"), + ).not.toBeInTheDocument(); + + fireEvent.click(screen.getByText("Disclosure")); + expect(screen.getByText("Disclosure letter.docx")).toBeInTheDocument(); + }); + + it("only renders the configured tabs", () => { + render( + , + ); + + expect(screen.getByRole("button", { name: "Files" })).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Projects" }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Templates" }), + ).not.toBeInTheDocument(); + }); + + it.each([ + ["files", "Matter files", "Library agreement.pdf"], + ["templates", "NDAs", "NDA template.docx"], + ] as const)( + "renders folders on the %s tab", + (initialTab, folderName, filename) => { + render( + , + ); + + expect(screen.getByText(folderName)).toBeInTheDocument(); + expect(screen.queryByText(filename)).not.toBeInTheDocument(); + + fireEvent.click(screen.getByText(folderName)); + expect(screen.getByText(filename)).toBeInTheDocument(); + }, + ); +}); diff --git a/frontend/src/app/components/shared/FileDirectory.tsx b/frontend/src/app/components/shared/FileDirectory.tsx index 4b65ef235..62e019fcf 100644 --- a/frontend/src/app/components/shared/FileDirectory.tsx +++ b/frontend/src/app/components/shared/FileDirectory.tsx @@ -17,6 +17,11 @@ import { APP_SURFACE_HOVER_CLASS, } from "@/app/components/ui/liquid-surface"; +type DirectoryFolder = Pick< + LibraryFolder, + "id" | "name" | "parent_folder_id" | "created_at" +>; + const DIRECTORY_GRID_CLASS = "grid grid-cols-[14px_14px_minmax(0,1fr)_48px_84px_64px] items-center gap-2"; @@ -25,6 +30,7 @@ const DIRECTORY_TABS: { value: DirectoryTab; label: string }[] = [ { value: "templates", label: "Templates" }, { value: "projects", label: "Projects" }, ]; +const ALL_DIRECTORY_TAB_VALUES = DIRECTORY_TABS.map((tab) => tab.value); const EMPTY_DOCUMENTS: Document[] = []; const EMPTY_FOLDERS: LibraryFolder[] = []; @@ -64,7 +70,9 @@ interface FileDirectoryProps { uploadingFilenames?: string[]; showTabs: boolean; initialTab?: DirectoryTab; + tabs?: readonly DirectoryTab[]; excludeProjectId?: string; + folders?: DirectoryFolder[]; } export function FileDirectory({ @@ -75,7 +83,9 @@ export function FileDirectory({ uploadingFilenames = [], showTabs, initialTab = "files", + tabs = ALL_DIRECTORY_TAB_VALUES, excludeProjectId, + folders = EMPTY_FOLDERS, }: FileDirectoryProps) { const [expandedProjects, setExpandedProjects] = useState>( new Set(), @@ -83,13 +93,20 @@ export function FileDirectory({ const [expandedLibraryFolders, setExpandedLibraryFolders] = useState< Set >(new Set()); - const [selectedTab, setSelectedTab] = useState(initialTab); + const initialDirectoryTab = tabs.includes(initialTab) + ? initialTab + : (tabs[0] ?? "files"); + const availableTabs = DIRECTORY_TABS.filter((tab) => + tabs.includes(tab.value), + ); + const [selectedTab, setSelectedTab] = + useState(initialDirectoryTab); // Follow initialTab changes so keep-mounted parents (which never remount // this component) can still steer the starting tab per open. useEffect(() => { - setSelectedTab(initialTab); - }, [initialTab]); + setSelectedTab(initialDirectoryTab); + }, [initialDirectoryTab]); const [search, setSearch] = useState(""); const { loadingTabs, @@ -99,12 +116,17 @@ export function FileDirectory({ templateFolders: loadedTemplateFolders, projects, loadTab, - } = useDirectoryData(showTabs, initialTab); + } = useDirectoryData(showTabs, initialDirectoryTab); useEffect(() => { - if (!showTabs || initialTab === "templates") return; + if ( + !showTabs || + initialDirectoryTab === "templates" || + !tabs.includes("templates") + ) + return; void loadTab("templates"); - }, [initialTab, showTabs, loadTab]); + }, [initialDirectoryTab, showTabs, loadTab, tabs]); const directoryStandaloneDocs = useMemo( () => showTabs @@ -125,7 +147,7 @@ export function FileDirectory({ : EMPTY_DOCUMENTS; const directoryFileFolders = showTabs ? loadedFileFolders - : EMPTY_FOLDERS; + : folders; const directoryTemplateFolders = showTabs ? loadedTemplateFolders : EMPTY_FOLDERS; @@ -243,7 +265,7 @@ export function FileDirectory({ } function childFolders( - folders: LibraryFolder[], + folders: DirectoryFolder[], parentFolderId: string | null, ) { return folders.filter( @@ -256,7 +278,7 @@ export function FileDirectory({ } function collectFolderDocuments( - folders: LibraryFolder[], + folders: DirectoryFolder[], docs: Document[], folderId: string, ): Document[] { @@ -285,8 +307,7 @@ export function FileDirectory({ } function indentedRowPadding(depth: number) { - if (depth <= 0) return 8; - return 4 + depth * 20; + return 8 + Math.max(0, depth) * 22; } function renderDocumentRow(doc: Document, depth = 0) { @@ -329,8 +350,8 @@ export function FileDirectory({ ); } - function renderLibraryFolderRows( - folders: LibraryFolder[], + function renderFolderRows( + folders: DirectoryFolder[], docs: Document[], parentFolderId: string | null, depth = 0, @@ -391,7 +412,7 @@ export function FileDirectory({ {isExpanded && (
- {renderLibraryFolderRows( + {renderFolderRows( folders, docs, folder.id, @@ -434,6 +455,7 @@ export function FileDirectory({ onChange={handleTabChange} selectedCount={selectedIds.size} showTabs={showTabs} + tabs={availableTabs} /> )}
@@ -481,6 +503,7 @@ export function FileDirectory({ onChange={handleTabChange} selectedCount={selectedIds.size} showTabs={showTabs} + tabs={availableTabs} /> )}
@@ -507,6 +530,7 @@ export function FileDirectory({ onChange={handleTabChange} selectedCount={selectedIds.size} showTabs={showTabs} + tabs={availableTabs} /> )} {activeTabHasNoResults ? ( @@ -539,7 +563,7 @@ export function FileDirectory({
))} {!q && - renderLibraryFolderRows( + renderFolderRows( directoryFileFolders, directoryStandaloneDocs, null, @@ -562,7 +586,7 @@ export function FileDirectory({ {activeTab === "templates" && ( <> {!q && - renderLibraryFolderRows( + renderFolderRows( directoryTemplateFolders, directoryTemplateDocs, null, @@ -586,6 +610,7 @@ export function FileDirectory({ const isExpanded = !!q || expandedProjects.has(project.id); const docs = project.documents ?? []; + const projectFolders = project.folders ?? []; const projectDocIds = docs.map((doc) => doc.id); const allProjectDocsSelected = projectDocIds.length > 0 && @@ -661,66 +686,33 @@ export function FileDirectory({ {isExpanded && (
- {docs.length === 0 ? ( + {docs.length === 0 && + projectFolders.length === 0 ? (

Empty

) : ( - docs.map((doc) => { - const selected = - selectedIds.has(doc.id); - return ( - - ); - }) + <> + {!q && + renderFolderRows( + projectFolders, + docs, + null, + 1, + )} + {(q + ? docs + : folderDocuments( + docs, + null, + ) + ).map((doc) => + renderDocumentRow( + doc, + 1, + ), + )} + )}
)} @@ -741,15 +733,12 @@ export function FileDirectory({ ); } -function FileDirectoryHeader({ indented = false }: { indented?: boolean }) { +function FileDirectoryHeader() { return (
- - Name + Name Version Created Size @@ -782,17 +771,19 @@ function FileDirectoryControls({ onChange, selectedCount, showTabs, + tabs, }: { activeTab: DirectoryTab; onChange: (tab: DirectoryTab) => void; selectedCount: number; showTabs: boolean; + tabs: typeof DIRECTORY_TABS; }) { return (
{showTabs ? (
- {DIRECTORY_TABS.map((tab) => { + {tabs.map((tab) => { const active = activeTab === tab.value; return ( ({ + getProject: vi.fn(), + listWorkflows: vi.fn(async () => []), + uploadProjectDocument: vi.fn(), + uploadStandaloneDocument: vi.fn(), +})); + +vi.mock("../shared/FileDirectory", () => ({ + FileDirectory: ({ tabs }: { tabs?: string[] }) => ( +
+ Document directory + {tabs?.join(",")} +
+ ), +})); + +describe("NewTRModal", () => { + it("shows folder grouping on the first screen and excludes Templates", () => { + const onAdd = vi.fn(); + render( + , + ); + + expect(screen.getByText("Document grouping")).toBeInTheDocument(); + expect( + screen.getByText( + "Treat documents in the same folder as one review row", + ), + ).toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText("Review name"), { + target: { value: "Closing review" }, + }); + const groupingSwitch = screen.getByRole("switch", { + name: "Treat documents in the same folder as one review row", + }); + expect(groupingSwitch).toHaveAttribute("aria-checked", "false"); + fireEvent.click(groupingSwitch); + expect(groupingSwitch).toHaveAttribute("aria-checked", "true"); + fireEvent.click(screen.getByRole("button", { name: "Next" })); + + expect(screen.getByText("Document directory")).toBeInTheDocument(); + expect(screen.getByTestId("directory-tabs")).toHaveTextContent( + "files,projects", + ); + expect(screen.queryByText("Document grouping")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Create" })); + expect(onAdd).toHaveBeenCalledWith( + "Closing review", + undefined, + undefined, + undefined, + "folder", + ); + }); +}); diff --git a/frontend/src/app/components/tabular/NewTRModal.tsx b/frontend/src/app/components/tabular/NewTRModal.tsx index fd969cbce..fc29f984a 100644 --- a/frontend/src/app/components/tabular/NewTRModal.tsx +++ b/frontend/src/app/components/tabular/NewTRModal.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { Loader2, Upload } from "lucide-react"; -import type { Document, Project, Workflow } from "../shared/types"; +import type { Document, Folder, Project, Workflow } from "../shared/types"; import { getProject, listWorkflows, @@ -14,11 +14,13 @@ import { Modal } from "../modals/Modal"; import { ModalFieldLabel } from "../modals/ModalFieldLabel"; import { ModalSelect } from "../modals/ModalSelect"; import { ModalTextInput } from "../modals/ModalTextInput"; +import { ToggleSwitch } from "@/app/components/ui/toggle-switch"; const isDev = process.env.NODE_ENV !== "production"; const devLog = (...args: Parameters) => { if (isDev) console.log(...args); }; +const TABULAR_DIRECTORY_TABS = ["files", "projects"] as const; interface Props { open: boolean; @@ -28,10 +30,12 @@ interface Props { projectId?: string, documentIds?: string[], columnsConfig?: Workflow["columns_config"], + documentGrouping?: "document" | "folder", ) => void; projects?: Project[]; /** When provided, skip the project/directory picker and show only these docs */ projectDocs?: Document[]; + projectFolders?: Folder[]; projectName?: string; projectCmNumber?: string | null; } @@ -42,6 +46,7 @@ export function NewTRModal({ onAdd, projects = [], projectDocs: fixedProjectDocs, + projectFolders: fixedProjectFolders, projectName, projectCmNumber, }: Props) { @@ -53,12 +58,14 @@ export function NewTRModal({ // Project-scoped docs (when underProject is true and no fixedProjectDocs) const [projectDocs, setProjectDocs] = useState([]); + const [projectFolders, setProjectFolders] = useState([]); const [loadingDocs, setLoadingDocs] = useState(false); const [extraStandaloneDocs, setExtraStandaloneDocs] = useState( [], ); const [selectedDocuments, setSelectedDocuments] = useState([]); + const [groupBySubfolder, setGroupBySubfolder] = useState(false); const [uploading, setUploading] = useState(false); const fileInputRef = useRef(null); @@ -113,8 +120,10 @@ export function NewTRModal({ setUnderProject(false); setSelectedProjectId(""); setProjectDocs([]); + setProjectFolders([]); setExtraStandaloneDocs([]); setSelectedDocuments([]); + setGroupBySubfolder(false); setSelectedWorkflowId(null); onClose(); } @@ -145,6 +154,7 @@ export function NewTRModal({ ? selectedDocuments.map((document) => document.id) : undefined, selectedWorkflow?.columns_config ?? undefined, + groupBySubfolder ? "folder" : "document", ); handleClose(); } @@ -152,6 +162,7 @@ export function NewTRModal({ async function handleSelectProject(projectId: string) { setSelectedProjectId(projectId); setProjectDocs([]); + setProjectFolders([]); setSelectedDocuments([]); setLoadingDocs(true); try { @@ -160,6 +171,7 @@ export function NewTRModal({ (d) => d.status === "ready", ); setProjectDocs(docs); + setProjectFolders(proj.folders ?? []); setSelectedDocuments(docs); } finally { setLoadingDocs(false); @@ -225,6 +237,11 @@ export function NewTRModal({ : underProject ? projectDocs : extraStandaloneDocs; + const directoryFolders = isProjectMode + ? (fixedProjectFolders ?? []) + : underProject + ? projectFolders + : []; const directoryLoading = isProjectMode ? false : underProject @@ -349,30 +366,20 @@ export function NewTRModal({ Project - + Create under a project + {underProject && ( )} + +
+ + Document grouping + + + Treat documents in the same folder as one review row + +
) : (
{showDirectory && ( )}
diff --git a/frontend/src/app/components/tabular/TRChatPanel.tsx b/frontend/src/app/components/tabular/TRChatPanel.tsx index 911995330..08d6a495b 100644 --- a/frontend/src/app/components/tabular/TRChatPanel.tsx +++ b/frontend/src/app/components/tabular/TRChatPanel.tsx @@ -26,7 +26,7 @@ import { type TRChat, type TRCitationAnnotation, } from "@/app/lib/mikeApi"; -import type { AssistantEvent, ColumnConfig, Document } from "../shared/types"; +import type { AssistantEvent } from "../shared/types"; import { ModelToggle } from "../assistant/ModelToggle"; import { ApiKeyMissingPopup } from "../popups/ApiKeyMissingPopup"; import { PreResponseWrapper } from "../assistant/PreResponseWrapper"; @@ -121,8 +121,6 @@ interface Props { reviewId: string; reviewTitle?: string | null; projectName?: string | null; - columns: ColumnConfig[]; - documents: Document[]; onCitationClick: (colIdx: number, rowIdx: number) => void; onClose: () => void; initialChatId?: string | null; @@ -756,8 +754,6 @@ export function TRChatPanel({ reviewId, reviewTitle, projectName, - columns: _columns, - documents: _documents, onCitationClick, onClose, initialChatId, diff --git a/frontend/src/app/components/tabular/TRExpandedCellSurface.tsx b/frontend/src/app/components/tabular/TRExpandedCellSurface.tsx new file mode 100644 index 000000000..bf1d4b883 --- /dev/null +++ b/frontend/src/app/components/tabular/TRExpandedCellSurface.tsx @@ -0,0 +1,9 @@ +import type { ReactNode } from "react"; + +export function TRExpandedCellSurface({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} diff --git a/frontend/src/app/components/tabular/TRFirstColumnCell.tsx b/frontend/src/app/components/tabular/TRFirstColumnCell.tsx new file mode 100644 index 000000000..0e015cba5 --- /dev/null +++ b/frontend/src/app/components/tabular/TRFirstColumnCell.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { CornerDownRight } from "lucide-react"; +import type { Document, TabularReviewRow } from "../shared/types"; +import { FileTypeIcon } from "../shared/FileTypeIcon"; +import { ClosedFolderSvgIcon } from "../shared/FolderSvgIcon"; +import { TABLE_CHECKBOX_CLASS } from "../shared/TablePrimitive"; +import { TRExpandedCellSurface } from "./TRExpandedCellSurface"; + +interface Props { + row: TabularReviewRow; + sourceDocuments: Document[]; + selected: boolean; + closeSignal: number; + className: string; + onToggleSelection: () => void; +} + +export function TRFirstColumnCell({ + row, + sourceDocuments, + selected, + closeSignal, + className, + onToggleSelection, +}: Props) { + const [expanded, setExpanded] = useState(false); + const containerRef = useRef(null); + + useEffect(() => { + const timeout = window.setTimeout(() => setExpanded(false), 0); + return () => window.clearTimeout(timeout); + }, [closeSignal]); + + useEffect(() => { + if (!expanded) return; + function handleClickOutside(event: MouseEvent) { + if ( + containerRef.current && + !containerRef.current.contains(event.target as Node) + ) { + setExpanded(false); + } + } + document.addEventListener("mousedown", handleClickOutside); + return () => + document.removeEventListener("mousedown", handleClickOutside); + }, [expanded]); + + return ( +
+ + {row.row_type === "folder" ? ( + + ) : ( + <> + + + {row.label} + + + )} + + {row.row_type === "folder" && expanded && ( + +
+
+ + + {row.label} + +
+
+ {sourceDocuments.map((document) => ( +
+
+ ))} +
+
+
+ )} +
+ ); +} diff --git a/frontend/src/app/components/tabular/TRSidePanel.test.tsx b/frontend/src/app/components/tabular/TRSidePanel.test.tsx new file mode 100644 index 000000000..4cf84037f --- /dev/null +++ b/frontend/src/app/components/tabular/TRSidePanel.test.tsx @@ -0,0 +1,96 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { + ColumnConfig, + Document, + TabularCell, + TabularReviewRow, +} from "../shared/types"; +import { TRSidePanel } from "./TRSidePanel"; + +vi.mock("../shared/views/PdfView", () => ({ + PdfView: ({ doc }: { doc: { document_id: string } }) => ( +
PDF {doc.document_id}
+ ), +})); +vi.mock("../shared/views/DocxView", () => ({ + DocxView: () =>
DOCX
, +})); +vi.mock("../shared/views/SpreadsheetView", () => ({ + SpreadsheetView: () =>
Spreadsheet
, +})); + +describe("TRSidePanel", () => { + it("opens the source document encoded in a grouped-row citation", () => { + const documents = [ + { + id: "doc-1", + filename: "First.pdf", + file_type: "pdf", + }, + { + id: "doc-2", + filename: "Second.pdf", + file_type: "pdf", + }, + ] as Document[]; + const row = { + id: "row-1", + label: "Closing", + row_type: "folder", + document_id: null, + source_document_ids: ["doc-1", "doc-2"], + } as TabularReviewRow; + const column = { + index: 0, + name: "Clause", + prompt: "Extract the clause", + } as ColumnConfig; + const cell = { + id: "cell-1", + row_id: row.id, + column_index: column.index, + status: "done", + content: { + summary: + "Answer [[document:doc-2||page:4||quote:Exact language]]", + flag: "grey", + reasoning: "", + }, + } as TabularCell; + + const { container } = render( + , + ); + + expect( + container.querySelector('img[src*="folder-closed"]'), + ).toBeInTheDocument(); + + const folderButton = screen.getByRole("button", { name: "Closing" }); + expect(folderButton).toHaveAttribute("aria-expanded", "false"); + expect( + screen.queryByRole("button", { name: "First.pdf" }), + ).not.toBeInTheDocument(); + + fireEvent.click(folderButton); + + expect(folderButton).toHaveAttribute("aria-expanded", "true"); + fireEvent.click(screen.getByRole("button", { name: "First.pdf" })); + + expect(screen.getByText("PDF doc-1")).toBeInTheDocument(); + + fireEvent.click(screen.getByTitle('Page 4: "Exact language"')); + + expect(screen.getByText("PDF doc-2")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/app/components/tabular/TRSidePanel.tsx b/frontend/src/app/components/tabular/TRSidePanel.tsx index 47e7d23b8..57c3c4dfb 100644 --- a/frontend/src/app/components/tabular/TRSidePanel.tsx +++ b/frontend/src/app/components/tabular/TRSidePanel.tsx @@ -20,7 +20,12 @@ import { RefreshCw, X, } from "lucide-react"; -import type { ColumnConfig, Document, TabularCell } from "../shared/types"; +import type { + ColumnConfig, + Document, + TabularCell, + TabularReviewRow, +} from "../shared/types"; import { isSpreadsheetFilename } from "../shared/types"; import { preprocessCitations, type ParsedCitation } from "./citation-utils"; import { getPillClass } from "./pillUtils"; @@ -28,6 +33,7 @@ import { PdfView } from "../shared/views/PdfView"; import { SpreadsheetView } from "../shared/views/SpreadsheetView"; import { DocxView } from "../shared/views/DocxView"; import { FileTypeIcon } from "../shared/FileTypeIcon"; +import { SubfolderSvgIcon } from "../shared/FolderSvgIcon"; import { CitationQuotesHeader } from "../assistant/CitationQuotesHeader"; import { cn } from "@/app/lib/utils"; import { @@ -48,12 +54,14 @@ function isDocxDocument(d: { interface Props { cell: TabularCell; - document: Document; - documents: Document[]; + row: TabularReviewRow; + rows: TabularReviewRow[]; + document?: Document; + documents?: Document[]; column: ColumnConfig; columns: ColumnConfig[]; onClose: () => void; - onNavigate: (documentId: string, columnIndex: number) => void; + onNavigate: (rowId: string, columnIndex: number) => void; onRegenerate?: () => Promise; /** If true, open the document panel immediately */ displayDocument?: boolean; @@ -65,11 +73,14 @@ interface Props { citationSheet?: string; /** Spreadsheet A1 cell address or range */ citationCell?: string; + /** Source document encoded in a grouped-row citation. */ + citationDocumentId?: string; /** One-based citation number shown in the cell content */ citationRef?: number; } type TRPanelCitation = { + documentId?: string; quote: string; page?: number; sheet?: string; @@ -95,8 +106,10 @@ const INFO_PANE_WIDTH = 300; export function TRSidePanel({ cell, - document: doc, - documents, + row, + rows, + document: initialDocument, + documents = [], column, columns, onClose, @@ -107,6 +120,7 @@ export function TRSidePanel({ citationPage, citationSheet, citationCell, + citationDocumentId, citationRef, }: Props) { const sortedColumns = [...columns].sort((a, b) => a.index - b.index); @@ -117,17 +131,34 @@ export function TRSidePanel({ currentPos >= 0 && currentPos < sortedColumns.length - 1 ? sortedColumns[currentPos + 1] : null; - const currentDocumentPos = documents.findIndex( - (candidate) => candidate.id === doc.id, + const currentRowPos = rows.findIndex( + (candidate) => candidate.id === row.id, ); - const previousDocument = - currentDocumentPos > 0 ? documents[currentDocumentPos - 1] : null; - const nextDocument = - currentDocumentPos >= 0 && currentDocumentPos < documents.length - 1 - ? documents[currentDocumentPos + 1] + const previousRow = currentRowPos > 0 ? rows[currentRowPos - 1] : null; + const nextRow = + currentRowPos >= 0 && currentRowPos < rows.length - 1 + ? rows[currentRowPos + 1] : null; + const sourceDocuments = row.source_document_ids.flatMap((documentId) => { + const sourceDocument = documents.find( + (document) => document.id === documentId, + ); + return sourceDocument ? [sourceDocument] : []; + }); const [regenerating, setRegenerating] = useState(false); - const [documentPaneOpen, setDocumentPaneOpen] = useState(displayDocument); + const [folderExpanded, setFolderExpanded] = useState(false); + const [activeDocumentId, setActiveDocumentId] = useState( + citationDocumentId ?? initialDocument?.id, + ); + const doc = + documents.find( + (document) => + document.id === activeDocumentId && + row.source_document_ids.includes(document.id), + ) ?? initialDocument; + const [documentPaneOpen, setDocumentPaneOpen] = useState( + displayDocument && !!doc, + ); const [documentPaneWidth, setDocumentPaneWidth] = useState( DEFAULT_DOCUMENT_PANE_WIDTH, ); @@ -144,6 +175,7 @@ export function TRSidePanel({ page: citationPage, sheet: citationSheet, cell: citationCell, + documentId: citationDocumentId, citationRef, } : undefined, @@ -158,19 +190,32 @@ export function TRSidePanel({ page: citationPage, sheet: citationSheet, cell: citationCell, + documentId: citationDocumentId, citationRef, } : undefined, ); - setDocumentPaneOpen(displayDocument); + const nextDocument = citationDocumentId + ? documents.find( + (document) => + document.id === citationDocumentId && + row.source_document_ids.includes(document.id), + ) + : initialDocument; + setActiveDocumentId(nextDocument?.id); + setDocumentPaneOpen(displayDocument && !!nextDocument); }, [ cell.id, displayDocument, citationCell, + citationDocumentId, citationPage, citationQuote, citationRef, citationSheet, + documents, + initialDocument, + row, ]); useEffect( @@ -245,6 +290,22 @@ export function TRSidePanel({ function handleCitationOpen(citation: TRPanelCitation) { setDocCitation(citation); + const citedDocument = citation.documentId + ? documents.find( + (document) => + document.id === citation.documentId && + row.source_document_ids.includes(document.id), + ) + : doc; + if (citedDocument) { + setActiveDocumentId(citedDocument.id); + setDocumentPaneOpen(true); + } + } + + function handleSourceDocumentOpen(sourceDocument: Document) { + setActiveDocumentId(sourceDocument.id); + setDocCitation(undefined); setDocumentPaneOpen(true); } @@ -263,7 +324,7 @@ export function TRSidePanel({ )} > {/* Resizable document panel — left */} - {documentPaneOpen && ( + {documentPaneOpen && doc && (
{/* Header */}
- + {doc && ( + + )} {onRegenerate && ( + {folderExpanded && ( +
+ {sourceDocuments.map( + (sourceDocument) => ( + + ), + )} +
+ )}
-
+ ) : ( +
+ +
+ {row.label} +
+
+ )}
{/* Column field */} @@ -495,12 +636,12 @@ export function TRSidePanel({
- previousDocument && - onNavigate(previousDocument.id, column.index) + previousRow && + onNavigate(previousRow.id, column.index) } > @@ -512,7 +653,7 @@ export function TRSidePanel({ disabled={!previousColumn} onClick={() => previousColumn && - onNavigate(doc.id, previousColumn.index) + onNavigate(row.id, previousColumn.index) } > @@ -525,19 +666,18 @@ export function TRSidePanel({ disabled={!nextColumn} onClick={() => nextColumn && - onNavigate(doc.id, nextColumn.index) + onNavigate(row.id, nextColumn.index) } > - nextDocument && - onNavigate(nextDocument.id, column.index) + nextRow && onNavigate(nextRow.id, column.index) } > @@ -598,7 +738,7 @@ function citationKey(cellId: string, citation: ParsedCitation): string { const location = citation.sheet ? `${citation.sheet}:${citation.cell ?? ""}` : `page:${citation.page ?? 1}`; - return `tr-cell:${cellId}:${location}`; + return `tr-cell:${cellId}:${citation.documentId ?? "document"}:${location}`; } function CitationBadge({ @@ -616,6 +756,7 @@ function CitationBadge({ data-page={citation.page} data-sheet={citation.sheet} data-cell={citation.cell} + data-document-id={citation.documentId} data-quote={citation.quote} title={`${formatCitationLocation(citation)}: "${citation.quote}"`} onClick={() => @@ -624,6 +765,7 @@ function CitationBadge({ page: citation.page, sheet: citation.sheet, cell: citation.cell, + documentId: citation.documentId, citationRef: index + 1, }) } diff --git a/frontend/src/app/components/tabular/TRTable.test.tsx b/frontend/src/app/components/tabular/TRTable.test.tsx index 7d13e7817..072bf8298 100644 --- a/frontend/src/app/components/tabular/TRTable.test.tsx +++ b/frontend/src/app/components/tabular/TRTable.test.tsx @@ -1,20 +1,42 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { TRTable } from "./TRTable"; -import type { Document } from "../shared/types"; +import type { Document, TabularReviewRow } from "../shared/types"; -const doc = { id: "doc-1", filename: "report.pdf" } as Document; +const row = { + id: "row-1", + label: "Contracts", + row_type: "folder", + document_id: null, + source_document_ids: ["doc-1", "doc-2"], +} as TabularReviewRow; +const documents = [ + { + id: "doc-1", + filename: "Agreement.pdf", + file_type: "pdf", + }, + { + id: "doc-2", + filename: "Schedule.docx", + file_type: "docx", + }, +] as Document[]; -function renderTable() { +function renderTable( + documentGrouping: "document" | "folder" = "folder", +) { return render( { // The grid here is div-based (no table/columnheader/rowheader roles), so // this asserts on rendered content rather than ARIA table semantics. - it("renders the Document header and a row for each document", () => { + it("renders one table row for a grouped folder", () => { renderTable(); - expect(screen.getByText("Document")).toBeInTheDocument(); - expect(screen.getByText("report.pdf")).toBeInTheDocument(); - // One select-all checkbox in the header plus one per document row. + expect(screen.getByText("Folder / Document")).toBeInTheDocument(); + expect(screen.getByText("Contracts")).toBeInTheDocument(); + // One select-all checkbox in the header plus one per logical review row. expect(screen.getAllByRole("checkbox")).toHaveLength(2); }); + + it("uses Document as the header for document grouping", () => { + renderTable("document"); + expect(screen.getByText("Document")).toBeInTheDocument(); + expect( + screen.queryByText("Folder / Document"), + ).not.toBeInTheDocument(); + }); + + it("expands a folder row to list its source documents", () => { + renderTable(); + fireEvent.click(screen.getByText("Contracts")); + expect(screen.getByText("Agreement.pdf")).toBeInTheDocument(); + expect(screen.getByText("Schedule.docx")).toBeInTheDocument(); + }); }); diff --git a/frontend/src/app/components/tabular/TRTable.tsx b/frontend/src/app/components/tabular/TRTable.tsx index 1da4b263d..a02bcc2c3 100644 --- a/frontend/src/app/components/tabular/TRTable.tsx +++ b/frontend/src/app/components/tabular/TRTable.tsx @@ -11,6 +11,7 @@ import type { ColumnConfig, Document, TabularCell, + TabularReviewRow, } from "../shared/types"; import { TabularCell as TabularCellComponent } from "./TabularCell"; import { TREditColumnMenu } from "./TREditColumnMenu"; @@ -22,6 +23,7 @@ import { } from "../shared/TablePrimitive"; import { PillButton } from "@/app/components/ui/pill-button"; import { TabularReviewSkeuoIcon } from "@/app/components/shared/AppSidebarSkeuoIcons"; +import { TRFirstColumnCell } from "./TRFirstColumnCell"; import { APP_SURFACE_ACTIVE_CLASS, APP_SURFACE_GROUP_HOVER_CLASS, @@ -47,12 +49,14 @@ export interface TRTableHandle { interface Props { loading: boolean; + documentGrouping: "document" | "folder"; columns: ColumnConfig[]; + rows: TabularReviewRow[]; documents: Document[]; cells: TabularCell[]; savingColumn: boolean; savingColumnsConfig: boolean; - selectedDocIds: string[]; + selectedRowIds: string[]; uploadingFilenames?: string[]; dragOverFiles?: boolean; highlightedCell?: { colIdx: number; rowIdx: number } | null; @@ -65,6 +69,7 @@ interface Props { citationRef: number, sheet?: string, citationCell?: string, + documentId?: string, ) => void; onUpdateColumn: (col: ColumnConfig) => void; onDeleteColumn: (colIndex: number) => void; @@ -75,12 +80,14 @@ interface Props { export const TRTable = forwardRef(function TRTable( { loading, + documentGrouping, columns, + rows, documents, cells, savingColumn, savingColumnsConfig, - selectedDocIds, + selectedRowIds, uploadingFilenames = [], dragOverFiles = false, highlightedCell, @@ -109,6 +116,11 @@ export const TRTable = forwardRef(function TRTable( } const sortedColumns = [...columns].sort((a, b) => a.index - b.index); + const documentsById = new Map( + documents.map((document) => [document.id, document]), + ); + const firstColumnLabel = + documentGrouping === "folder" ? "Folder / Document" : "Document"; const totalContentWidth = DOC_COL_W_PX + sortedColumns.length * DATA_COL_W_PX + 32; const skeletonContentWidth = @@ -140,31 +152,31 @@ export const TRTable = forwardRef(function TRTable( }, })); - function getCell(docId: string, colIdx: number) { + function getCell(row: TabularReviewRow, colIdx: number) { return cells.find( - (c) => c.document_id === docId && c.column_index === colIdx, + (cell) => + cell.row_id === row.id && cell.column_index === colIdx, ); } const allSelected = - documents.length > 0 && - documents.every((d) => selectedDocIds.includes(d.id)); + rows.length > 0 && rows.every((row) => selectedRowIds.includes(row.id)); const someSelected = - !allSelected && documents.some((d) => selectedDocIds.includes(d.id)); + !allSelected && rows.some((row) => selectedRowIds.includes(row.id)); function toggleAll() { if (allSelected) { onSelectionChange([]); } else { - onSelectionChange(documents.map((d) => d.id)); + onSelectionChange(rows.map((row) => row.id)); } } - function toggleDoc(id: string) { - if (selectedDocIds.includes(id)) { - onSelectionChange(selectedDocIds.filter((x) => x !== id)); + function toggleRow(id: string) { + if (selectedRowIds.includes(id)) { + onSelectionChange(selectedRowIds.filter((x) => x !== id)); } else { - onSelectionChange([...selectedDocIds, id]); + onSelectionChange([...selectedRowIds, id]); } } @@ -180,7 +192,7 @@ export const TRTable = forwardRef(function TRTable( className={`sticky left-0 z-[80] ${DOC_COL_W} ${TR_STICKY_CELL_BG} flex items-center border-b border-r border-gray-200 py-2 pl-4 pr-2 text-xs font-medium text-gray-500`} > - Document + {firstColumnLabel}
{Array.from({ length: SKELETON_COLS }).map((_, i) => (
(function TRTable( if ( columns.length === 0 && - documents.length === 0 && + rows.length === 0 && uploadingFilenames.length === 0 ) { return ( @@ -231,7 +243,7 @@ export const TRTable = forwardRef(function TRTable(
- Document + {firstColumnLabel}
@@ -296,7 +308,7 @@ export const TRTable = forwardRef(function TRTable( onChange={toggleAll} className={TABLE_CHECKBOX_CLASS} /> - Document + {firstColumnLabel}
{columns.map((col) => (
(function TRTable(
))} - {documents.map((doc, docIdx) => { - const isSelected = selectedDocIds.includes(doc.id); + {rows.map((row, rowIdx) => { + const isSelected = selectedRowIds.includes(row.id); + const sourceDocuments = row.source_document_ids + .map((documentId) => documentsById.get(documentId)) + .filter( + (document): document is Document => !!document, + ); const rowBg = isSelected ? APP_SURFACE_ACTIVE_CLASS : APP_SURFACE_HOVER_CLASS; @@ -372,34 +389,26 @@ export const TRTable = forwardRef(function TRTable( : TR_STICKY_CELL_BG; return (
-
toggleRow(row.id)} className={`sticky left-0 z-[60] ${DOC_COL_W} border-b border-r border-gray-200 py-2 pl-4 pr-2 text-xs text-gray-800 flex items-center transition-colors ${stickyRowBg} ${isSelected ? "" : APP_SURFACE_GROUP_HOVER_CLASS}`} - > - toggleDoc(doc.id)} - className={TABLE_CHECKBOX_CLASS} - /> - - {doc.filename} - -
+ /> {columns.map((col) => { - const cell = getCell(doc.id, col.index); + const cell = getCell(row, col.index); const colPos = sortedColumns.findIndex( (c) => c.index === col.index, ); const isHighlighted = highlightedCell?.colIdx === colPos && - highlightedCell?.rowIdx === docIdx; + highlightedCell?.rowIdx === rowIdx; return (
(function TRTable( citationRef, sheet, citationCell, + documentId, ) => onCitationClick( cell, @@ -425,6 +435,7 @@ export const TRTable = forwardRef(function TRTable( citationRef, sheet, citationCell, + documentId, ) } /> diff --git a/frontend/src/app/components/tabular/TabularCell.test.tsx b/frontend/src/app/components/tabular/TabularCell.test.tsx new file mode 100644 index 000000000..95e5794f3 --- /dev/null +++ b/frontend/src/app/components/tabular/TabularCell.test.tsx @@ -0,0 +1,39 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { TabularCell as TabularCellData } from "../shared/types"; +import { TabularCell } from "./TabularCell"; + +describe("TabularCell", () => { + it("passes the cited source document through the click handler", () => { + const onCitationClick = vi.fn(); + const cell = { + id: "cell-1", + status: "done", + content: { + summary: + "Answer [[document:doc-2||page:4||quote:Exact language]]", + flag: "grey", + reasoning: "", + }, + } as TabularCellData; + + render( + , + ); + + fireEvent.click(screen.getByTitle('Page 4: "Exact language"')); + + expect(onCitationClick).toHaveBeenCalledWith( + 4, + "Exact language", + 1, + undefined, + undefined, + "doc-2", + ); + }); +}); diff --git a/frontend/src/app/components/tabular/TabularCell.tsx b/frontend/src/app/components/tabular/TabularCell.tsx index 41fbdb01c..aedfa7704 100644 --- a/frontend/src/app/components/tabular/TabularCell.tsx +++ b/frontend/src/app/components/tabular/TabularCell.tsx @@ -8,6 +8,7 @@ import type { ColumnConfig, TabularCell as TCell } from "../shared/types"; import { preprocessCitations, type ParsedCitation } from "./citation-utils"; import { getPillClass } from "./pillUtils"; import { SkeletonLine } from "../shared/TablePrimitive"; +import { TRExpandedCellSurface } from "./TRExpandedCellSurface"; interface Props { cell: TCell; @@ -20,6 +21,7 @@ interface Props { citationRef: number, sheet?: string, cell?: string, + documentId?: string, ) => void; } @@ -75,6 +77,7 @@ function CellMarkdown({ citationRef: number, sheet?: string, cell?: string, + documentId?: string, ) => void; onExpand: () => void; inline?: boolean; @@ -130,6 +133,7 @@ function CellMarkdown({ idx + 1, citation.sheet, citation.cell, + citation.documentId, ); } else { onExpand(); @@ -238,9 +242,17 @@ export function TabularCell({ citationRef: number, sheet?: string, citationCell?: string, + documentId?: string, ) { setInlineExpanded(false); - onCitationClick?.(page, quote, citationRef, sheet, citationCell); + onCitationClick?.( + page, + quote, + citationRef, + sheet, + citationCell, + documentId, + ); } function handleSeeDetails() { @@ -276,7 +288,7 @@ export function TabularCell({ {/* Inline expanded overlay — absolutely positioned so it overlays without disrupting table layout */} {inlineExpanded && ( -
+
{cell.content.flag && (
-
+ )}
); diff --git a/frontend/src/app/components/tabular/TabularReviewDetailsModal.tsx b/frontend/src/app/components/tabular/TabularReviewDetailsModal.tsx index b7c7377e1..7a514fa5b 100644 --- a/frontend/src/app/components/tabular/TabularReviewDetailsModal.tsx +++ b/frontend/src/app/components/tabular/TabularReviewDetailsModal.tsx @@ -5,6 +5,7 @@ import { Modal } from "../modals/Modal"; import { ModalFieldLabel } from "../modals/ModalFieldLabel"; import { ModalSelect } from "../modals/ModalSelect"; import { ModalTextInput } from "../modals/ModalTextInput"; +import { ToggleSwitch } from "@/app/components/ui/toggle-switch"; import type { Project, TabularReview } from "../shared/types"; interface TabularReviewDetailsModalProps { @@ -152,35 +153,18 @@ export function TabularReviewDetailsModal({ {!lockProject && (
Project - + Move under a project + {underProject && ( (null); const [cells, setCells] = useState([]); const [documents, setDocuments] = useState([]); + const [rows, setRows] = useState([]); const [columns, setColumns] = useState([]); const [loading, setLoading] = useState(true); const [generating, setGenerating] = useState(false); @@ -102,10 +104,11 @@ export function TRView({ reviewId, projectId }: Props) { page?: number; sheet?: string; cell?: string; + documentId?: string; citationRef: number; } | undefined >(undefined); - const [selectedDocIds, setSelectedDocIds] = useState([]); + const [selectedRowIds, setSelectedRowIds] = useState([]); const [actionsOpen, setActionsOpen] = useState(false); const [search, setSearch] = useState(""); const [dragOverReviewFiles, setDragOverReviewFiles] = useState(false); @@ -161,9 +164,10 @@ export function TRView({ reviewId, projectId }: Props) { useEffect(() => { const fetches: Promise[] = [ - getTabularReview(reviewId).then(({ review, cells, documents }) => { + getTabularReview(reviewId).then(({ review, cells, rows, documents }) => { setReview(review); setCells(cells); + setRows(rows); setDocuments(documents); setColumns(review.columns_config || []); }), @@ -195,7 +199,6 @@ export function TRView({ reviewId, projectId }: Props) { try { const updated = await updateTabularReview(reviewId, { columns_config: nextColumns, - document_ids: documents.map((document) => document.id), }); setReview(updated); setColumns(updated.columns_config || nextColumns); @@ -218,23 +221,11 @@ export function TRView({ reviewId, projectId }: Props) { document_ids: allIds, columns_config: columns, }); - setDocuments((prev) => [...prev, ...toAdd]); - if (columns.length > 0) { - setCells((prev) => [ - ...prev, - ...toAdd.flatMap((doc) => - columns.map((col) => ({ - id: `new-${doc.id}-${col.index}`, - review_id: reviewId, - document_id: doc.id, - column_index: col.index, - content: null, - status: "pending" as const, - created_at: new Date().toISOString(), - })), - ), - ]); - } + const detail = await getTabularReview(reviewId); + setReview(detail.review); + setDocuments(detail.documents); + setRows(detail.rows); + setCells(detail.cells); } function hasFilePayload(dt: DataTransfer): boolean { @@ -264,7 +255,7 @@ export function TRView({ reviewId, projectId }: Props) { } } - async function handleRegenerateCell(docId: string, colIndex: number) { + async function handleRegenerateCell(rowId: string, colIndex: number) { if (apiKeys && !isModelAvailable(tabularModel, apiKeys)) { setApiKeyModalProvider(getModelProvider(tabularModel)); return; @@ -272,7 +263,7 @@ export function TRView({ reviewId, projectId }: Props) { setCells((prev) => prev.map((c) => - c.document_id === docId && c.column_index === colIndex + c.row_id === rowId && c.column_index === colIndex ? { ...c, status: "generating" as const, content: null } : c, ), @@ -285,12 +276,12 @@ export function TRView({ reviewId, projectId }: Props) { try { const result = await regenerateTabularCell( reviewId, - docId, + rowId, colIndex, ); setCells((prev) => prev.map((c) => - c.document_id === docId && c.column_index === colIndex + c.row_id === rowId && c.column_index === colIndex ? { ...c, status: "done" as const, content: result } : c, ), @@ -304,7 +295,7 @@ export function TRView({ reviewId, projectId }: Props) { console.error("Regeneration failed", err); setCells((prev) => prev.map((c) => - c.document_id === docId && c.column_index === colIndex + c.row_id === rowId && c.column_index === colIndex ? { ...c, status: "error" as const } : c, ), @@ -348,11 +339,11 @@ export function TRView({ reviewId, projectId }: Props) { // Optimistically set empty/pending/error cells to generating (skip done cells) setCells((prev) => - documents.flatMap((doc) => + rows.flatMap((row) => columns.map((col) => { const existing = prev.find( (c) => - c.document_id === doc.id && + c.row_id === row.id && c.column_index === col.index, ); if (existing?.status === "done" && existing?.content) { @@ -365,9 +356,10 @@ export function TRView({ reviewId, projectId }: Props) { content: null, } : { - id: `${doc.id}-${col.index}`, + id: `${row.id}-${col.index}`, review_id: reviewId, - document_id: doc.id, + row_id: row.id, + document_id: row.document_id, column_index: col.index, content: null, status: "generating" as const, @@ -397,7 +389,7 @@ export function TRView({ reviewId, projectId }: Props) { if (data.type === "cell_update") { setCells((prev) => prev.map((c) => - c.document_id === data.document_id && + c.row_id === data.row_id && c.column_index === data.column_index ? { ...c, @@ -429,31 +421,32 @@ export function TRView({ reviewId, projectId }: Props) { setColumns(newCols); setCells((prev) => [ ...prev, - ...documents - .filter((doc) => + ...rows + .filter((row) => normalizedColumns.some( (column) => !prev.some( (cell) => - cell.document_id === doc.id && + cell.row_id === row.id && cell.column_index === column.index, ), ), ) - .flatMap((doc) => + .flatMap((row) => normalizedColumns .filter( (column) => !prev.some( (cell) => - cell.document_id === doc.id && + cell.row_id === row.id && cell.column_index === column.index, ), ) .map((column) => ({ - id: `new-${doc.id}-${column.index}`, + id: `new-${row.id}-${column.index}`, review_id: reviewId, - document_id: doc.id, + row_id: row.id, + document_id: row.document_id, column_index: column.index, content: null, status: "pending" as const, @@ -517,52 +510,69 @@ export function TRView({ reviewId, projectId }: Props) { } async function handleDeleteDocuments() { - const idsToDelete = [...selectedDocIds]; - if (idsToDelete.length === 0) return; + const rowIdsToDelete = [...selectedRowIds]; + if (rowIdsToDelete.length === 0) return; + const documentIdsToDelete = new Set( + rows + .filter((row) => rowIdsToDelete.includes(row.id)) + .flatMap((row) => row.source_document_ids), + ); const previousDocuments = documents; + const previousRows = rows; const previousCells = cells; - const remaining = documents.filter((d) => !idsToDelete.includes(d.id)); + const remaining = documents.filter( + (document) => !documentIdsToDelete.has(document.id), + ); setDocuments(remaining); - setCells((prev) => - prev.filter((c) => !idsToDelete.includes(c.document_id)), + setRows((current) => + current.filter((row) => !rowIdsToDelete.includes(row.id)), ); - setSelectedDocIds([]); + setCells((current) => + current.filter( + (cell) => !rowIdsToDelete.includes(cell.row_id), + ), + ); + setSelectedRowIds([]); setActionsOpen(false); try { await updateTabularReview(reviewId, { document_ids: remaining.map((d) => d.id), columns_config: columns, }); + const detail = await getTabularReview(reviewId); + setReview(detail.review); + setDocuments(detail.documents); + setRows(detail.rows); + setCells(detail.cells); } catch (err) { setDocuments(previousDocuments); + setRows(previousRows); setCells(previousCells); - setSelectedDocIds(idsToDelete); + setSelectedRowIds(rowIdsToDelete); console.error("Failed to delete tabular review documents", err); } } - async function clearResultsForDocuments(docIds: string[]) { - if (docIds.length === 0) return; + async function clearResultsForRows(rowIds: string[]) { + if (rowIds.length === 0) return; setCells((prev) => prev.map((c) => - docIds.includes(c.document_id) + rowIds.includes(c.row_id) ? { ...c, content: null, status: "pending" } : c, ), ); - setSelectedDocIds([]); + setSelectedRowIds([]); setActionsOpen(false); - await clearTabularCells(reviewId, docIds); + await clearTabularCells(reviewId, rowIds); } async function handleClearResults() { - await clearResultsForDocuments([...selectedDocIds]); + await clearResultsForRows([...selectedRowIds]); } async function handleClearAllResults() { - await clearResultsForDocuments( - documents.map((document) => document.id), - ); + await clearResultsForRows(rows.map((row) => row.id)); } function requestReviewDetails() { @@ -650,16 +660,20 @@ export function TRView({ reviewId, projectId }: Props) { setCells([]); try { await saveColumnsConfig(nextColumns); - if (documents.length > 0) { + if (rows.length > 0) { try { await clearTabularCells( reviewId, - documents.map((document) => document.id), + rows.map((row) => row.id), ); } catch (err) { console.error("Failed to clear old tabular cells", err); } } + const detail = await getTabularReview(reviewId); + setReview(detail.review); + setRows(detail.rows); + setCells(detail.cells); setWorkflowModalOpen(false); } catch (err) { setColumns(previousColumns); @@ -671,9 +685,9 @@ export function TRView({ reviewId, projectId }: Props) { } const q = search.toLowerCase(); - const filteredDocuments = q - ? documents.filter((d) => d.filename.toLowerCase().includes(q)) - : documents; + const filteredRows = q + ? rows.filter((row) => row.label.toLowerCase().includes(q)) + : rows; return (
@@ -730,7 +744,7 @@ export function TRView({ reviewId, projectId }: Props) { type: "search", value: search, onChange: setSearch, - placeholder: "Search documents…", + placeholder: "Search rows…", }, !projectId ? { @@ -765,19 +779,18 @@ export function TRView({ reviewId, projectId }: Props) { review?.title || "Tabular Review", columns, - documents, + rows, cells, }), disabled: columns.length === 0 || - documents.length === 0, + rows.length === 0, }, { label: "Clear results", icon: X, onSelect: handleClearAllResults, - disabled: - documents.length === 0, + disabled: rows.length === 0, }, { label: "Delete", @@ -812,7 +825,7 @@ export function TRView({ reviewId, projectId }: Props) { disabled: generating || columns.length === 0 || - documents.length === 0 || + rows.length === 0 || savingColumnsConfig, icon: generating ? ( @@ -838,7 +851,7 @@ export function TRView({ reviewId, projectId }: Props) { disabled: loading || columns.length === 0 || - documents.length === 0, + rows.length === 0, title: chatOpen ? "Close chat" : "Open chat", @@ -875,7 +888,7 @@ export function TRView({ reviewId, projectId }: Props) { {loading ? (
) : null} - {!loading && selectedDocIds.length > 0 && ( + {!loading && selectedRowIds.length > 0 && ( <> {/* Desktop: compact Actions menu */}
{ setExpandedCell(cell); setExpandedCellCitation(undefined); @@ -994,6 +1011,7 @@ export function TRView({ reviewId, projectId }: Props) { citationRef, sheet, citationCell, + documentId, ) => { setExpandedCell(cell); setExpandedCellCitation({ @@ -1001,6 +1019,7 @@ export function TRView({ reviewId, projectId }: Props) { page, sheet, cell: citationCell, + documentId, citationRef, }); }} @@ -1016,8 +1035,6 @@ export function TRView({ reviewId, projectId }: Props) { reviewId={reviewId} reviewTitle={review?.title ?? null} projectName={project?.name ?? null} - columns={columns} - documents={documents} onCitationClick={handleTabularCitationClick} onClose={() => { setSelectedChatId(null); @@ -1033,29 +1050,42 @@ export function TRView({ reviewId, projectId }: Props) { {/* Cell detail side panel */} {expandedCell && (() => { + const expandedRow = rows.find( + (row) => row.id === expandedCell.row_id, + ); + const citedDocumentId = + expandedCellCitation?.documentId && + expandedRow?.source_document_ids.includes( + expandedCellCitation.documentId, + ) + ? expandedCellCitation.documentId + : undefined; const expandedDoc = documents.find( - (d) => d.id === expandedCell.document_id, + (document) => + document.id === + (citedDocumentId ?? expandedRow?.document_id), ); const expandedCol = columns.find( (c) => c.index === expandedCell.column_index, ); - if (!expandedDoc || !expandedCol) return null; + if (!expandedRow || !expandedCol) return null; return ( { setExpandedCell(null); setExpandedCellCitation(undefined); }} - onNavigate={(documentId, columnIndex) => { + onNavigate={(rowId, columnIndex) => { const nextCell = cells.find( (candidate) => - candidate.document_id === - documentId && + candidate.row_id === rowId && candidate.column_index === columnIndex, ); if (nextCell) { @@ -1065,15 +1095,21 @@ export function TRView({ reviewId, projectId }: Props) { }} onRegenerate={() => handleRegenerateCell( - expandedCell.document_id, + expandedRow.id, expandedCell.column_index, ) } - displayDocument={expandedCellCitation !== undefined} + displayDocument={ + !!expandedDoc && + expandedCellCitation !== undefined + } citationQuote={expandedCellCitation?.quote} citationPage={expandedCellCitation?.page} citationSheet={expandedCellCitation?.sheet} citationCell={expandedCellCitation?.cell} + citationDocumentId={ + expandedCellCitation?.documentId + } citationRef={expandedCellCitation?.citationRef} /> ); diff --git a/frontend/src/app/components/tabular/citation-utils.test.ts b/frontend/src/app/components/tabular/citation-utils.test.ts new file mode 100644 index 000000000..d0d732c72 --- /dev/null +++ b/frontend/src/app/components/tabular/citation-utils.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { preprocessCitations } from "./citation-utils"; + +describe("preprocessCitations", () => { + it("parses a source-aware page citation", () => { + const result = preprocessCitations( + "Value [[document:doc-2||page:4||quote:Exact language]]", + ); + + expect(result.processed).toBe("Value §0§"); + expect(result.citations).toEqual([ + { + documentId: "doc-2", + page: 4, + quote: "Exact language", + }, + ]); + }); + + it("parses a source-aware spreadsheet citation", () => { + const result = preprocessCitations( + "Value [[document:doc-sheet||sheet:Summary||cell:B7||quote:42]]", + ); + + expect(result.citations).toEqual([ + { + documentId: "doc-sheet", + sheet: "Summary", + cell: "B7", + quote: "42", + }, + ]); + }); + + it("keeps legacy page citations compatible", () => { + const result = preprocessCitations( + "Value [[page:2||quote:Legacy language]]", + ); + + expect(result.citations).toEqual([ + { + documentId: undefined, + page: 2, + quote: "Legacy language", + }, + ]); + }); +}); diff --git a/frontend/src/app/components/tabular/citation-utils.ts b/frontend/src/app/components/tabular/citation-utils.ts index e114fb4ec..aba1cd03d 100644 --- a/frontend/src/app/components/tabular/citation-utils.ts +++ b/frontend/src/app/components/tabular/citation-utils.ts @@ -3,6 +3,7 @@ const INLINE_METADATA_RE = /\[\[((?:[^\[\]]|\[[^\]]*\])+)\]\]/g; export interface ParsedCitation { + documentId?: string; page?: number; sheet?: string; cell?: string; @@ -36,17 +37,18 @@ export function preprocessCitations(text: string): { } function parsePageCitation(metadata: string): ParsedCitation | null { - const match = metadata.match(/^page:(\d+)\|\|(?:quote:)?([\s\S]+)$/i); + const match = metadata.match( + /^(?:document:([^|]+)\|\|)?page:(\d+)\|\|(?:quote:)?([\s\S]+)$/i, + ); if (!match) return null; return { - page: parseInt(match[1], 10), - quote: match[2].trim(), + documentId: match[1]?.trim() || undefined, + page: parseInt(match[2], 10), + quote: match[3].trim(), }; } function parseSpreadsheetCitation(metadata: string): ParsedCitation | null { - if (!metadata.toLowerCase().startsWith("sheet:")) return null; - const quoteSeparator = metadata.search(/\|\|quote:/i); if (quoteSeparator < 0) return null; @@ -72,5 +74,10 @@ function parseSpreadsheetCitation(metadata: string): ParsedCitation | null { const cell = fields.get("cell") ?? (column && row ? `${column}${row}` : undefined); if (!sheet || !cell) return null; - return { sheet, cell, quote }; + return { + documentId: fields.get("document"), + sheet, + cell, + quote, + }; } diff --git a/frontend/src/app/components/tabular/exportToExcel.ts b/frontend/src/app/components/tabular/exportToExcel.ts index 6ba577ee3..11d69afa7 100644 --- a/frontend/src/app/components/tabular/exportToExcel.ts +++ b/frontend/src/app/components/tabular/exportToExcel.ts @@ -3,8 +3,8 @@ import ExcelJS from "exceljs"; import type { ColumnConfig, - Document, TabularCell, + TabularReviewRow, } from "../shared/types"; import { preprocessCitations } from "./citation-utils"; @@ -35,20 +35,22 @@ function sanitizeFilename(name: string): string { export async function exportTabularReviewToExcel(params: { reviewTitle: string; columns: ColumnConfig[]; - documents: Document[]; + rows: TabularReviewRow[]; cells: TabularCell[]; }) { - const { reviewTitle, columns, documents, cells } = params; + const { reviewTitle, columns, rows, cells } = params; const sortedCols = [...columns].sort((a, b) => a.index - b.index); const cellMap = new Map(); - for (const c of cells) cellMap.set(`${c.document_id}:${c.column_index}`, c); + for (const cell of cells) { + cellMap.set(`${cell.row_id}:${cell.column_index}`, cell); + } const wb = new ExcelJS.Workbook(); const ws = wb.addWorksheet("Review"); ws.columns = [ - { header: "Document", width: 40 }, + { header: "Folder / document", width: 40 }, ...sortedCols.map((c) => ({ header: c.name, width: 40 })), ]; @@ -61,10 +63,14 @@ export async function exportTabularReviewToExcel(params: { fgColor: { argb: "FFF3F4F6" }, }; - for (const doc of documents) { - const row: string[] = [doc.filename]; + for (const reviewRow of rows) { + const row: string[] = [reviewRow.label]; for (const col of sortedCols) { - row.push(formatCellForExport(cellMap.get(`${doc.id}:${col.index}`))); + row.push( + formatCellForExport( + cellMap.get(`${reviewRow.id}:${col.index}`), + ), + ); } const excelRow = ws.addRow(row); excelRow.alignment = { vertical: "top", wrapText: true }; diff --git a/frontend/src/app/components/ui/toggle-switch.test.tsx b/frontend/src/app/components/ui/toggle-switch.test.tsx new file mode 100644 index 000000000..8b95aab9c --- /dev/null +++ b/frontend/src/app/components/ui/toggle-switch.test.tsx @@ -0,0 +1,31 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { ToggleSwitch } from "./toggle-switch"; + +describe("ToggleSwitch", () => { + it("renders the checked styling and emits the next value", () => { + const onCheckedChange = vi.fn(); + const { container } = render( + + Group documents + , + ); + + const toggle = screen.getByRole("switch", { + name: "Group documents", + }); + const track = container.querySelector( + '[data-slot="toggle-switch-track"]', + ); + const thumb = container.querySelector( + '[data-slot="toggle-switch-thumb"]', + ); + + expect(toggle).toHaveAttribute("aria-checked", "true"); + expect(track).toHaveClass("bg-blue-600", "h-5", "w-9"); + expect(thumb).toHaveClass("h-3", "w-3", "left-1", "top-1"); + + fireEvent.click(toggle); + expect(onCheckedChange).toHaveBeenCalledWith(false); + }); +}); diff --git a/frontend/src/app/components/ui/toggle-switch.tsx b/frontend/src/app/components/ui/toggle-switch.tsx new file mode 100644 index 000000000..b04e4c218 --- /dev/null +++ b/frontend/src/app/components/ui/toggle-switch.tsx @@ -0,0 +1,54 @@ +"use client"; + +import * as React from "react"; +import { cn } from "@/app/lib/utils"; + +type ToggleSwitchProps = Omit< + React.ComponentProps<"button">, + "role" | "aria-checked" | "onClick" +> & { + checked: boolean; + onCheckedChange: (checked: boolean) => void; +}; + +export function ToggleSwitch({ + checked, + onCheckedChange, + type = "button", + className, + children, + ...props +}: ToggleSwitchProps) { + return ( + + ); +} diff --git a/frontend/src/app/components/workflows/UseWorkflowModal.tsx b/frontend/src/app/components/workflows/UseWorkflowModal.tsx index a570b92f7..3475264dd 100644 --- a/frontend/src/app/components/workflows/UseWorkflowModal.tsx +++ b/frontend/src/app/components/workflows/UseWorkflowModal.tsx @@ -343,6 +343,9 @@ export function UseWorkflowModal({ workflows, workflow, onClose, skipSelect = fa
{ return apiRequest("/tabular-review", { method: "POST", @@ -1121,6 +1122,7 @@ export async function updateTabularReview( columns_config?: { index: number; name: string; prompt: string }[]; document_ids?: string[]; project_id?: string | null; + document_grouping?: "document" | "folder"; shared_with?: string[]; }, ): Promise { @@ -1306,7 +1308,7 @@ export async function renameTabularChat( export async function regenerateTabularCell( reviewId: string, - documentId: string, + rowId: string, columnIndex: number, ): Promise<{ summary: string; @@ -1317,7 +1319,7 @@ export async function regenerateTabularCell( method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - document_id: documentId, + row_id: rowId, column_index: columnIndex, }), }); @@ -1325,12 +1327,12 @@ export async function regenerateTabularCell( export async function clearTabularCells( reviewId: string, - documentIds: string[], + rowIds: string[], ): Promise { await apiRequest(`/tabular-review/${reviewId}/clear-cells`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ document_ids: documentIds }), + body: JSON.stringify({ row_ids: rowIds }), }); }