Skip to content

Deep research: frontend (report view, corpus tab, notifications) + chat status tool#1836

Merged
JSv4 merged 27 commits into
mainfrom
claude/inspiring-knuth-bhtzs
May 31, 2026
Merged

Deep research: frontend (report view, corpus tab, notifications) + chat status tool#1836
JSv4 merged 27 commits into
mainfrom
claude/inspiring-knuth-bhtzs

Conversation

@JSv4

@JSv4 JSv4 commented May 30, 2026

Copy link
Copy Markdown
Collaborator

Context

Deep research shipped in #1814 as a backend-only feature. The flow exists end-to-end on the server — a user asks the corpus chat agent to "research X", a long-running Celery job produces a cited markdown ResearchReport, and on completion the backend fires a notification and drops a chat message linking to /research/{slug}. But the frontend was explicitly deferred to v2, so today:

  • The /research/{slug} route doesn't exist → the completion chat link 404s.
  • RESEARCH_REPORT_* notifications are broadcast but not in the frontend NotificationType enum, so completion never surfaces.
  • There's no report view or list anywhere in the UI.
  • The chat agent could start a job but had no tool to check on one — "is my research done?" was a dead end.

This PR adds the full frontend surface plus a backend status tool so a user can trigger (conversationally), monitor, and read reports.

What's in here

Backend (small, additive)

  • researchReportBySlug(slug) query resolver (config/graphql/research_queries.py) — creator-only via BaseService.filter_visible, IDOR-safe null for non-owners/unknown slugs — so the frontend can resolve the existing /research/{slug} link.
  • check_deep_research_status read-only chat tool (opencontractserver/llms/tools/research_tools.py) backed by a new ResearchReportService.list_recent_for_corpus helper, registered on the corpus chat agent next to start_deep_research. Both are filtered out of the read-only research agent via restrict_tool_names, so a follow-up like "is my research done?" returns status/progress + the report link.

Frontend

  • GraphQL + cache: research queries/mutations, JobStatus/ResearchReportType types, openedResearchReport reactive var, and relayStylePagination(["corpusId","status"]) + researchReportBySlug cache policies (keyArgs use the GraphQL field-arg names per CLAUDE.md Bump django-coverage-plugin from 2.0.2 to 2.0.3 #15).
  • Routing: standalone /research/:slug resolved by CentralRouteManager Phase 1 (slug-based) with a Phase-3 canonical-redirect guard like threads/labelsets; openedResearchReport added to the routing write-discipline allowlist.
  • Views: ResearchReportDetail (markdown body with footnote citations via SafeMarkdown; Report / Citations / Sources / Run-details tabs; live polling + Cancel while running; citations deep-link to the cited annotation) and a corpus "Research" tab listing reports. A secondary StartResearchModal provides an explicit kickoff; the primary trigger remains the chat agent.
  • Notifications: RESEARCH_REPORT_* wired into the notification type union + job-toast system (clickable toast deep-links to /research/:slug), terminal Apollo-cache updates, and a completion hook the detail view uses to refetch + stop polling.

Tests: backend slug-resolver + status-tool/service tests (opencontractserver/tests/research/); frontend unit tests for route parsing + research utils; a Playwright component test for the detail view.

Design notes / decisions

  • No per-step progress events server-side (v1), so the detail view polls the (indexed, creator-only) single-report query while non-terminal and stops on the terminal WebSocket notification.
  • Creator-only (v1) — there is no isPublic/sharing surface, so no sharing UI is built; Cancel is gated on myPermissions.
  • The citations JSON keeps its raw snake_case keys / integer PKs (GenericScalar passthrough); the detail view maps PKs → source documents for deep-links.

Verification

  • tsc --noEmit clean (0 errors); Prettier clean.
  • ✅ Frontend unit tests pass (route parsing, research utils) and the routing write-discipline test passes with the new reactive var.
  • ⚠️ Not runnable in the CI sandbox used to author this (no Docker / no Python env / no Playwright browsers): the backend test suite should be run via docker compose -f test.yml run django python manage.py test opencontractserver.tests.research --keepdb, and the Playwright component test via cd frontend && yarn test:ct --reporter=list -g "ResearchReportDetail".

Manual smoke test

  1. Ask the corpus chat agent to research something → a row appears in the corpus Research tab; ask "is my research done?" → the agent answers via the new status tool.
  2. Open /research/:slug while running → slug resolves, progress updates via polling, Cancel transitions to CANCELLED.
  3. On completion → RESEARCH_REPORT_COMPLETE toast appears, is clickable → lands on /research/:slug; the in-chat [Open report] link works.
  4. Completed report → markdown body + footnotes/Sources render; citation rows deep-link to the cited annotation (?ann=); Sources documents open.
  5. A non-creator hitting /research/:slug gets not-found (no leak); no sharing controls exist.

Out of scope (v2)

Sharing / is_public, document-subset scoping, a backend per-step PROGRESS emitter (would let us drop polling), watchdog cleanup task.


Generated by Claude Code

claude added 3 commits May 30, 2026 03:05
Backend groundwork for the deep-research frontend (the v1 feature shipped
backend-only; the completion chat message links to /research/{slug} but
nothing resolved that route, and the chat agent could start a job but not
report on one).

- research_queries.py: add researchReportBySlug(slug) resolver (creator-only
  via BaseService.filter_visible; IDOR-safe null for non-owners/unknown slugs)
  so the frontend can resolve the /research/{slug} route.
- research_tools.py: add check_deep_research_status chat tool (read-only) so a
  follow-up like "is my research done?" returns status/progress + the report
  link instead of being a dead end.
- research_reports.py: add ResearchReportService.list_recent_for_corpus read
  helper (creator-only, bounded) backing the tool.
- pydantic_ai_agents.py: register the status tool alongside the kickoff tool on
  the corpus chat agent; both are filtered out of the read-only research agent
  via restrict_tool_names.
- tests: slug-resolver GraphQL tests + status-tool/service tests.
Completes the backend-only v1 (#1814) so users can monitor and read
deep-research reports. The completion chat message linked to /research/{slug}
but no route resolved it; RESEARCH_REPORT_* notifications weren't surfaced;
and there was no report view or list.

Frontend:
- GraphQL + cache: research queries/mutations, JobStatus/ResearchReportType
  types, openedResearchReport reactive var, relayStylePagination keyed by the
  GraphQL field-arg names (corpusId/status) + researchReportBySlug keyArgs.
- Routing: standalone /research/:slug resolved by CentralRouteManager Phase 1
  (slug-based) with a Phase-3 canonical-redirect guard; openedResearchReport
  added to the routing write-discipline allowlist.
- Views: ResearchReportDetail (SafeMarkdown body with footnote citations;
  Report/Citations/Sources/Run-details tabs; live polling + Cancel while
  running; citations deep-link to the cited annotation) and a corpus
  'Research' tab listing reports. Secondary StartResearchModal for an explicit
  kickoff (primary trigger stays the chat agent).
- Notifications: RESEARCH_REPORT_* wired into the notification type union +
  job-toast system (clickable, deep-links to /research/:slug), terminal cache
  updates, and a completion hook the detail view uses to refetch + stop
  polling.

Tests: frontend unit tests (route parsing + research utils) and a Playwright
component test for the detail view. Typescript compiles; unit + routing
discipline tests pass.
@claude

claude Bot commented May 30, 2026

Copy link
Copy Markdown

Code Review: Deep research frontend + chat status tool

Overall this is a solid, well-structured PR that follows the established patterns in the codebase. The backend additions are clean and secure, the routing integration mirrors existing entities correctly, and the test coverage is meaningful. I have a few bugs and style issues to flag.


Bugs

1. toast.error called at render time (frontend/src/components/research/CorpusResearchReportCards.tsx, after the useQuery call)

if (error) {
  toast.error("ERROR\nCould not fetch research reports for this corpus.");
}

This fires on every render while error is truthy — i.e., once per React reconciliation pass, which can easily produce dozens of duplicate toasts. It needs to move into a useEffect:

useEffect(() => {
  if (error) {
    toast.error("Could not fetch research reports for this corpus.");
  }
}, [error]);

2. ?tab=research back-navigation may silently no-op (frontend/src/views/ResearchReportDetail.tsx, handleBack)

navigate(`${getCorpusUrl(corpus)}?tab=research`);

The Corpuses view drives active tab via a setActiveTab state setter, not by reading ?tab from the URL. Unless there's URL→state sync for the tab param elsewhere (not visible in this diff), navigating back will land on the corpus home tab, not the Research tab. Either wire up the tab param in Corpuses.tsx or fall back to a simpler navigate(-1).


Code Quality

3. Hardcoded hex colors (frontend/src/views/ResearchReportDetail.tsx)

CLAUDE.md rule #4 (no magic numbers) applies to color tokens too. Three raw hex values appear that are not in OS_LEGAL_COLORS:

  • background: #ffffff — in RunningState, Row, RowLink
  • background: #fff7ed and color: #9a3412 — in WarningChip

These should either use existing OS_LEGAL_COLORS tokens or be added there with a semantic name (e.g., warningBg, warningText).

4. Array index as React key (frontend/src/views/ResearchReportDetail.tsx, citations map)

{citations.map((c, i) => {
  ...
  return href ? <RowLink key={i} ...> : <Row key={i} ...>;
})}

key={i} is fragile if citations can be sorted or filtered. c.footnote is available and should be unique per report; key={c.footnote} is a stable identity.

5. console.debug in hook (frontend/src/hooks/useResearchCompletionNotification.ts, line ~52)

console.debug(
  `[useResearchCompletionNotification] Report ${numericId} reached terminal state`
);

Raw console.* calls are not used elsewhere in this codebase — the pattern is to pass a logger or drop the statement entirely. Remove or replace with the routingLogger/logger pattern used in CentralRouteManager and the backend.

6. numericId computed outside a memo (frontend/src/hooks/useResearchCompletionNotification.ts)

let numericId: number | null = null;
try {
  numericId = reportId ? getNumericIdFromGlobalId(reportId) : null;
} catch { /* ... */ }

This runs on every render. Wrap it in useMemo(() => { ... }, [reportId]) so the decode only re-runs when reportId changes.


Minor / Polish

7. Redundant refetch on auth_token change (frontend/src/components/research/CorpusResearchReportCards.tsx)

useEffect(() => {
  if (auth_token && opened_corpus?.id) {
    refetch();
  }
}, [auth_token, opened_corpus?.id, refetch]);

The query already uses fetchPolicy: "network-only" and notifyOnNetworkStatusChange: true. The variables useMemo also reconstructs when opened_corpus?.id changes, which alone triggers a fresh query. Watching auth_token here mirrors the pattern in CorpusExtracts and similar list components, so this is acceptable, but worth noting it may fire a duplicate network request on mount in some auth flows.

8. No cancelRequested visual feedback while pending (frontend/src/views/ResearchReportDetail.tsx)

The cancelRequested field is fetched and available on report, and handleCancel shows a toast, but there's no in-page indicator that cancellation is in flight (e.g., a "Cancellation pending…" note below the Cancel button). The current UX leaves the button re-clickable until the status actually flips to CANCELLED. Consider disabling or relabelling the button when report.cancelRequested === true.

9. Duplicate GraphQL selection (frontend/src/graphql/queries.ts)

GET_RESEARCH_REPORT and RESOLVE_RESEARCH_REPORT_BY_SLUG share an identical field selection. The code comment acknowledges this intentional duplication ("matching this file's convention — no gql fragments are used here"). That's fine, but if the selection grows in future it'll need updating in two places. Worth a brief // SYNC WITH GET_RESEARCH_REPORT comment on the slug variant.


Security / Correctness ✅

  • researchReportBySlug resolver correctly uses BaseService.filter_visible + @login_required — IDOR-safe.
  • list_recent_for_corpus enforces filter_visible (creator-only) and caps limit to 25.
  • check_deep_research_status_tool correctly sets requires_write_permission=False and is correctly filtered out of the read-only research agent via restrict_tool_names.
  • Cache keyArgs in cache.ts correctly use the GraphQL field-argument names (corpusId, status) — per CLAUDE.md Bump django-coverage-plugin from 2.0.2 to 2.0.3 #15.
  • openedResearchReport is properly added to the routing write-discipline allowlist.

Test Coverage ✅

Backend slug-resolver tests cover creator access, non-creator null, and unknown slug. Service tests cover ordering, creator-only scoping, corpus scoping, and limit clamping. Frontend tests cover completed, cancelled/failed states and the citations tab. The PROGRESS notification type is reserved in the enum but not handled in JOB_NOTIFICATION_TYPES — that's correct and consistent with the "v1 no per-step events" design note.


Summary: Two bugs to fix before merge (render-time toast, back-navigation tab param), plus hardcoded hex colors and the numericId memo. The rest are polish items.

Comment thread frontend/src/views/ResearchReportDetail.tsx Fixed
CodeQL flagged the destructured `loading` from the GET_RESEARCH_REPORT
useQuery as unused (the running/cancel states use the mutation's
`cancelLoading`, not the query's). No behavior change.
@claude

claude Bot commented May 30, 2026

Copy link
Copy Markdown

Code Review — PR #1836: Deep Research Frontend + Chat Status Tool

This is a well-scoped, cleanly structured PR that completes the backend-only deep-research feature from #1814. The architecture choices (slug-based routing, polling + WebSocket hybrid, creator-only access) are sound and consistent with how Extracts and Threads are implemented. A handful of issues worth addressing before merge:


Bugs

1. Toast spam on render in CorpusResearchReportCards.tsx (line 302)

The toast.error call sits directly in the render body. This fires on every render while error is truthy — every poll interval tick or reactive-var change produces another toast. Move the toast call inside a useEffect with error as its dependency, so it only fires when error transitions to a truthy value.

2. StartResearchModal state persists across open/close cycles

prompt and title are never reset when the modal closes. A user who types a prompt, dismisses, reopens, and submits will see and submit their previous inputs. The onClose handler should reset prompt and title to empty strings.


Design / Convention Issues

3. Hardcoded hex colors in JobNotificationToast.tsx (lines 144–163)

The RESEARCH_REPORT_* configs use #4CAF50, #F44336, #9CA3AF. New entries should mirror the RESEARCH_STATUS_COLORS constants defined in this same PR rather than re-inventing the color values.

4. Missing "Queued" filter tab in ResearchTabContent.tsx

FILTER_ITEMS covers: All / Running / Completed / Failed / Cancelled — but not Queued. FILTER_TO_STATUS in CorpusResearchReportCards.tsx already maps "queued" to the backend value, and a freshly triggered job sits in QUEUED briefly. The tab should include it or explicitly fold QUEUED into "Running" with a comment explaining why.

5. variableMatcher wildcard in ResearchReportDetailTestWrapper.tsx

CLAUDE.md calls out that GraphQL mocks must match variables exactly (null vs undefined matters). A wildcard matcher hides any future variable mismatch. The exact variables object is already present in the same mock — dropping variableMatcher costs nothing and makes the mock more useful as a regression guard.


Minor / Non-blocking

6. numericId computed inline in useResearchCompletionNotification.ts

The try/catch that decodes reportId runs on every render pass. Wrapping in useMemo keyed on reportId is the pattern used elsewhere in the codebase and makes the intent clearer.

7. Duplicate selection sets in GET_RESEARCH_REPORT / RESOLVE_RESEARCH_REPORT_BY_SLUG

The comment acknowledges this mirrors the extract convention (intentional). Just noting that both selections will need updating if the type grows new fields.


Security / Permissions — OK

  • researchReportBySlug uses BaseService.filter_visible + @login_required — IDOR-safe null for non-owners/unknown slugs. Correct.
  • Cancel button gated on myPermissions.includes("update_researchreport"). Correct.
  • list_recent_for_corpus clamps limit to 25. Appropriate.
  • No raw SQL or injection surface in new code.

Test Coverage — Good

  • Backend: creator-only slug resolver, IDOR (non-creator gets null), unknown slug, service ordering + cross-corpus isolation, limit clamping, async status tool. Good coverage of the permission boundary.
  • Frontend CT: completed + failed states, citations tab — covers the main rendering paths.
  • test_research_status_tool.py uses TransactionTestCase for the async boundary — correct rationale.

Summary

The two bugs (toast spam, stale modal state) are functional regressions that should be fixed before merge. The missing "Queued" filter and the wildcard test matcher are low-cost fixes worth doing here. The rest are polish notes.

Overall the implementation is solid — routing, caching, notification, and service-layer patterns all follow established conventions correctly.

claude added 2 commits May 30, 2026 04:34
The Playwright CT (esbuild dev) bundler failed to bind the named getCorpusUrl
import in ResearchReportDetail, throwing 'getCorpusUrl is not defined' at
render so the report never mounted (all 3 component tests failed). getDocumentUrl
from the same module binds fine. Inline the corpus back-navigation URL (same
/c/{creator}/{corpus} shape) and drop the getCorpusUrl import. The 3
ResearchReportDetail component tests now pass locally.
…iables

The real cause of the Component Tests failure: MockedProvider rejects a mock
that supplies both request.variables and variableMatcher ("should contain
either variableMatcher or request.variables"), so the provider constructor
threw and the component never mounted. Keep the explicit request.variables
({ id }) form and drop the redundant variableMatcher.
@claude

claude Bot commented May 30, 2026

Copy link
Copy Markdown

Code Review: Deep-research Frontend + Chat Status Tool

Overview

This PR completes the frontend surface for the backend-only deep-research feature shipped in #1814. Well-scoped and self-contained: slug-resolver GraphQL query, chat status tool, full routing, report detail view, corpus tab, notification wiring, and tests. The architectural decisions are sound — the routing follows the existing CentralRouteManager pattern, cache key-args are correct per CLAUDE.md, and the service/permission layering is respected throughout.


Bugs

1. toast.error called in the render body — fires on every re-render (CorpusResearchReportCards.tsx, around line 91)

The block if (error) { toast.error(...) } sits in the render path, not inside a useEffect. Apollo re-renders the component whenever networkStatus changes (and notifyOnNetworkStatusChange is true), so a persistent fetch error will spawn a new toast on every re-render. Fix by wrapping in useEffect with [error] dependency.


Code Quality

2. Inline corpus URL in ResearchReportDetail.handleBack — DRY violation (ResearchReportDetail.tsx, around line 488)

The comment explains this was done because getCorpusUrl() tripped the CT bundler. The right fix is to address the bundler import issue rather than inlining the URL shape here — a future corpus URL scheme change will silently skip this callsite.

3. Duplicate GraphQL field selections (queries.ts)

GET_RESEARCH_REPORT and RESOLVE_RESEARCH_REPORT_BY_SLUG share an identical ~45-field selection. The comment acknowledges this follows the extract pattern. A named fragment would make future field additions/removals less error-prone. Not blocking, but worth a follow-up.

4. queued status absent from the corpus Research tab filter (ResearchTabContent.tsx)

FILTER_ITEMS has running, completed, failed, cancelled but not queued. FILTER_TO_STATUS in CorpusResearchReportCards does map queued, so queued reports only surface under "All". May be intentional (queued is transient), but worth a comment confirming the decision.


Potential Issues

5. Missing init.py for the new test directory

opencontractserver/tests/research/ is new but the diff contains no init.py. Django's manage.py test discovery relies on the module hierarchy; without it these tests may be silently skipped. Confirm discovery works or add the missing init.py.

6. numericId decoded on every render in useResearchCompletionNotification

The try/catch that calls getNumericIdFromGlobalId(reportId) sits at the top level of the hook (not in a useMemo), so the value is a new primitive each render. The useCallback for handleNotificationCreated depends on numericId, meaning a stale closure is possible if reportId changes between assignment and capture. Using useMemo with [reportId] dependency removes the ambiguity.


Security

All good:

  • researchReportBySlug uses @login_required + BaseService.filter_visible and returns null for non-owners/unknown slugs — IDOR-safe.
  • list_recent_for_corpus is bounded (max 25) and enforces creator-only visibility.
  • check_deep_research_status_tool is requires_write_permission=False and correctly excluded from the read-only research agent via restrict_tool_names.
  • Cancel is gated on myPermissions.includes("update_researchreport").

Tests

Good coverage for an additive PR:

  • Backend: researchReportBySlug (creator fetch, non-creator null, unknown null) and acheck_deep_research_status (empty, running, completed, creator-only, unknown corpus).
  • Frontend: route parsing and getResearchReportUrl unit tests; Playwright component test for completed + citations + failed states.
  • Routing write-discipline test updated correctly.

Gaps for a follow-up:

  • No test for the polling lifecycle in ResearchReportDetail (start on queued/running, stop on terminal WebSocket flip).
  • No test for the RESEARCH_REPORT_* cache-update path in jobNotificationCacheUpdates.ts.
  • Playwright test does not cover the running/queued state (progress bar + Cancel button visibility).

Summary

Solid PR that follows existing patterns faithfully. Two items to address before merging: the render-path toast bug (#1) and verifying test discovery (#5). The DRY violation in handleBack (#2) should be fixed before it becomes a maintenance hazard. The rest are nits or follow-up candidates.

@claude

claude Bot commented May 30, 2026

Copy link
Copy Markdown

Code Review — Deep Research Frontend + Chat Status Tool

Overall this is a solid, well-scoped PR. The architecture closely mirrors existing patterns (extract routing, notification hooks, service layer), the CLAUDE.md conventions are largely followed, and the PR is appropriately additive with no dead code left behind. A few issues below, ordered by severity.


Bugs / Correctness

1. Side effect (toast) called directly in render body — CorpusResearchReportCards.tsx:107

if (error) {
  toast.error("ERROR\nCould not fetch research reports for this corpus.");
}

toast.error() is a side effect. Calling it unconditionally in the component body will fire every re-render while error is truthy, flooding the user with duplicate toasts. This needs a useEffect:

useEffect(() => {
  if (error) {
    toast.error("Could not fetch research reports for this corpus.");
  }
}, [error]);

2. Source / citation links use <a href> instead of React Router <Link>ResearchReportDetail.tsx:304, 754, 785

RowLink is a styled <a>, and getDocumentUrl() returns an internal path like /d/{userSlug}/{corpusSlug}/{docSlug}. A bare <a href="/d/..."> triggers a full browser navigation (page reload) instead of client-side routing. Either replace with a styled react-router-dom <Link>, or call navigate(href) from an onClick:

const RowLink = styled(Link)`...`;
// and use <RowLink to={href}>

Code Quality

3. handleBack hardcodes corpus URL pattern — ResearchReportDetail.tsx:459-461

// Corpus URL shape mirrors getCorpusUrl(); inlined here so this view
// doesn't import it (the named import tripped the CT bundler).
navigate(`/c/${corpus.creator.slug}/${corpus.slug}?tab=research`);

The CLAUDE.md split-import rule (pitfall #16) applies to *.ct.tsx test files, not to view source files. ResearchReportDetail.tsx is a normal view component; importing getCorpusUrl from navigationUtils.ts here should not trip the bundler. Using getCorpusUrl directly would be DRYer and safer against future route-shape changes:

import { getCorpusUrl } from "../utils/navigationUtils";
// ...
navigate(getCorpusUrl(corpus) + "?tab=research");

If the import truly does cause a CT issue when this view is mounted in a test, that's a separate bug to fix in the test wrapper rather than working around it in production code.

4. numericId decoded on every render — useResearchCompletionNotification.ts:48-52

let numericId: number | null = null;
try {
  numericId = reportId ? getNumericIdFromGlobalId(reportId) : null;
} catch { ... }

This is fine functionally (the decode is cheap) but inconsistent with the hook's use of useCallback([numericId]). Since numericId feeds useCallback's dep array it should itself be a useMemo:

const numericId = useMemo(() => {
  try {
    return reportId ? getNumericIdFromGlobalId(reportId) : null;
  } catch { return null; }
}, [reportId]);

5. console.debug left in production hook — useResearchCompletionNotification.ts:66-69

Debug logs in shipping hooks should be guarded or removed:

console.debug(
  `[useResearchCompletionNotification] Report ${numericId} reached terminal state`
);

Test Coverage Gaps

6. Missing __init__.py for Django test runner

opencontractserver/tests/research/ has an __init__.py (visible in the working tree), so this is already handled — good. Just confirming the test invocation in the PR description (python manage.py test opencontractserver.tests.research) will work.

7. test_completed_report_shows_durationduration_seconds is a @property, not a field

The test sets started_at / completed_at and asserts "finished in 2m 5s". The model's duration_seconds is a derived property (computed from those fields), so this is fine. Just noting it for clarity — no action needed.

8. No test for the toast.error path in CorpusResearchReportCards

Given bug #1 above, a regression test for the error state would be valuable once the toast call is moved into a useEffect.


Minor / Nits

9. fetchPolicy: "network-only" in CorpusResearchReportCards

network-only skips the cache on every mount. cache-and-network would render cached data immediately and then update, which is the standard pattern for list views in this codebase (see ExtractsTabContent). This is a UX latency issue, not a bug.

10. console.warn in getResearchReportUrl production utility

console.warn("Cannot generate research report URL without slug:", report);

The existing getDocumentUrl has the same pattern, so this is consistent with the codebase, but both are a little noisy at runtime. Fine to leave as-is given precedent.

11. Duplicate GraphQL field selections in GET_RESEARCH_REPORT vs. RESOLVE_RESEARCH_REPORT_BY_SLUG

The PR comment already acknowledges this follows the extract pattern. Worth a GQL fragment in a future cleanup pass, but not blocking.


What Looks Good

  • Permission enforcement is correct throughout: @login_required + BaseService.filter_visible on the slug resolver, creator-only visibility in list_recent_for_corpus, and limit clamped to 25.
  • relayStylePagination(["corpusId", "status"]) uses the correct GraphQL field-argument names per CLAUDE.md Bump django-coverage-plugin from 2.0.2 to 2.0.3 #15.
  • The RESEARCH_REPORT_PROGRESS type is added to the enum but deliberately unhandled — good discipline.
  • check_deep_research_status_tool correctly has requires_write_permission=False and is filtered out of the read-only research agent via restrict_tool_names.
  • The routing write-discipline test is updated to include openedResearchReport.
  • Backend tests cover creator-only isolation, limit clamping, and the async tool boundary correctly (using TransactionTestCase).
  • Phase-3 canonical-redirect guard is correctly added for the research route.

Summary: Items 1 and 2 are the only issues I'd call blockers — the toast side effect will produce duplicate notifications in production, and the <a href> links will cause full page reloads for what should be client-side navigation. The rest are improvements for a follow-up.

JSv4 added 2 commits May 29, 2026 23:56
…t, hook cleanups

- ResearchReportDetail: RowLink is now styled(react-router Link) with to= so
  internal document/citation links route client-side instead of full-reload.
- CorpusResearchReportCards: move the error toast into a useEffect so it fires
  once per error rather than on every re-render.
- useResearchCompletionNotification: memoise numericId (feeds useCallback deps)
  and drop a leftover console.debug.
- Add ResearchReportListCard component tests (completed + running) with a
  documentation screenshot.
@claude

claude Bot commented May 30, 2026

Copy link
Copy Markdown

Code Review: Deep-research frontend + chat status tool

Overall this is a well-structured PR that completes the backend-only deep-research feature with a coherent frontend surface. The architecture follows established patterns (CentralRouteManager, reactive vars, services layer, relay pagination). A few issues to address before merge.


Bugs / Correctness

1. Potential test failure in test_research_status_tool.pyduration_seconds may not be populated

test_completed_report_shows_duration sets started_at / completed_at on the report object and then asserts "finished in 2m 5s" in the tool output. But _summarize_recent_reports reads report.duration_seconds — a stored field, not a computed property. If that field is only written by the Celery task completion logic (which doesn't run in the test), it will be None and the assertion will fail. The test should either set duration_seconds=125 explicitly, or the code should compute it from started_at/completed_at as a fallback.

2. openedResearchReport not cleared in several route handlers

The extract route handler was updated to clear openedResearchReport(null), and the global reset path clears it, but the corpus, document, thread, labelset, and user route handlers were not touched. If a user lands on /research/:slug first (setting the var), then clicks to a corpus page within the same navigation session, and the global clear runs before the corpus resolve, this is fine. But during the brief window between clear and resolve, if the component re-renders, it could briefly show stale data. More critically: if openedResearchReport somehow persists through a transition and the Phase 2 early-exit check fires on a non-research route, you'd get a silent no-op resolve. The safe pattern (followed by the other handlers) is to null out all entity vars at the start of each successful handler branch, not just in the extract and global paths.

3. Inlined corpus URL in ResearchReportDetail.tsx handleBack

navigate(`/c/${corpus.creator.slug}/${corpus.slug}?tab=research`);

The comment says this was inlined because importing getCorpusUrl "tripped the CT bundler." Per CLAUDE.md pitfall #16, the fix is to keep the JSX component import in its own import statement separate from helpers — not to duplicate URL-building logic inline. This creates a maintenance burden if the corpus URL schema changes. Fix the import split instead.


Code Quality / Style

4. Hardcoded hex colors violate no-magic-numbers rule

JobNotificationToast.tsx introduces "#4CAF50", "#F44336", "#9CA3AF" directly in JOB_NOTIFICATION_CONFIG. Similarly, ResearchReportDetail.tsx uses background: #ffffff, #fff7ed, #9a3412 in styled components. All of these should come from OS_LEGAL_COLORS tokens (or be added there). The existing extract/analysis notification configs in the same file also use hex literals — this PR is a good opportunity to extract them, but at minimum the new entries should follow the rule.

5. researchSearchTerm reactive var not reset on unmount

ResearchTabContent debounces updates to researchSearchTerm but never resets it on unmount. A previous search term will persist across tab switches. This might be intentional UX, but if it isn't, add a cleanup: useEffect(() => () => researchSearchTerm(""), []). If it's intentional, document it.

6. Client-side title search is disconnected from the pagination window

CorpusResearchReportCards loads one page (DEFAULT_LIST_PAGE_SIZE) from the server and filters client-side by researchSearchTerm. If there are more reports than the page size, the search silently misses them. The component note says "the connection exposes no name argument," which is fine for v1, but the empty-state copy should clarify this limitation (e.g. "Searching the loaded page only — load more to search further") rather than giving the impression it's a full-text search.

7. DEBOUNCE.EXTRACT_SEARCH_MS reuse

Minor: reusing the extract search debounce constant for research search is pragmatic, but the name is misleading. Consider DEBOUNCE.LIST_SEARCH_MS (or add a RESEARCH_SEARCH_MS entry to the constants) so the next reader doesn't assume these two UI elements must stay in sync.


Security

8. researchReportBySlug resolver — services layer compliance

The new resolver uses BaseService.filter_visible directly in the GraphQL resolver body:

BaseService.filter_visible(ResearchReport, info.context.user, request=info.context)
    .filter(slug=slug)
    .first()

This follows the spirit of the services rule (it goes through the shared base service, not inline visible_to_user), and passes request=info.context for Tier-2 cache. Technically BaseService is the services layer for cases where a dedicated per-app method is overkill (as the CLAUDE.md notes). The resolver is IDOR-safe (returns null for non-owners). This looks correct but worth calling out explicitly: if a ResearchReportService.get_by_slug(user, slug) helper is ever added, the resolver should be updated to use it.


Test Coverage Gaps

9. No test for the "cancel" path from the detail view

ResearchReportDetail renders a Cancel button gated on myPermissions. There's no component test or backend test covering the cancel mutation flow (permission check, optimistic update, error state). The Playwright test only covers terminal states.

10. CorpusResearchReportCards has no component test

The list view (filter tabs, load more, empty states, search) is covered only by the unit tests in researchUtils.test.ts. A component test covering the network-idle → cards rendered path would catch regression in the useMemo/fetchMore wiring.


Nits

  • ResearchReportDetail.tsx line ~424: navigate(-1) as the fallback when corpus context is missing will go wrong if the user arrived via a direct link (no history entry). Consider navigate("/") as a safer fallback.
  • ResearchReportListCard.tsx: the handleClick guard if (url !== "#") is correct but relies on getResearchReportUrl's sentinel. A missing slug at this layer is unexpected (the list query always returns slug); a console.warn there would surface the issue faster.
  • The duplicate selection set between GET_RESEARCH_REPORT and RESOLVE_RESEARCH_REPORT_BY_SLUG is noted as intentional (matching project convention). Fine for v1, but a fragment would eliminate drift risk as the type evolves.
  • frontend/tests/ResearchReportDetail.ct.tsx imports ResearchReportDetailTestWrapper and buildMockReport from the same file in two separate statements — this is correct per CLAUDE.md pitfall Update Tests and Remove Configs #16 (the component reference must be isolated). Good.

Summary

The PR is architecturally sound and follows project conventions well (services layer, relay pagination keyArgs, route discipline test, useRef-based callback for the notification hook). The critical items to fix before merge are:

  1. Bug: Verify duration_seconds is set in the test (or use the computed fallback)
  2. Bug: Clear openedResearchReport in all route handlers (not just extract + global reset)
  3. Style: Fix the inlined corpus URL in handleBack via proper import separation
  4. Style: Replace hardcoded hex colors with OS_LEGAL_COLORS tokens

claude and others added 4 commits May 30, 2026 05:33
… feedback

Follow-up to the review fixes already on this branch:
- ResearchReportDetail: replace remaining hardcoded hex (#ffffff/#fff7ed/
  #9a3412) with OS_LEGAL_COLORS tokens (surface/warningSurface/warningText);
  stable keys (key={c.footnote} for citations, key=index+value for warnings);
  Cancel button shows 'Cancelling…' and disables while cancelRequested is
  pending.
- StartResearchModal: reset prompt/title on close so a reopened modal starts
  blank.
- ResearchTabContent: add the 'Queued' filter tab (backend status map already
  supports it; a fresh job sits in QUEUED briefly).

Typescript compiles; research component tests pass.
…tore getCorpusUrl helper, tokenize toast colors, reset research search on unmount

- Clear openedResearchReport(null) in thread/labelset/user route handlers (sibling of openedExtract, was omitted)
- Replace inlined corpus URL in ResearchReportDetail.handleBack with getCorpusUrl(corpus, {tab}); safer navigate('/') fallback
- JobNotificationToast: source status colors from OS_LEGAL_COLORS tokens instead of raw hex
- ResearchTabContent: reset researchSearchTerm and use DEBOUNCE.LIST_SEARCH_MS; clarify empty-state copy + add Load More in CorpusResearchReportCards
- ResearchReportListCard: console.warn on missing slug instead of silent no-op
…eenshot

- New CorpusResearchReportCards.ct.tsx + wrapper: mounts the corpus Research tab list, asserts query wiring + empty state, captures a screenshot (review item 10). Populated-list assertion omitted — the component's network-only query re-fires across renders and MockedProvider can't serve it deterministically in isolation; card visuals are covered by ResearchReportListCard.ct.tsx.
- ResearchReportDetail.ct.tsx: add docScreenshot for the completed-report state.
@claude

claude Bot commented May 30, 2026

Copy link
Copy Markdown

Code Review — Deep Research Frontend + Chat Status Tool

Overall this is a well-structured, carefully scoped feature addition. The architecture follows existing patterns (routing discipline, reactive vars, service layer), and the IDOR protection / creator-only visibility is correctly enforced throughout. Below are findings by severity.


Bugs

1. Annotation typename mismatch in citation deep-links (ResearchReportDetail.tsx ~L2822)

// detail view — builds the annotation URL param
toGlobalId("AnnotationType", annPk)

But the test wrapper uses:

// ResearchReportDetailTestWrapper.tsx
id: toGlobalId("ServerAnnotationType", 10),

If the backend emits annotations with __typename = "ServerAnnotationType" (which the Apollo cache would store under that key), then constructing the deep-link with "AnnotationType" will produce a mismatched global ID. The annotation deep-link from the Citations tab would silently point to the wrong entity. The fullSourceAnnotationList already returns a properly-formed global ID in the id field — use that instead of reconstructing it from the raw PK:

// prefer: look up the annotation by PK from the already-fetched list
const annGlobalId = report?.fullSourceAnnotationList?.find(
  (a) => getNumericIdFromGlobalId(a.id) === annPk
)?.id;

Code Quality / Convention Issues

2. Double GraphQL field selection (DRY)

GET_RESEARCH_REPORT and RESOLVE_RESEARCH_REPORT_BY_SLUG share an identical 30-field selection. The PR notes this mirrors how RESOLVE_EXTRACT_BY_ID / REQUEST_GET_EXTRACT handle their duplication — acceptable as a convention call, but worth a note: if a field is added/removed from one query it will need to be changed in two places.

3. GetResearchReportsInput.status?: string should be JobStatus | undefined

export interface GetResearchReportsInput {
  status?: string;  // ← loose

Other interfaces in this file use the enum. Using JobStatus | undefined gives call-sites a compile-time guard against typos.

4. Toolbar hardcodes background: white (ResearchTabContent.tsx L2964)

const Toolbar = styled.div`
  background: white;  // ← not a design token

All other toolbar backgrounds in this PR use OS_LEGAL_COLORS.*. Should be OS_LEGAL_COLORS.surface or OS_LEGAL_COLORS.background to stay consistent and survive any future theme changes.

5. Magic number in back navigation (ResearchTabContent.tsx L3044)

onClick={() => setActiveTab(0)}

Tab 0 is currently the "home" tab but this is fragile — if tabs are reordered, the back button silently goes to the wrong view. Compare how ExtractsTabContent handles this (it uses a tab ID string). The setActiveTab signature already accepts number, so this would need a signature change or an exported constant like CORPUS_TAB_HOME = 0.

6. Missing opencontractserver/tests/research/__init__.py

The diff adds two files to a new research/ subdirectory under opencontractserver/tests/ but doesn't include an __init__.py. Django's manage.py test with dotted module paths (opencontractserver.tests.research.test_*) requires it. Pytest-based runners usually discover without it, but the CLAUDE.md shows both runners in use — worth adding to be safe.


Minor / Nitpicks

7. Cancelled toast uses STATUS_NEUTRAL (muted grey) — intentional UX decision, but STATUS_INFO or STATUS_FAILED might be more discoverable. Not a bug, just worth a deliberate sign-off.

8. toolCallLog / modelUsage rendered via JSON.stringify in a <pre> block

<CodeBlock>{JSON.stringify(report.toolCallLog, null, 2)}</CodeBlock>

JSON.stringify does not HTML-escape its output — a string value containing <script> would appear verbatim. React's {} interpolation does escape it before insertion into the DOM (React text nodes are always escaped), so this is safe in practice. Just confirming it's intentionally using React's built-in escaping rather than SafeMarkdown, which is the right call here.

9. completedAt cache-write uses client wall-clock as fallback

completedAt: (existing) => existing ?? new Date().toISOString(),

The client time may drift from server time. This is a safe fallback since the next refetch overwrites it, but a future reader might not realise it's approximate.

10. warnings key uses array index

key={`${i}-${String(w)}`}

Warnings on a completed report don't reorder, so this is fine in practice, but using the content itself (or a stable hash) would be more idiomatic React.


Test Coverage

  • ✅ Backend: slug resolver (creator / non-creator / unknown slug)
  • ✅ Backend: status tool + list_recent_for_corpus service (ordering, isolation, limit, duration)
  • ✅ Frontend unit: route parsing, URL generation, utility functions
  • ✅ Frontend CT: detail view (completed + failed states, citations tab), list card, empty state
  • ⚠️ Not covered: StartResearchModal submit/error paths, the polling stop-on-notification integration, and the toast navigation click handler — acceptable for v1 scope but worth noting for follow-up.

Summary

The IDOR protection, service-layer routing, cache key args (CLAUDE.md #15), and routing discipline (Phase-3 guard, openedResearchReport allowlist) are all correctly implemented. The main actionable finding is the annotation typename mismatch (item 1) which would produce broken citation deep-links in production. Items 2–6 are lower priority but worth addressing before merge.

…r token

- Fix citation deep-link annotation typename mismatch in ResearchReportDetail:
  build the annotation global ID from fullSourceAnnotationList (the canonical
  ServerAnnotationType id the backend emits) via an annGlobalIdByPk map,
  instead of reconstructing toGlobalId('AnnotationType', pk) which guessed the
  wrong type name and produced deep-links resolving to the wrong entity. Drop
  the now-unused toGlobalId import.
- Add a CT regression guard asserting the citation link's ?ann= param carries
  the ServerAnnotationType global id.
- Type GetResearchReportsInput.status as JobStatus | undefined (was loose
  string) and tighten FILTER_TO_STATUS to match.
- Use OS_LEGAL_COLORS.surface for the research Toolbar background instead of a
  hardcoded 'white' literal, consistent with the other toolbars in this PR.
@claude

claude Bot commented May 30, 2026

Copy link
Copy Markdown

Code Review

Good PR overall — the feature is well-scoped, the security model is correct, and the routing/notification patterns mirror existing conventions faithfully. A few items to address before merge:


Potential Bug (needs verification)

test_completed_report_shows_duration may be testing a non-existent code path

test_research_status_tool.py (line ~113) sets started_at and completed_at on a report but never sets duration_seconds, then asserts:

self.assertIn("finished in 2m 5s", result)

_summarize_recent_reports reads report.duration_seconds directly — if that field is a stored column (not a @property that computes from started_at/completed_at), the field will be None and the formatted detail will be an empty string, causing the assertion to silently pass the wrong branch or fail outright. Please confirm that ResearchReport.duration_seconds is either a computed property or that the test explicitly sets it. If it's a stored field, the test fixture needs duration_seconds=125.


Medium — Robustness

ResearchReportDetailTestWrapper seeds reactive var in useEffect instead of useState

CorpusResearchReportCardsTestWrapper correctly uses the synchronous useState-initializer trick to seed reactive vars before the child's first render:

React.useState(() => {
  openedCorpus({ id: CORPUS_ID } as any);
  return null;
});

ResearchReportDetailTestWrapper uses useEffect instead, so the component renders a "not found" state for at least one frame before the effect fires and the report appears. The tests don't fail because of the 15 s timeout, but this is a reliability risk (flaky on slow CI). Consider switching to the same useState-initializer pattern for consistency.


Low — Error Handling

CorpusResearchReportCards has no explicit error render path

When error is truthy the component fires a toast but falls through to the loading spinner (if no edges yet) or the empty state (if edges exist from a previous successful fetch). Users who land on a hard error see "No research yet" with no indication that a fetch actually failed. Even a minimal if (error && allReports.length === 0) branch returning an EmptyState with a "Could not load reports" description would be clearer.


Nit — GraphQL variable type looseness

GET_RESEARCH_REPORTS declares $status: String but the backend resolver likely accepts a JobStatus enum. Graphene coerces strings to enum values, so this works, but it bypasses schema validation. If the resolver ever tightens its signature the client will silently send invalid values instead of a compile-time error. Consider changing to $status: String$status: JobStatus (or whatever the Graphene-generated enum scalar name is) to get the type check.


Nit — Unused union member

RESEARCH_REPORT_PROGRESS is added to NotificationType with a comment that it is "not emitted in v1". Adding non-emitted types to a discriminated union is fine for forward-compatibility, but none of the handlers (JOB_NOTIFICATION_TYPES, RESEARCH_NOTIFICATION_TYPES, JOB_NOTIFICATION_CONFIG) include it, so it will never produce a toast or cache update if the server does start emitting it. Either include it in the config stubs (even as a no-op) or leave a // TODO(v2) comment so the next author knows it is intentionally deferred.


Positive observations

  • Security model is correct. researchReportBySlug returns null for non-owners (IDOR-safe), and list_recent_for_corpus goes through filter_visible — no inline Tier-0 ORM access in config/graphql/.
  • Async tool is correctly structured. acheck_deep_research_status delegates DB work to sync_to_async(_summarize_recent_reports), matching the pattern required for all production tools.
  • annGlobalIdByPk uses server-emitted IDs rather than reconstructing them with toGlobalId("AnnotationType", pk) — this avoids the typename mismatch with ServerAnnotationType and is a subtle but important correctness choice. The regression test in ResearchReportDetail.ct.tsx (citations tab, expect(annParam).toBe(toGlobalId("ServerAnnotationType", 10))) correctly guards this.
  • Phase 3 routing skip guard prevents spurious corpus/document redirects on /research/:slug navigations.
  • researchReports: relayStylePagination(["corpusId", "status"]) keyArgs use the correct GraphQL field-argument names (CLAUDE.md Bump django-coverage-plugin from 2.0.2 to 2.0.3 #15).
  • ResearchTabContent correctly cancels the debounced search and resets the reactive var on unmount, preventing stale queries from polluting the next tab visit.
  • Test structure is solidTransactionTestCase for the async tool tests, TestCase for the synchronous service tests, and @login_required on the resolver are all correct.

JSv4 added 2 commits May 30, 2026 17:40
…usly

ResearchReportDetailTestWrapper seeded openedResearchReport in a useEffect,
which fires only after the child's first render — so ResearchReportDetail
flashed its 'not found' state for one frame before the report appeared, a
flakiness risk on slow CI. Switch to the synchronous useState-initializer
pattern (matching CorpusResearchReportCardsTestWrapper) so the var is seeded
before the first render, keeping a useEffect solely for unmount cleanup.
@claude

claude Bot commented May 30, 2026

Copy link
Copy Markdown

Code Review — Deep-research frontend + chat status tool

Overall this is a well-structured, thoughtful PR. The CLAUDE.md pitfalls are handled explicitly (keyArgs field-argument names, Playwright CT split-import, async tools), the permission model is IDOR-safe, and the test coverage is solid for a first pass. A few items below worth addressing before merge.


Bugs

1. routeLoading(false) never called on the 404 path (CentralRouteManager.tsx)

When the report is null or a GraphQL error occurs, the code navigates to /404 without calling routeLoading(false). The /404 pathname resolves as type: "other" and falls through the entity chain to the final "Invalid route configuration" warn, also without clearing routeLoading. The UI loading state stays stuck true until the component unmounts. This pre-exists in the extract handler too, but the pattern is propagated here.

Fix: Call routeLoading(false) before navigate("/404", { replace: true }) in both the error and null-data branches of the research resolution block.


2. Dead code in ResearchReportRoute.tsx — the error branch is unreachable

The research route handler in CentralRouteManager never calls routeError() — on error or not-found it always calls navigate("/404", { replace: true }). Unlike the user route (which does set routeError), routeError here can only be a leftover from a previous route handler. The if (error) branch in ResearchReportRoute is dead code and could mislead future readers.

Fix: Either set routeError(new Error(...)) in the resolution block instead of navigating to /404, or remove the error branch from ResearchReportRoute and rely on the !report branch as the sole not-found display.


3. React key collision risk in the Citations list (ResearchReportDetail.tsx)

citations.map((c, i) => { return <RowLink key={c.footnote}>key={c.footnote} is only unique if footnote numbers are guaranteed distinct. The citations JSON comes from GenericScalar passthrough — there is no schema enforcement preventing duplicates. A malformed backend payload would produce a React key collision (silently broken reconciliation).

Fix: Use the array index key={i} or a composite key as a tiebreaker.


Consistency

4. StartResearchModal bypasses the getResearchReportUrl() helper it introduces

The modal builds the navigation path with a template literal directly. The same PR introduces getResearchReportUrl(report) in navigationUtils.ts specifically to encapsulate this path. Using the helper keeps the URL shape in one place and makes future renaming automatic.

Fix: navigate(getResearchReportUrl(payload.obj)) using the import already available from navigationUtils.


5. ResearchTabContent prop type narrower than the passed function

The component declares setActiveTab: (tab: number) => void but Corpuses.tsx passes setActiveTab(tabIndexOrId: number | string). TypeScript accepts this due to function-type contravariance, so compilation passes. But the prop declaration is misleading — a future editor adding a string call site inside ResearchTabContent would get a runtime surprise.

Fix: Widen to (tab: number | string) => void or extract the shared type.


Performance

6. Double-fetch on initial mount in CorpusResearchReportCards

fetchPolicy: "network-only" already bypasses the cache. The refetch effect fires a second request immediately after the first on initial mount because both auth_token and opened_corpus?.id are truthy when the component first renders. The refetch effect is clearly intended to handle re-auth, not initial load.

Fix: Add a mount-guard ref (same pattern other hooks use) or switch to fetchPolicy: "cache-and-network" and let the refetch effect handle post-auth re-fetches only.


Minor

7. ResearchReportDetail.tsx at 871 lines — Styled-component declarations, three large useMemo blocks, and four tab panels could reasonably be split into a ResearchReportTabs child or a useResearchReportMaps hook. Not a blocker, but Single Responsibility would help when citation logic evolves.

8. console.warn in production utilitygetResearchReportUrl logs a warning when slug is missing. This will appear in every user's browser console in production. The "#" sentinel already signals the caller; the warn is redundant.


Backend

  • researchReportBySlug is creator-only via BaseService.filter_visible — IDOR-safe.
  • acheck_deep_research_status is correctly async, goes through the service layer, and is registered with requires_write_permission=False.
  • list_recent_for_corpus clamps limit to 25 — prevents unbounded list exposure.
  • test_completed_report_shows_duration correctly relies on duration_seconds being a Python property computed from started_at/completed_at, not a DB field — works correctly.
  • Tests cover creator-only access, unknown slugs, corpus isolation, and running/completed/queued states.

Summary

Priority Item
Should fix routeLoading not cleared on 404 path (items 1, 2)
Should fix Citation key collision risk (item 3)
Should fix getResearchReportUrl bypassed in StartResearchModal (item 4)
Nice-to-have Prop type widening, double-fetch guard, component split, console.warn guard
Looks good Backend security and permissions, keyArgs field args, async tools, test suite

JSv4 added 3 commits May 30, 2026 18:37
Resolve CHANGELOG.md conflict by keeping both unreleased entries
(deep-research frontend + runtime corpus categories).
…guard

- CentralRouteManager: call routeLoading(false) before navigate('/404')
  in both the error and null-data branches of the research-report block.
- ResearchReportRoute: remove dead error branch (CentralRouteManager
  redirects to /404 on error/null, never sets routeError for research).
- ResearchReportDetail: key citation rows by array index, not c.footnote,
  to avoid React key collisions from duplicate footnotes in JSON.
- StartResearchModal: navigate via getResearchReportUrl(payload.obj).
- ResearchTabContent: widen setActiveTab prop to (number | string).
- CorpusResearchReportCards: mount-guard the refetch effect to avoid a
  double fetch on initial mount; add explicit 'Could not load reports'
  error render when error && no reports loaded.
- navigationUtils: drop production console.warn in getResearchReportUrl
  ('#' sentinel already signals the caller).
- useNotificationWebSocket: TODO(v2) on RESEARCH_REPORT_PROGRESS union member.
- CorpusResearchReportCardsTestWrapper: set addTypename:false on the
  custom InMemoryCache so it agrees with MockedProvider; a typename-adding
  cache injected __typename into the query the mock link matched against,
  draining the mock bucket and resolving the final fire to an error.
- CorpusResearchReportCards: switch fetchPolicy to cache-and-network
  (nextFetchPolicy cache-first) so re-renders serve from cache instead of
  a network-only refetch storm; only surface the error state once the
  request has settled (error && !loading) so a transient retry error
  doesn't flash over the spinner.
- ResearchReportDetail.ct: add research--report-detail--citations
  docScreenshot after the citations-tab assertions.
@claude

claude Bot commented May 31, 2026

Copy link
Copy Markdown

Code Review — Deep Research Frontend + Chat Status Tool

Overall this is a well-executed, comprehensive PR that follows the project's conventions closely. The architecture is sound, security is handled correctly (IDOR-safe null returns, creator-only visibility via filter_visible, @login_required on every new resolver), and the CLAUDE.md rules are respected throughout (services layer, keyArgs using field-argument names, no inline permission checks, async tools). The comments and test structure are notably thorough.

Issues below are grouped by severity.


Bugs / Correctness

1. RESEARCH_REPORT_PROGRESS in NotificationType is dead code (minor correctness risk)

frontend/src/hooks/useNotificationWebSocket.ts:61

| "RESEARCH_REPORT_PROGRESS";

This type is added to the union but has zero handlers in useJobNotifications.tsx, jobNotificationCacheUpdates.ts, or useResearchCompletionNotification.ts. If the backend ever starts emitting this event in v2, the frontend silently swallows it — the type union won't catch the gap. Better to add it only when the handler is wired (in v2). At minimum remove the trailing comment // TODO(v2): wire incremental progress notifications; not emitted in v1. from the type union and leave the real TODO at the usage site.

2. getResearchStatus falls back to QUEUED for any unknown status

frontend/src/utils/researchUtils.ts:54–59

default:
  return {
    label: RESEARCH_STATUS.QUEUED,
    color: RESEARCH_STATUS_COLORS[RESEARCH_STATUS.QUEUED],
  };

If the backend ever adds a new JobStatus value, unknown strings silently render as "Queued". A non-running report shown as "Queued" could confuse users. An "Unknown" sentinel (or at least a console.warn in the default branch) makes the gap visible.

3. Backend duration format inconsistency with frontend

opencontractserver/llms/tools/research_tools.py:169

f" — finished in {int(secs // 60)}m {int(secs % 60)}s"

The frontend's formatResearchDuration omits the minutes segment when mins === 0 (renders "42s"), but the backend always renders "0m 42s". For the status tool this is LLM-facing text, so the inconsistency is tolerable — but it's worth aligning with the frontend helper (or at least noting why it differs).


Test Coverage

4. Missing anonymous-user test for researchReportBySlug

opencontractserver/tests/research/test_research_report_queries.py

The test covers creator-can-fetch, non-creator-gets-null, and unknown-slug-gets-null, but not the case where an unauthenticated request is made. The @login_required decorator raises PermissionDenied (or returns a GraphQL error, depending on the JWT middleware config) rather than returning null. A test like:

def test_anonymous_user_gets_error(self):
    # create an anonymous-like context (no user) and verify the response
    ...

would lock in the expected behaviour and guard against the decorator accidentally being removed.


Code Style / Minor Issues

5. Misleading nextFetchPolicy comment in CorpusResearchReportCards

frontend/src/components/research/CorpusResearchReportCards.tsx:95–98

// cache-and-network serves from cache on re-render after the first fetch
// (avoiding a network-only refetch storm) while still revalidating.
fetchPolicy: "cache-and-network",
nextFetchPolicy: "cache-first",

The comment describes cache-and-network but the nextFetchPolicy is "cache-first", which does not revalidate on subsequent executions. The comment belongs to the fetchPolicy line and should not imply that nextFetchPolicy also revalidates.

6. StartResearchModal — no maxLength on the optional title input

frontend/src/components/widgets/modals/StartResearchModal.tsx:106

The backend model caps title at 255 chars (models.CharField(max_length=255)). The prompt textarea correctly has maxLength={MAX_RESEARCH_PROMPT_CHARS}, but the title <Input> has no cap. A user who pastes a very long title will get a silent DB truncation (or a backend validation error if the service validates it). Adding maxLength={255} (or a named constant from constants.ts) would be consistent with how the prompt is handled.

7. ResearchReportDetail.tsx is 871 lines — consider extracting styled components

The styled-components section alone runs ~300 lines. The project already has the Corpuses.styles.ts pattern. Extracting to ResearchReportDetail.styles.ts would make the component logic easier to scan. Not a blocking issue for this PR, but worth a follow-up.

8. Citation and source rows use array index as React key

frontend/src/views/ResearchReportDetail.tsx

return href ? <RowLink key={i} to={href}></RowLink> : <Row key={i}></Row>;

Citations are immutable for a given report so this won't cause actual bugs, but using c.footnote (which is already rendered as the FootnoteBadge and should be unique per report) as a key would be semantically better and removes the dual-element conditional key issue.


Positive Callouts

  • The researchReports: relayStylePagination(["corpusId", "status"]) with the inline comment referencing CLAUDE.md Bump django-coverage-plugin from 2.0.2 to 2.0.3 #15 is exactly right, and prevents the silent same-cache-across-corpora bug that has bitten other connection types.
  • The useResearchCompletionNotification approach (ref-stable callback + numericId memoization) mirrors useExtractCompletionNotification cleanly.
  • The ResearchReportDetailTestWrapper using React.useState initializer to seed the reactive var before the child's first render (avoiding the "flash of not-found" race) is a nice pattern.
  • BaseService.filter_visible + .filter(corpus=corpus).order_by("-created") in list_recent_for_corpus is the correct, service-layer way to expose this. The bounded = max(1, min(int(limit), 25)) ceiling is a good defensive touch.
  • The Phase-3 canonical-redirect guard in CentralRouteManager (if (currentRoute.type === "research") return) prevents the slug-based route from being bounced by the corpus/document/extract redirect logic.
  • RESEARCH_REPORT_CANCELLED maps to the warning color (amber) rather than error (red) — correct: cancellation is user-initiated, not a failure.

JSv4 added 2 commits May 30, 2026 20:58
- Remove dead RESEARCH_REPORT_PROGRESS member from the notification type union
  (no v1 emitter/handler); add it when the handler is wired.
- getResearchStatus dev-warns on an unrecognized JobStatus instead of silently
  rendering 'Queued' (null/undefined still treated as the not-set-yet case).
- Align the chat status tool's duration string with the frontend
  formatResearchDuration helper (sub-minute reads '42s', not '0m 42s'); add a
  regression test.
- Cap StartResearchModal's optional title input at MAX_RESEARCH_TITLE_CHARS
  (255, mirroring the ResearchReport.title model column) + new smoke CT.
- Key citation rows off the unique footnote, not the array index.
- Add anonymous-user rejection test pinning @login_required on
  researchReportBySlug.
- Clarify the cache-and-network/cache-first nextFetchPolicy comment.
@claude

claude Bot commented May 31, 2026

Copy link
Copy Markdown

PR Review: Deep Research Frontend + Chat Status Tool

Overall this is a large, well-structured feature addition. The implementation follows the project's architecture patterns closely, uses constants instead of magic numbers, and the security model (creator-only, IDOR-safe slug resolver) is correctly implemented. The positive highlights are listed at the bottom. Here are the issues I found:


Bugs / Correctness

1. Docstring lists similarity_score as a citation dict field, but the dict doesn't include it

opencontractserver/research/services/research_reports.py_render_citations docstring vs. the actual dict literal (~lines 438 vs. 510–519).

The frontend ResearchCitation type marks similarity_score?: number | null (optional) so there is no runtime crash, but the docstring is misleading. Either populate the field from the annotation or remove it from the docstring.


2. start() checks READ permission while the tool wrapper declares requires_write_permission=True

opencontractserver/research/services/research_reports.py line ~71 vs. opencontractserver/llms/tools/research_tools.py line ~205.

The two-level gate is: the CoreTool flag blocks the read-only research agent from calling the tool (correct), and the service checks corpus READ. This means a user with corpus READ but no WRITE can consume LLM budget. If READ is intentional, a short comment explaining the deliberate choice would prevent future readers from "fixing" it to WRITE.


3. Double round-trip fetch when corpus changes in CorpusResearchReportCards

frontend/src/components/research/CorpusResearchReportCards.tsx lines ~107–116.

opened_corpus?.id is in the useEffect dependency array alongside auth_token. When the corpus changes, Apollo automatically re-executes the query because the variables (corpusId) changed — and then the useEffect fires refetch() on top of that, causing two network requests. The didMountRef guard only prevents this on initial mount.

Fix: remove opened_corpus?.id from the useEffect deps and only react to auth_token changes there (Apollo handles the corpus change automatically via variables).


Security

4. No rate-limiting on researchReportBySlug slug enumeration

config/graphql/research_queries.py lines ~72–92.

The resolver correctly returns null for non-owners and unknown slugs (IDOR-safe), but other slug-resolving queries in the codebase carry rate_limited_user_field decorators. An authenticated user could enumerate slugs at high throughput. This is consistent with the existing pattern on documentBySlugs, so it may be acceptable as-is — but worth flagging here in case you want to tighten it with a READ_HEAVY decorator.


Performance

5. refetch() on notification fires before stopPolling() in ResearchReportDetail

frontend/src/views/ResearchReportDetail.tsx lines ~433–451.

When the completion notification fires onComplete → refetch(), the response flips isTerminal, which triggers a useEffect on the next render to call stopPolling(). This is correct, but it means one extra 5-second poll cycle may race the notification-triggered refetch. Calling stopPolling() inside the onComplete callback before refetch() would be cleaner and eliminate the race.


6. Duplicated ~50-line GQL field selection between GET_RESEARCH_REPORT and RESOLVE_RESEARCH_REPORT_BY_SLUG

frontend/src/graphql/queries.ts.

CLAUDE.md's DRY principle would suggest extracting a GQL fragment. If the codebase intentionally avoids fragments in queries.ts (consistent with the RESOLVE_EXTRACT_BY_ID/REQUEST_GET_EXTRACT pattern), this is fine — just noting it.


Code Quality

7. warnings.map uses index-hybrid key

frontend/src/views/ResearchReportDetail.tsx line ~685: key ${i}-${String(w)}.

Using the string content alone (String(w)) would be more stable if warnings can reorder, since they are expected to be unique. Not a bug at typical data volumes, but a minor improvement.


8. ResearchReportListItem.corpus omits creator.slug — silent fallback risk

frontend/src/graphql/queries.ts (GET_RESEARCH_REPORTS corpus sub-selection) and frontend/src/types/graphql-api.ts.

The list corpus selection only fetches id and title. ResearchReportListCard uses getResearchReportUrl (slug-based) for navigation so this is fine today. However, if a future caller tries to use ResearchReportListItem.corpus with getCorpusUrl, it will silently get "#" (the fallback) because creator.slug is absent. A brief comment on the type noting the intentional omission would prevent confusion.


Test Coverage

9. No test for cancelResearchReport mutation rejected for non-creators

The cancel button is gated on myPermissions.includes("update_researchreport") and ResearchReportService.request_cancel correctly rejects non-creators on the backend. If the existing test_research_report_service.py already covers this path, no action needed — but if not, it's a gap worth closing.


10. CorpusResearchReportCards CT test only covers the empty-state path

frontend/tests/CorpusResearchReportCards.ct.tsx lines ~8–15.

The test comment honestly documents why the populated-card path is skipped (cache-and-network drains the MockedProvider). The workaround used in ResearchReportDetail.ct.tsx (seeding the Apollo cache directly before mounting) could extend coverage here, but this is not blocking.


Positive Highlights

  • IDOR-safe null return on researchReportBySlug: BaseService.filter_visible + @login_required with a dedicated anonymous-user rejection test that locks in the auth gate.
  • useResearchCompletionNotification mirrors useExtractCompletionNotification cleanly, with correct useRef-based callback stabilization.
  • relayStylePagination(["corpusId", "status"]) keyArgs correctly use GraphQL field-argument names (per CLAUDE.md Bump django-coverage-plugin from 2.0.2 to 2.0.3 #15 — common footgun avoided).
  • openedResearchReport reactive var is properly cleared in all route-transition branches of CentralRouteManager (6 sites), and is added to the centralRouteDiscipline.test.ts allowlist.
  • Phase-3 canonical-redirect bypass for research routes is a clean 5-line guard that prevents the redirect loop documented in the PR description.
  • select_for_update TOCTOU guard in ResearchReportService.start for the concurrent-start concurrency check is a nice correctness detail.
  • Constants for MAX_RESEARCH_PROMPT_CHARS, MAX_RESEARCH_TITLE_CHARS, and RESEARCH_REPORT_POLL_INTERVAL_MS follow the no-magic-numbers rule.
  • check_deep_research_status_tool is correctly filtered out of the read-only research agent via restrict_tool_names, preventing recursive research spawning.

Files with highest regression risk to manually verify:

  • frontend/src/routing/CentralRouteManager.tsx — any missed openedResearchReport(null) clear on a route transition could leave stale state.
  • opencontractserver/research/services/research_reports.pyfinalize() writes content then sets M2M in separate calls; an interrupted Celery task would leave content with empty provenance (pre-existing behavior, but worth noting).

…e change

- finalize() now wraps the terminal content/status save and the
  source_annotations/source_documents M2M set() in a single
  transaction.atomic() block, so an interrupted Celery task can no longer
  leave a COMPLETED report whose citations have no backing provenance rows.
- CentralRouteManager clears openedResearchReport in Phase 1 whenever the
  resolved route is no longer a research route (single source of truth),
  closing the stale-state gap on /research/:slug -> corpus/document routes
  and browser back/forward. Regression-guarded in centralRouteDiscipline.test.ts.
@claude

claude Bot commented May 31, 2026

Copy link
Copy Markdown

Code Review — PR #1836: Deep research frontend + chat status tool

This is a well-structured PR that cleanly completes the backend-only research feature from #1814. The design decisions are sound and the implementation follows project conventions. Below are findings organized by priority.


🔴 Issues worth fixing before merge

1. GET_RESEARCH_REPORT fetches fullSourceAnnotationList + fullSourceDocumentList on every 5-second poll

ResearchReportDetail.tsx uses GET_RESEARCH_REPORT (which selects fullSourceAnnotationList and fullSourceDocumentList) as the polling query. During a running job both fields are empty so the immediate cost is low, but as soon as the agent records its first annotations these lists grow and every poll over-fetches. A lighter query (status + stepCount + lastProgressAt only) for polling, with a single full-fetch refetch on the terminal WebSocket notification, would eliminate the waste. This is v2-compatible — the useResearchCompletionNotification hook already calls refetch() on terminal, so switching the poll query to a lighter variant requires only a second GQL document.

2. ResearchReportDetail.tsx is 878 lines — violates Single Responsibility

The file contains ~130 lines of styled components, the main component, four tab panels rendered inline, citation row logic, source document logic, and run details. CLAUDE.md rule 3 calls for single-responsibility modules. The tabs in particular are self-contained enough to extract (CitationsTab, SourcesTab, RunDetailsTab). At this size regressions are harder to trace and the diff is large to review on the next change.


🟡 Potential bugs / edge cases

3. Cancel button: optimistic label flips on cancelRequested (server state) not cancelLoading (local state)

In ResearchReportDetail.tsx:

{report.cancelRequested ? "Cancelling…" : "Cancel"}

There is a window between the button click and the server returning cancelRequested=true where the button shows "Cancel" but cancelLoading=true disables it — confusing UX. Using cancelLoading || report.cancelRequested for the label text (and just cancelLoading || report.cancelRequested for disabled) would keep the two states coherent.

4. updateResearchReportTerminal trusts notification data.status without validating it

In jobNotificationCacheUpdates.ts:

const nextStatus = data.status || fallbackStatus;

The data.status field comes from the WebSocket notification payload. If a malformed payload carries an unexpected string, the cache gets a non-canonical status value, making subsequent isTerminalResearchStatus() checks return false and re-enabling polling. The fallbackStatus is always correct (it's the hardcoded per-case value); data.status should either be validated against JobStatus values or dropped in favour of always using fallbackStatus.

5. CorpusResearchReportCardsTestWrapper fixture includes corpus.creator in ResearchReportListItem

ResearchReportListItem does not declare a creator field on corpus, but the fixture passes { id, slug, creator: {...} }. The as ResearchReportListItem cast hides this at compile time. Not a production concern, but can mislead readers into thinking creator is available on list items.


🟡 Performance notes

6. Client-side search doesn't scale

CorpusResearchReportCards applies research_search_term client-side across only the loaded page. The empty-state copy reads "load more to search further" — this is an honest disclosure, but users with many reports will hit friction. Consider logging this limitation in a follow-up issue as a candidate for adding a title_Contains arg to the researchReports connection.


🟢 Things done well

  • Atomicity fix in finalize() — wrapping the save() + M2M set() calls in transaction.atomic() is exactly the right fix for the content/provenance split-brain bug described in the changelog.
  • Route discipline is preservedopenedResearchReport is added to RESERVED_SETTERS, the early-clear guard runs before resolveEntity, and the regression test pins both.
  • IDOR safetyresearchReportBySlug uses BaseService.filter_visible (service layer, per rule 7) + @login_required; non-owners and anonymous users get null/error respectively, pinned by both the resolver test and the anonymous-rejection test.
  • keyArgs use field-argument names (["corpusId", "status"] for relayStylePagination), honoring CLAUDE.md Bump django-coverage-plugin from 2.0.2 to 2.0.3 #15.
  • annGlobalIdByPk uses the server-emitted id rather than reconstructing it with toGlobalId("AnnotationType", pk), which would produce the wrong typename (ServerAnnotationType). The regression test for this specific bug (expect(annParam).toBe(toGlobalId("ServerAnnotationType", 10))) is excellent.
  • Sub-minute duration format mirrors the frontend (42s not 0m 42s) with a dedicated backend test — catches a subtle user-visible inconsistency.
  • RESEARCH_REPORT_PROGRESS deliberately excluded from the notification union until the backend emitter exists.
  • Playwright CT split-import rule (CLAUDE.md #16) followed correctly in all new test files.
  • Test coverage is solid across backend (queries, service, tool) and frontend (route parsing, utils, component smoke tests).

Minor nits

  • ResearchTabContent.tsx root div uses an inline style object rather than a styled component, inconsistent with the surrounding code.
  • The RESOLVE_RESEARCH_REPORT_BY_SLUG query duplicates the full selection from GET_RESEARCH_REPORT. The comment acknowledges this mirrors the extract pattern, which is fine, but a shared GQL fragment would prevent them drifting.
  • ResearchReportDetail does not render an error state when the polling query itself fails (network error). Silent failures during polling could leave the progress indicator frozen.

Collapse a source_documents.set(Document.objects.filter(...)) call onto one
line — black flagged it (the only pre-commit/linter violation on HEAD;
isort/flake8/pyupgrade all clean). Formatting only, no behavior change.
@claude

claude Bot commented May 31, 2026

Copy link
Copy Markdown

Code Review: Deep Research Frontend + Chat Status Tool (#1836)

Overview

This PR completes the frontend surface for the backend-only deep-research feature from #1814. It adds a /research/:slug route, a corpus Research tab, notifications wiring, and a chat status tool. The scope is well-contained, the design decisions are clearly documented, and the implementation generally follows project conventions closely.


Positives

  • Service layer discipline is maintained: resolve_research_report_by_slug routes through BaseService.filter_visible (per CLAUDE.md rule 7), @login_required is present, and the null return for non-owners is IDOR-safe.
  • Apollo cache keyArgs correctly uses field-argument names (["corpusId", "status"]) per CLAUDE.md pitfall Bump django-coverage-plugin from 2.0.2 to 2.0.3 #15.
  • Atomic finalize() transaction (CHANGELOG) is a real correctness fix — content/status and M2M provenance rows previously committed separately.
  • State-leak fix for openedResearchReport is elegant: driving the clear off route type rather than entity IDs covers the corpus/document branches that never clear sibling vars. The regression test (centralRouteDiscipline.test.ts) is appropriate insurance.
  • annGlobalIdByPk mapping in ResearchReportDetail.tsx correctly uses the server's own emitted id rather than re-constructing via toGlobalId("AnnotationType", pk), which would produce the wrong typename — this comment explains a subtle invariant well.
  • getResearchStatus dev-mode warning on unrecognized status is a nice "catch the gap early" touch.
  • useResearchCompletionNotification mirrors the extract pattern cleanly and uses a ref to avoid WebSocket reconnection on callback reference changes.

Issues and Suggestions

1. Polling fetches full report payload (performance)

ResearchReportDetail.tsx uses GET_RESEARCH_REPORT for both the initial load and the 5-second poll. That query fetches everything including fullSourceAnnotationList and fullSourceDocumentList — potentially dozens of annotation rows — on every tick while a job is running:

const { data, refetch, startPolling, stopPolling } = useQuery<...>(GET_RESEARCH_REPORT, {
  variables: { id: reportVar?.id ?? "" },
  ...
});
// ...
startPolling(RESEARCH_REPORT_POLL_INTERVAL_MS);  // 5s

While the job is running, the only data that actually changes is status, stepCount, lastProgressAt, and cancelRequested. The provenance lists only populate after COMPLETED. Consider either:

  • A slim polling query that fetches only the mutable fields while non-terminal, then switching to the full query on terminal flip, or
  • Separating the provenance fetch (fullSourceAnnotationList, fullSourceDocumentList) into a lazy query triggered only when isCompleted becomes true.

2. canCancel uses a hardcoded permission string

const canCancel =
  !isTerminal &&
  Boolean(report?.myPermissions?.includes("update_researchreport"));

A typo here silently disables Cancel for all users. This string should be a constant (e.g. RESEARCH_REPORT_UPDATE_PERMISSION = "update_researchreport" in constants/) so it can be grepped and is consistent with any future permission check on the same model.

3. getResearchStatus fallback to QUEUED is a non-terminal default

default:
  // ...
  return { label: RESEARCH_STATUS.QUEUED, color: RESEARCH_STATUS_COLORS[RESEARCH_STATUS.QUEUED] };

QUEUED is a non-terminal status. If a future backend state arrives before the frontend catches up, the detail view will call startPolling() for it unnecessarily (because isTerminalResearchStatus returns false for unrecognized values). Falling back to COMPLETED or a separate UNKNOWN terminal state would be safer. Alternatively, isTerminalResearchStatus could treat unknown strings as terminal.

4. Duplicate query selection sets

GET_RESEARCH_REPORT and RESOLVE_RESEARCH_REPORT_BY_SLUG are byte-for-byte identical field selections (with a full nested sub-selection). The PR comment acknowledges this as intentional (mirrors the extract pattern), but given the selection is ~40 fields deep, a GQL fragment would eliminate the drift risk. Not a blocker, but worth flagging if this pattern proliferates.

5. CHANGELOG indentation nit

-  PermissionTypes.READ, request=info.context)` and returns the IDOR-safe
+PermissionTypes.READ, request=info.context)` and returns the IDOR-safe

Line ~41 in the CHANGELOG diff has lost its 2-space indent inside the bullet. Minor but inconsistent with the surrounding entries.

6. useNavigate in useJobNotifications — context assumption

const navigate = useNavigate();

Adding useNavigate() to useJobNotifications makes the hook non-composable outside a Router context. If there are any call sites that mount this hook before <BrowserRouter> is in the tree (e.g. in test harnesses), they'll throw. The existing callers appear to be within the app shell, so this is likely fine — just worth noting for future test writers.

7. updateResearchReportTerminal uses client-side completedAt fallback

completedAt: (existing) => existing ?? new Date().toISOString(),

This is the right shape (preserve server value, fallback to now), but client-clock drift could make the displayed "Finished" time off by a few seconds from the server's actual completed_at. The notification payload could carry a completed_at field (or the refetch after the WebSocket fires covers it). Low severity.


Test Coverage

  • Backend slug-resolver + status-tool tests are present.
  • Frontend unit tests cover parseRoute, getResearchReportUrl, and all researchUtils functions — good coverage of the pure logic.
  • The routing write-discipline regression test is well-targeted.
  • ResearchReportDetail.ct.tsx is the only Playwright test; CorpusResearchReportCards, StartResearchModal, and ResearchReportRoute are untested at the component level. Acceptable for v1 given the complexity, but worth tracking.

Summary

The implementation is solid and the architectural choices (slug-based routing, polling + WebSocket terminal flip, service layer routing, cache keyArgs) are all correct. The main concerns are the polling scope (fetching provenance on every tick) and the hardcoded permission string. Everything else is low-severity.

JSv4 added 2 commits May 31, 2026 11:00
… guard, CHANGELOG indent

- Extract the research-report update permission string into a
  RESEARCH_REPORT_UPDATE_PERMISSION constant so a typo can't silently
  disable Cancel for all users (review #2).
- isTerminalResearchStatus now treats an unrecognized status as terminal
  so the detail view never polls indefinitely on a future backend state
  the frontend doesn't recognize; null/Queued/Running stay non-terminal
  (review #3). Added a unit-test case.
- Fix a lost 2-space continuation indent in the CHANGELOG SmartLabel
  entry (review #5).
@claude

claude Bot commented May 31, 2026

Copy link
Copy Markdown

Code Review: Deep Research Frontend + Chat Status Tool

Overview

This PR ships the full frontend surface for the deep-research feature that was backend-only in #1814: a /research/:slug route, a corpus Research tab, report detail view with polling + WebSocket completion, notification wiring, and a chat status tool so users can ask "is my research done?" This is a substantial, well-scoped feature completion.


Positives

  • Architecture is sound. Route resolution follows the same CentralRouteManager Phase-1/3 pattern as extracts, threads, and labelsets. The openedResearchReport reactive var, cache policies, and allowlist discipline test are all consistent with how existing entity routes work.
  • Security posture is good. researchReportBySlug uses filter_visible (creator-only), the resolver is @login_required, non-owner and unknown slug both return null (same response, prevents enumeration), and the anonymous rejection is pinned with a test.
  • Atomic finalize fix is correct. Wrapping the terminal save() and M2M set() calls in transaction.atomic() closes a real gap — a worker crash between them would have left a COMPLETED report with empty provenance.
  • Polling design is clean. isTerminalResearchStatus treats unrecognized statuses as terminal so polling never loops forever on an unknown state. The indirect flow of refetch → status update → useEffect stopping polling is correct.
  • Constants are in the right place. MAX_RESEARCH_PROMPT_CHARS, MAX_RESEARCH_TITLE_CHARS, RESEARCH_REPORT_POLL_INTERVAL_MS, RESEARCH_REPORT_UPDATE_PERMISSION are all in constants.ts, not inlined (CLAUDE.md rule 4).
  • relayStylePagination keyArgs are correct. ["corpusId", "status"] uses the GraphQL field-argument names, per CLAUDE.md pitfall Bump django-coverage-plugin from 2.0.2 to 2.0.3 #15.
  • Test coverage is solid. Backend slug resolver, status tool, and service helper are well-exercised. Frontend unit tests cover utilities, route parsing, and the routing write-discipline regression guard.

Issues

1. Detail test wrapper mock will drain on retries (test fragility risk)

File: frontend/tests/ResearchReportDetailTestWrapper.tsx

The mock array provides exactly one response. The component uses notifyOnNetworkStatusChange: true and calls refetch() inside onComplete, which means a second network trip will find an empty mock bucket and log a "No more mocked responses" error.

Since all tests in ResearchReportDetail.ct.tsx use terminal states (where polling is disabled and useResearchCompletionNotification is also disabled via !isTerminal), this is probably fine in practice. But it is a fragility: if test timing changes or a future test exercises a non-terminal state, the bucket drains and test behaviour becomes unpredictable.

Compare to CorpusResearchReportCardsTestWrapper.tsx which correctly uses maxUsageCount: Number.POSITIVE_INFINITY. Consider applying the same pattern to the detail mock.

2. Duration test relies on duration_seconds being a computed property

File: opencontractserver/tests/research/test_research_status_tool.py

test_completed_report_shows_duration sets started_at and completed_at directly, then asserts the formatted duration appears in the output. This works only if ResearchReport.duration_seconds is a @property computed from those timestamps at read-time — not a stored field.

If duration_seconds is a stored field set only by finalize(), the test creates a report where duration_seconds is None, and _summarize_recent_reports's if secs is not None guard would make detail = "" — causing assertIn("finished in 2m 5s", result) to fail. Since the PR says tests pass, duration_seconds is presumably a property, but a comment documenting this assumption would prevent a future refactor from silently breaking it.

3. Async error handling discards the exception

Files: frontend/src/views/ResearchReportDetail.tsx, frontend/src/components/widgets/modals/StartResearchModal.tsx

Both catch blocks swallow the underlying error without logging. Network errors, GraphQL parse failures, and unexpected exceptions all disappear silently. Add console.error(e) at minimum so production issues are diagnosable.

4. Snake_case local variable names in CorpusResearchReportCards

File: frontend/src/components/research/CorpusResearchReportCards.tsx

The reactive-var shadow locals opened_corpus, research_search_term, and auth_token use snake_case. The rest of the codebase uses camelCase for these. They should be openedCorpus, researchSearchTerm, authToken to match the convention.

5. CancelResearchReportOutput.obj.status typed as plain string

File: frontend/src/graphql/mutations.ts

The cancel output's obj.status is typed as string while ResearchReportType uses JobStatus | string. Minor inconsistency — the narrower union would catch typos if any caller compares against JobStatus values.

6. Slug index on ResearchReport — worth confirming

File: config/graphql/research_queries.py

The researchReportBySlug resolver runs .filter(slug=slug).first() on every route load. If the slug column lacks a DB index this will be a full-table scan as the table grows. AutoSlugField / SlugField(unique=True) both carry an implicit index, so this is likely already covered — just confirm 0001_initial.py declares the field with unique=True or db_index=True.


Minor nits

  • ResearchReportDetail.tsx is 881 lines (mostly styled components). A .styles.ts companion would match the Corpuses.styles.ts pattern — worth a follow-up issue.
  • RESEARCH_REPORT_PROGRESS is correctly absent from the notification union per the inline comment; add it when the backend emitter lands.

Summary

The implementation is clean, security-conscious, and follows project conventions throughout. Main actionable items: add maxUsageCount: Infinity to the detail test wrapper, add error logging to async catch blocks, fix the snake_case variable names, and confirm the slug column is indexed. None of these block the feature from working correctly, but addressing them before merge keeps the test suite resilient and the codebase consistent.

Resolve CHANGELOG.md conflict by keeping both sets of Fixed entries.
@claude

claude Bot commented May 31, 2026

Copy link
Copy Markdown

Code Review

This PR ships the missing frontend surface for the deep-research feature (#1814), along with two backend additions — a slug resolver and a chat status tool. The overall architecture is sound and well-aligned with project conventions. Below are findings sorted by severity.


Correctness / Bugs

1. catch (e) without logging in handleCancel and handleSubmit

ResearchReportDetail.tsx (line ~486) and StartResearchModal.tsx (line ~127):

} catch (e) {
  toast.error("Could not cancel the research job.");
}

The thrown error e is silently swallowed — no console.error(e) or logging. If the mutation throws a network error or an unexpected shape, the root cause is invisible in the browser devtools. Add console.error(e) inside both catch blocks.

2. JobStatus.CREATED absent from the frontend enum

The backend JobStatus enum (opencontractserver/types/enums.py) has CREATED = "CREATED", but the frontend JobStatus in graphql-api.ts only maps Queued, Running, Completed, Failed, Cancelled. If a report briefly surfaces in CREATED state (before it transitions to QUEUED), isTerminalResearchStatus returns true (the unknown-status-is-terminal fallback), causing the detail view to stop polling prematurely and display a completed/terminal UI for a running job.

The CREATED → QUEUED transition may be fast enough in practice that users never hit this, but if it's intentional that the frontend never sees CREATED, that should be documented with a comment in the enum. If it can be seen, it needs to be handled. The getResearchStatus fallback also renders it as "Queued" (via the default branch), which is confusing when paired with the terminal-polling decision.

3. Potential stale-slug 404 race in StartResearchModal

After startResearchReport succeeds, the modal immediately navigates to /research/${payload.obj.slug}. CentralRouteManager then fires researchReportBySlug(slug), which uses cache-first. If the Apollo cache doesn't yet contain that slug (first access after creation), it falls through to network — which should work. However, if there is any read-replica lag and the slug query returns null, CentralRouteManager redirects the user to /404 immediately after creating the report.

This is unlikely in a single-writer setup, but worth noting. A targeted guard (e.g. a retry or a short skip until the report is confirmed in the list) would make the flow resilient.


UX Issues

4. Client-side search only covers the loaded page

CorpusResearchReportCards documents this:

Client-side title search (connection has no name argument).

The empty-state copy correctly adds a hint when pageInfo?.hasNextPage is true. However, the ResearchTabContent SearchBox offers no visual indication that search only covers loaded results. A user searching a corpus with many reports could get an empty result set and not realize they need to click "Load more" first. Consider greying out or annotating the search box with "Searching loaded results" when hasNextPage is true.


Minor / Style

5. isRunning variable includes Queued state

const isRunning = status === JobStatus.Running || status === JobStatus.Queued;

The name isRunning implies Running only. The conditional in RunningTitle correctly distinguishes the two ("Queued…" vs "Research in progress…"), but a future reader scanning the flag will be surprised. Prefer isActive or isNonTerminal to match the semantics.

6. WarningChip key uses array index

{warnings.map((w, i) => (
  <WarningChip key={`${i}-${String(w)}`}>

Index-based keys are fine since warnings are stable (no reorder/delete), but the full warning text (String(w)) is already unique and makes a better key. Using key={String(w)} would be cleaner and also silently de-duplicate identical warnings.

7. ResearchReportDetail.tsx is 881 lines

Most of it is styled-component definitions. This is fine for v1 scope, but the styled components block alone (~300 lines) could reasonably be extracted into a ResearchReportDetail.styles.ts file using the pattern followed by Corpuses.styles.ts — when there's appetite to do it.


What's Well Done

  • transaction.atomic() in finalize() is a correct, well-motivated fix. The comment explains why the block is needed (crash between save() and M2M set() leaves a COMPLETED report with empty provenance) — exactly the kind of comment CLAUDE.md asks for.
  • Security: researchReportBySlug goes through BaseService.filter_visible + @login_required — IDOR-safe null for non-owners, correct rejection for anonymous users, and a test that locks in the auth gate.
  • isTerminalResearchStatus treats unrecognized statuses as terminal — prevents unbounded polling on a future backend state the frontend doesn't yet know about.
  • Citation deep-links use the server's emitted id (from fullSourceAnnotationList) rather than reconstructing a global ID from toGlobalId("AnnotationType", pk), avoiding the ServerAnnotationType typename mismatch. The component test has a dedicated regression guard for this.
  • Apollo cache keyArgs use the GraphQL field-argument names (corpusId, status) per CLAUDE.md Bump django-coverage-plugin from 2.0.2 to 2.0.3 #15, not the variable names.
  • The early openedResearchReport(null) clear in Phase 1 is regression-guarded by the structural assertion in centralRouteDiscipline.test.ts. The comment explains why the per-branch clears are insufficient (corpus/document branches don't clear sibling vars).
  • check_deep_research_status_tool correctly has requires_write_permission=False and is filtered out of the read-only research agent via restrict_tool_names, preventing recursive research-on-research.
  • list_recent_for_corpus clamps limit to max(1, min(limit, 25)) — prevents unbounded list pulls from the chat tool.
  • Magic hex colors replaced with design tokens (STATUS_SUCCESS, etc.) in JobNotificationToast.tsx — good housekeeping alongside the new entries.

JSv4 added 2 commits May 31, 2026 16:01
- graphql-api: add the backend's transient JobStatus.CREATED to the frontend
  enum; it was missing, so isTerminalResearchStatus(CREATED) wrongly returned
  true and stopped status polling before the job left the pre-queue state
- researchUtils: treat CREATED as non-terminal and display it as 'Queued'
- ResearchReportDetail: rename isRunning -> isActive (it covers created/queued/
  running), key warning chips by their text instead of array index, log the
  swallowed error in the cancel catch block
- StartResearchModal: log the swallowed error in the submit catch block
- tests: cover CREATED in getResearchStatus and isTerminalResearchStatus
@claude

claude Bot commented May 31, 2026

Copy link
Copy Markdown

PR Review: Deep Research Frontend + Chat Status Tool

Overall this is a solid, well-structured PR. The code quality is high, security patterns are followed correctly, and the architecture mirrors the established conventions cleanly. A few things worth calling out.


Security / Correctness

config/graphql/research_queries.pyresearchReportBySlug resolver

The IDOR-safe null-return for non-owners and unknown slugs is correct, and the @login_required gate + anonymous-user rejection test in test_research_report_queries.py locks it in well. One minor note: the resolver calls BaseService.filter_visible directly rather than ResearchReportService.filter_visible. Both satisfy the service-layer rule (CLAUDE.md rule 7) since BaseService IS the service layer and E001 won't flag this — but for explicitness and discoverability, callers elsewhere use the app-specific service class. Worth a one-line alias if ResearchReportService already inherits it, otherwise fine as-is.

opencontractserver/research/services/research_reports.pyfinalize() atomic block

The transaction.atomic() wrapping the save() + M2M set() calls is the right fix. Good that the citation-rendering computation was left outside the block.


Potential Polling Edge Case

frontend/src/views/ResearchReportDetail.tsxisActive vs !isTerminal diverge when status is null/undefined

isTerminalResearchStatus(null) returns false (so !isTerminal = true), meaning the polling effect would call startPolling — but isActive is false (it checks for three explicit values), so no running-state UI is shown. In practice a report fetched from the server always has a status, so this is a theoretical edge case. But it could cause indefinite polling on a malformed API response. A defensive guard in the poll effect would close the gap:

if (!report?.id || !report?.status) return;

Low priority given the server always returns a status, but worth noting.


Minor Type Inconsistency in Test Fixtures

frontend/tests/ResearchReportListCard.ct.tsxmakeListItem fixture

The fixture includes slug and creator on the corpus field, but ResearchReportListItem.corpus is typed as { id: string; title?: string } | null. The as ResearchReportListItem cast at the end hides the inconsistency. Consider aligning the fixture to the declared type to avoid future confusion if the constraint tightens.


Missing Screenshot

frontend/tests/ResearchReportListCard.ct.tsx — screenshot target not committed

The test calls docScreenshot(page, "research--report-list-card--completed") but the corresponding PNG is not in the diff (the other four research screenshots are). Not blocking since the workflow is manual, but worth noting for whoever triggers the screenshot workflow next.


UX Note (Known Limitation)

CorpusResearchReportCards.tsx — client-side search applies only to the loaded page

The empty state copy explicitly acknowledges this ("No research reports on the loaded page match your search — load more to search further"), so it is documented. Worth tracking as a v2 item (a backend search arg on the connection) if the list grows large.


Things Done Well

  • Route state hygiene: The pre-resolveEntity guard that clears openedResearchReport whenever route.type !== "research" is the right single-source-of-truth fix. The regression test asserting this clear runs before resolveEntity is a nice enforcement pattern.
  • Notification type union: Explicitly NOT adding RESEARCH_REPORT_PROGRESS until the backend emitter is wired — avoids silent enum gaps.
  • Cache key args: relayStylePagination(["corpusId", "status"]) and researchReportBySlug: { keyArgs: ["slug"] } correctly use GraphQL field-argument names (CLAUDE.md pitfall Bump django-coverage-plugin from 2.0.2 to 2.0.3 #15).
  • Async tool: acheck_deep_research_status is correctly async with sync_to_async wrapping the ORM call (CLAUDE.md pitfall Bump django-celery-beat from 2.2.1 to 2.4.0 #13).
  • Test-wrapper pattern: All Playwright component tests mount through dedicated wrappers. The synchronous React.useState initializer trick for seeding reactive vars before the first render avoids the one-frame "not found" flash on slow CI.
  • Duration formatting parity: Backend _summarize_recent_reports mirrors frontend formatResearchDuration (sub-minute omits the minutes segment), with a regression test asserting "42s" not "0m 42s".
  • Backend test coverage: list_recent_for_corpus tests cover ordering, creator isolation, and limit clamping. The slug-resolver tests cover creator, non-creator null, unknown slug, and the anonymous-user auth gate.

Verdict: Approve pending the optional cleanup above. The two actionable items are the defensive status guard in the poll effect and the test-fixture type alignment; everything else is informational.

@JSv4 JSv4 merged commit 493ee9c into main May 31, 2026
17 of 18 checks passed
@JSv4 JSv4 deleted the claude/inspiring-knuth-bhtzs branch May 31, 2026 21:26
JSv4 added a commit that referenced this pull request Jun 1, 2026
* Address deep-research review follow-ups (#1864)

Code-review follow-ups from PR #1836:

- mutations.ts: narrow CancelResearchReportOutput.obj.status from bare
  `string` to `JobStatus | string` to match ResearchReportType.status,
  so callers comparing against JobStatus values get type checking.
- ResearchReportDetailTestWrapper: add maxUsageCount: Infinity to the detail
  query mock so the view's notifyOnNetworkStatusChange refetch/poll paths
  can't drain the single-response bucket (mirrors the sibling
  CorpusResearchReportCardsTestWrapper).
- research/models.py: document that ResearchReport.duration_seconds is always
  a computed property (never stored); add a guard test pinning the invariant
  the status-tool duration tests depend on.

Reviewer items verified to need no change: the async catch blocks in
ResearchReportDetail.tsx / StartResearchModal.tsx already log via
console.error; the slug column is already indexed (unique=True + db_index);
and the snake_case reactive-var locals in CorpusResearchReportCards
(opened_corpus / research_search_term / auth_token) intentionally match the
established sibling-component convention (CorpusExtractCards /
CorpusAnalysesCards) and avoid shadowing the imported reactive vars.

* Fix linting issues

Narrow Optional return of duration_seconds before assertAlmostEqual to
satisfy mypy arg-type check in the research report model test.

* Address review: trim test-file references from duration_seconds docstring

Per CLAUDE.md, docstrings should not name specific callers/tests since
those references rot as the codebase evolves. Keep the invariant and the
migration-to-stored-field warning, drop the named test files.

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants