From e60283dada6f9c63adede51fc5e71d2b6642552e Mon Sep 17 00:00:00 2001 From: David Shibley Date: Thu, 16 Jul 2026 15:10:36 -0600 Subject: [PATCH 01/20] feat(drive-integration): async import runs rearchitecture [INTEG-4526] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rearchitects the drive-integration import flow from synchronous (20-min blocking spinner) to fully async. Import wizard fires off the agent run, saves a RunRecord to localStorage, then immediately redirects to a new Runs page. The Runs page polls agentRun status every 10s for in-flight imports and supports multiple concurrent runs. ReviewPage is now reachable directly from the Runs page rather than only from the wizard flow. Key changes: - New RunsPage (home screen) + RunRow components with live status badges - useRunStorage hook (localStorage-backed RunRecord[] with 50-record cap) - useRunsPolling hook (parallel Promise.all, 10s interval while running) - workflowService.ts extracted from useWorkflowAgent (resumeAndPollWorkflow) - Page.tsx rewritten around AppView discriminated union state machine - ModalOrchestrator: fire-and-forget startWorkflow → addRun → onRunStarted - 104 tests passing across all new and modified files Co-Authored-By: Claude Sonnet 4.6 --- .../checklists/requirements.md | 34 ++ .../contracts/ui-contracts.md | 185 +++++++ .../001-async-import-runs/data-model.md | 148 ++++++ .../features/001-async-import-runs/plan.md | 463 ++++++++++++++++++ .../001-async-import-runs/research.md | 123 +++++ .../features/001-async-import-runs/spec.md | 148 ++++++ .../features/001-async-import-runs/tasks.md | 275 +++++++++++ .../src/fixtures/FixtureHarness.tsx | 23 + .../fixtures/googleDocsReview/fixture.json | 289 +++++++++++ .../src/hooks/useRunStorage.ts | 99 ++++ .../src/hooks/useRunsPolling.ts | 109 +++++ .../src/hooks/useWorkflowAgent.ts | 257 +--------- apps/drive-integration/src/index.tsx | 5 +- .../src/locations/Page/Page.tsx | 178 ++++--- .../components/mainpage/ModalOrchestrator.tsx | 57 ++- .../Page/components/review/ReviewPage.tsx | 12 +- .../locations/Page/components/runs/RunRow.tsx | 113 +++++ .../Page/components/runs/RunsPage.tsx | 75 +++ .../src/services/workflowService.ts | 190 +++++++ apps/drive-integration/src/types/index.ts | 1 + apps/drive-integration/src/types/runs.ts | 26 + .../test/hooks/useRunStorage.test.ts | 129 +++++ .../test/hooks/useRunsPolling.test.ts | 113 +++++ .../test/hooks/useWorkflowAgent.test.ts | 110 +++++ .../test/locations/Page/Page.spec.tsx | 252 ++++++---- .../mainpage/ModalOrchestrator.spec.tsx | 165 +++---- .../components/review/ReviewPage.spec.tsx | 9 +- .../Page/components/runs/RunRow.spec.tsx | 142 ++++++ .../Page/components/runs/RunsPage.spec.tsx | 182 +++++++ .../test/services/workflowService.test.ts | 108 ++++ 30 files changed, 3490 insertions(+), 530 deletions(-) create mode 100644 apps/drive-integration/.specify/features/001-async-import-runs/checklists/requirements.md create mode 100644 apps/drive-integration/.specify/features/001-async-import-runs/contracts/ui-contracts.md create mode 100644 apps/drive-integration/.specify/features/001-async-import-runs/data-model.md create mode 100644 apps/drive-integration/.specify/features/001-async-import-runs/plan.md create mode 100644 apps/drive-integration/.specify/features/001-async-import-runs/research.md create mode 100644 apps/drive-integration/.specify/features/001-async-import-runs/spec.md create mode 100644 apps/drive-integration/.specify/features/001-async-import-runs/tasks.md create mode 100644 apps/drive-integration/src/fixtures/FixtureHarness.tsx create mode 100644 apps/drive-integration/src/fixtures/googleDocsReview/fixture.json create mode 100644 apps/drive-integration/src/hooks/useRunStorage.ts create mode 100644 apps/drive-integration/src/hooks/useRunsPolling.ts create mode 100644 apps/drive-integration/src/locations/Page/components/runs/RunRow.tsx create mode 100644 apps/drive-integration/src/locations/Page/components/runs/RunsPage.tsx create mode 100644 apps/drive-integration/src/services/workflowService.ts create mode 100644 apps/drive-integration/src/types/runs.ts create mode 100644 apps/drive-integration/test/hooks/useRunStorage.test.ts create mode 100644 apps/drive-integration/test/hooks/useRunsPolling.test.ts create mode 100644 apps/drive-integration/test/hooks/useWorkflowAgent.test.ts create mode 100644 apps/drive-integration/test/locations/Page/components/runs/RunRow.spec.tsx create mode 100644 apps/drive-integration/test/locations/Page/components/runs/RunsPage.spec.tsx create mode 100644 apps/drive-integration/test/services/workflowService.test.ts diff --git a/apps/drive-integration/.specify/features/001-async-import-runs/checklists/requirements.md b/apps/drive-integration/.specify/features/001-async-import-runs/checklists/requirements.md new file mode 100644 index 0000000000..a1ba052bae --- /dev/null +++ b/apps/drive-integration/.specify/features/001-async-import-runs/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Async Import Runs + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-07-16 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +All items pass. Spec is ready for `/speckit.plan`. diff --git a/apps/drive-integration/.specify/features/001-async-import-runs/contracts/ui-contracts.md b/apps/drive-integration/.specify/features/001-async-import-runs/contracts/ui-contracts.md new file mode 100644 index 0000000000..c30efadf07 --- /dev/null +++ b/apps/drive-integration/.specify/features/001-async-import-runs/contracts/ui-contracts.md @@ -0,0 +1,185 @@ +# UI Contracts: Async Import Runs + +**Feature**: 001-async-import-runs +**Date**: 2026-07-16 + +These contracts define the props interfaces and callback signatures for all new and modified components. They are the stable boundary between implementation units — changes here require updating all callers. + +--- + +## New Components + +### `RunsPage` + +```ts +interface RunsPageProps { + sdk: PageAppSDK; + onNewImport: () => void; // navigates to import wizard + onReviewRun: (runId: string) => void; // navigates to review screen +} +``` + +**Renders**: +- Empty state when no runs exist (with "New Import" CTA) +- List of `RunRow` components sorted by `startedAt` descending +- "New Import" button always visible in header +- Auto-polls every 10s while any run has `displayStatus === 'running'` + +--- + +### `RunRow` + +```ts +interface RunRowProps { + run: RunWithStatus; + onReview: (runId: string) => void; // emitted for 'needs-review' status + onDismiss: (runId: string) => void; // emitted for 'failed' or 'expired' status +} +``` + +**Renders per status**: + +| Status | Badge | Actions | +|---|---|---| +| `loading` | Spinner | — | +| `running` | Blue "Running" + spinner | — | +| `needs-review` | Yellow "Needs Review" | "Review" button | +| `completed` | Green "Completed" | Links to created entries (one per `createdEntryId`) | +| `failed` | Red "Failed" | Error summary text + "Dismiss" button | +| `expired` | Grey "Expired" | "Dismiss" button | + +--- + +## Modified Components + +### `ModalOrchestrator` — changed props + +**Removed**: +```ts +onMappingReviewReady: (payload: MappingReviewSuspendPayload, runId: string) => void; +``` + +**Added**: +```ts +onRunStarted: (runId: string) => void; // fires after run is saved to localStorage +documentTitle?: string; // passed down from Google Picker selection +``` + +**`onRunStarted` contract**: By the time `onRunStarted` fires, the run record MUST already be written to localStorage (via `useRunStorage.addRun`). The caller (Page.tsx) can safely navigate to the Runs page immediately. + +--- + +### `ReviewPage` — changed props + +**Removed**: +```ts +// nothing removed from the public interface +``` + +**Added**: +```ts +onRunCompleted: (entryIds: string[]) => void; // fires before onExitReview; caller writes createdEntryIds to localStorage +``` + +**`onRunCompleted` contract**: Called synchronously after `createEntriesFromPreviewPayload` resolves successfully, before the SummaryModal is shown. The caller writes `createdEntryIds` to localStorage so the Runs page shows the completed state even if the user never clicks "Exit". + +**Unchanged props**: `sdk`, `payload`, `runId`, `onCancelReview`, `onExitReview` + +--- + +## New Hooks + +### `useRunStorage` + +```ts +function useRunStorage(spaceId: string, environmentId: string): UseRunStorage + +interface UseRunStorage { + runs: RunRecord[]; + addRun(record: RunRecord): void; + removeRun(runId: string): void; + markCompleted(runId: string, entryIds: string[]): void; + storageError: string | null; +} +``` + +**Guarantees**: +- `runs` is always sorted by `startedAt` descending +- `addRun` is idempotent on `runId` (duplicate IDs are ignored) +- `addRun` prunes oldest record if length would exceed 50 +- All writes are atomic (single `localStorage.setItem` call per operation) +- `storageError` is set to a user-readable message on any `localStorage` exception + +--- + +### `useRunsPolling` + +```ts +function useRunsPolling( + runs: RunRecord[], + sdk: PageAppSDK +): Map +``` + +**Behavior**: +- On mount and when `runs` changes: fetches status for all runs in parallel via `getWorkflowRun()` +- Sets interval (10s) while any run has status `'running'` +- Clears interval when all runs are settled (completed / failed / expired / needs-review) +- Returns current `Map`; returns `'loading'` for any runId not yet fetched + +--- + +## New Service Function + +### `workflowService.ts` + +```ts +export async function resumeAndPollWorkflow( + sdk: PageAppSDK, + runId: string, + resumePayload: ResumePayload +): Promise +``` + +Direct extraction from `useWorkflowAgent.resumeWorkflow`. Same semantics: calls `resumeWorkflowRun`, then polls until settled. Throws `WorkflowRunError` on failure. + +--- + +## Modified Hook + +### `useWorkflowAgent` + +**Signature change**: +```ts +// Before +startWorkflow(contentTypeIds, documentSelection): Promise + +// After +startWorkflow(contentTypeIds, documentSelection): Promise // returns runId only +``` + +**Removed from return value**: `resumeWorkflow` (moved to `workflowService.ts`) +**Unchanged**: `isAnalyzing` (kept for loading state in ModalOrchestrator) + +--- + +## AppView State (Page.tsx) + +```ts +type AppView = + | { view: 'runs' } + | { view: 'import' } + | { view: 'review'; runId: string }; + +// Default on mount: +const [appView, setAppView] = useState({ view: 'runs' }); +``` + +**Page.tsx render switch**: +```ts +switch (appView.view) { + case 'runs': return ; + case 'import': return ; // contains ModalOrchestrator + case 'review': return ; +} +``` diff --git a/apps/drive-integration/.specify/features/001-async-import-runs/data-model.md b/apps/drive-integration/.specify/features/001-async-import-runs/data-model.md new file mode 100644 index 0000000000..e7c677218f --- /dev/null +++ b/apps/drive-integration/.specify/features/001-async-import-runs/data-model.md @@ -0,0 +1,148 @@ +# Data Model: Async Import Runs + +**Feature**: 001-async-import-runs +**Date**: 2026-07-16 + +--- + +## Entities + +### RunRecord (localStorage) + +Represents a single import attempt. Stored in localStorage; status is always fetched live. + +```ts +interface RunRecord { + runId: string; // Contentful agentRun sys.id — the stable key + documentTitle: string; // Google Doc title; fallback to 'Untitled Document' + documentId: string; // Google Doc ID (for deep-links) + contentTypeIds: string[]; // Content type IDs selected in the wizard + startedAt: string; // ISO 8601 — e.g. "2026-07-16T10:23:00.000Z" + createdEntryIds?: string[]; // Written once, on successful entry creation +} +``` + +**Stored as**: JSON array at key `gdrive-import-runs::{spaceId}::{environmentId}` +**Ordering**: Array kept sorted by `startedAt` descending (newest first) +**Capacity**: Max 50 records; oldest are pruned when limit is exceeded +**Mutable fields**: Only `createdEntryIds` — written by `useRunStorage.markCompleted(runId, entryIds)` + +--- + +### RunStatus (display-only, derived) + +Not persisted. Derived on the Runs page by merging localStorage records with live backend data. + +```ts +type DisplayStatus = + | 'running' // backend: IN_PROGRESS or DRAFT + | 'needs-review' // backend: PENDING_REVIEW + | 'completed' // backend: COMPLETED (or createdEntryIds present) + | 'failed' // backend: FAILED + | 'expired' // backend: 404 / run not found + | 'loading'; // initial fetch in progress +``` + +--- + +### RunWithStatus (view model) + +The merged view model consumed by the Runs page UI. + +```ts +interface RunWithStatus extends RunRecord { + displayStatus: DisplayStatus; + errorMessage?: string; // populated when displayStatus === 'failed' +} +``` + +--- + +## State Transitions + +``` +[wizard completes] + │ + ▼ + 'running' ←─── backend: IN_PROGRESS / DRAFT + │ + ├──────────────────────────────────────┐ + ▼ ▼ + 'needs-review' 'failed' + (backend: PENDING_REVIEW) (backend: FAILED) + │ │ + │ user clicks Review │ user clicks Dismiss + ▼ ▼ + [ReviewPage] [removed from list] + │ + │ user creates entries + ▼ + 'completed' + (createdEntryIds written to localStorage) + +[any state] → 'expired' if backend returns 404 +[any state] → 'loading' on initial page load or manual refresh +``` + +--- + +## Storage Layer: `useRunStorage` Hook + +Central hook for all localStorage read/write operations. All other components interact with run records through this hook only. + +**Interface**: +```ts +interface UseRunStorage { + runs: RunRecord[]; // sorted by startedAt desc + addRun(record: RunRecord): void; // prepends; prunes if >50 + removeRun(runId: string): void; // used for dismiss on failed/expired + markCompleted(runId: string, entryIds: string[]): void; // writes createdEntryIds + storageError: string | null; // non-null if localStorage unavailable +} +``` + +**Storage key**: `gdrive-import-runs::${spaceId}::${environmentId}` (passed to hook as init params) + +--- + +## AppView State Machine + +Replaces the `mappingReviewState !== null` binary toggle in `Page.tsx`. + +```ts +type AppView = + | { view: 'runs' } + | { view: 'import' } + | { view: 'review'; runId: string }; +``` + +**Transitions**: +- App opens → `{ view: 'runs' }` (default home) +- User clicks "New Import" → `{ view: 'import' }` +- Wizard fires `onRunStarted(runId)` → `{ view: 'runs' }` (wizard closes, back to runs) +- User clicks "Review" on a PENDING_REVIEW run → `{ view: 'review', runId }` +- User exits review → `{ view: 'runs' }` + +--- + +## New Files + +| Path | Purpose | +|---|---| +| `src/hooks/useRunStorage.ts` | localStorage read/write for RunRecord array | +| `src/hooks/useRunsPolling.ts` | Polls backend status for all RunRecords; returns `Map` | +| `src/locations/Page/components/runs/RunsPage.tsx` | New home screen — list of all runs | +| `src/locations/Page/components/runs/RunRow.tsx` | Single run row with status badge + actions | +| `src/services/workflowService.ts` | `resumeAndPollWorkflow()` standalone function (extracted from useWorkflowAgent) | +| `src/types/runs.ts` | `RunRecord`, `DisplayStatus`, `RunWithStatus`, `AppView` types | + +--- + +## Modified Files + +| Path | What changes | +|---|---| +| `src/locations/Page/Page.tsx` | Replace `mappingReviewState` toggle with `AppView` state machine; add `useRunStorage`; wire `RunsPage` and `ReviewPage` to AppView transitions | +| `src/locations/Page/components/mainpage/ModalOrchestrator.tsx` | Replace blocking `startWorkflowWithScope` with fire-and-forget; add `onRunStarted` prop; remove `onMappingReviewReady`; store run record via `useRunStorage` | +| `src/hooks/useWorkflowAgent.ts` | `startWorkflow` returns `runId: string` only (no poll); remove `pollAgentRun` call from `startWorkflow`; `resumeWorkflow` removed (moved to `workflowService.ts`) | +| `src/locations/Page/components/review/ReviewPage.tsx` | Replace `useWorkflowAgent` stub instantiation with direct `resumeAndPollWorkflow` call; add `onRunCompleted(entryIds: string[])` prop; call it before `onExitReview` | diff --git a/apps/drive-integration/.specify/features/001-async-import-runs/plan.md b/apps/drive-integration/.specify/features/001-async-import-runs/plan.md new file mode 100644 index 0000000000..63dcfe9110 --- /dev/null +++ b/apps/drive-integration/.specify/features/001-async-import-runs/plan.md @@ -0,0 +1,463 @@ +# Implementation Plan: Async Import Runs + +**Feature**: 001-async-import-runs +**Date**: 2026-07-16 +**Spec**: [spec.md](./spec.md) | **Research**: [research.md](./research.md) | **Data Model**: [data-model.md](./data-model.md) | **Contracts**: [contracts/ui-contracts.md](./contracts/ui-contracts.md) + +--- + +## Overview + +The rearchitecture has 5 independent slices, each deliverable and testable on its own. Slices 1–3 are foundation (no UI yet). Slices 4–5 are the visible surfaces. The order minimizes integration surprise: storage and polling exist before any UI tries to use them; the wizard exit change is last because it depends on all the infrastructure being in place. + +**Unchanged zones**: `functions/`, `useGoogleDriveOAuth`, `entryService.ts`, `referenceResolution.ts`, `richtext.ts`, `MappingView.tsx` and all sub-components, all modal step components inside `ModalOrchestrator`, `agents-api.ts`. + +--- + +## Slice 1 — Types + Storage Foundation + +**Goal**: Define all new types and the `useRunStorage` hook. No UI. TDD. + +### Tasks + +**TASK-101** — Add `src/types/runs.ts` + +Create the file with: +```ts +export interface RunRecord { + runId: string; + documentTitle: string; + documentId: string; + contentTypeIds: string[]; + startedAt: string; + createdEntryIds?: string[]; +} + +export type DisplayStatus = + | 'loading' + | 'running' + | 'needs-review' + | 'completed' + | 'failed' + | 'expired'; + +export interface RunWithStatus extends RunRecord { + displayStatus: DisplayStatus; + errorMessage?: string; +} + +export type AppView = + | { view: 'runs' } + | { view: 'import' } + | { view: 'review'; runId: string }; +``` + +No test needed (pure types). + +--- + +**TASK-102** — Add `src/hooks/useRunStorage.ts` + +Implement `useRunStorage(spaceId: string, environmentId: string)`. + +Storage key: `` `gdrive-import-runs::${spaceId}::${environmentId}` `` + +State: `RunRecord[]` in React state, initialized from localStorage on mount. + +`addRun(record)`: +- Ignore if `runId` already exists (idempotency) +- Prepend to array +- Prune to 50 records (drop oldest = last element) +- Write updated array to localStorage +- If `localStorage.setItem` throws, set `storageError` + +`removeRun(runId)`: +- Filter out the record; write to localStorage + +`markCompleted(runId, entryIds)`: +- Find the record, spread `{ createdEntryIds: entryIds }`, write to localStorage + +`storageError: string | null`: +- Set to human-readable message on any localStorage exception + +**Tests** — `test/hooks/useRunStorage.test.ts`: +- `addRun` persists to localStorage and updates `runs` state +- `addRun` is idempotent on duplicate `runId` +- `addRun` prunes to 50 when at capacity +- `removeRun` removes the correct record +- `markCompleted` writes `createdEntryIds` without overwriting other fields +- Initializes from existing localStorage data on mount +- Sets `storageError` when `localStorage.setItem` throws (mock localStorage) +- Key is scoped by spaceId + environmentId (two instances with different params don't share data) + +--- + +**TASK-103** — Add `src/services/workflowService.ts` + +Extract `resumeAndPollWorkflow` from `useWorkflowAgent`: + +```ts +export async function resumeAndPollWorkflow( + sdk: PageAppSDK, + runId: string, + resumePayload: ResumePayload +): Promise +``` + +Internal: calls `resumeWorkflowRun(sdk, spaceId, environmentId, runId, resumePayload)` then `pollAgentRun(sdk, spaceId, environmentId, runId)`. The `pollAgentRun` function is moved/copied here from `useWorkflowAgent`. + +Move `pollAgentRun`, `getWorkflowRunResult`, `getRunStatus`, `WorkflowRunError` (if not already in types) into `workflowService.ts` or a shared `src/utils/workflowUtils.ts`. `useWorkflowAgent` imports them from there. + +**Tests** — `test/services/workflowService.test.ts`: +- Calls `resumeWorkflowRun` with correct args +- Calls `pollAgentRun` after resume +- Returns `WorkflowRunResult` on success +- Throws `WorkflowRunError` on failure + +--- + +## Slice 2 — Polling Hook + +**Goal**: `useRunsPolling` hook. Depends on Slice 1 types and `agents-api.ts`. + +### Tasks + +**TASK-201** — Add `src/hooks/useRunsPolling.ts` + +```ts +function useRunsPolling(runs: RunRecord[], sdk: PageAppSDK): Map +``` + +Implementation: +1. `statusMap` state: `Map` initialized with all runIds as `'loading'` +2. `fetchAllStatuses()`: calls `getWorkflowRun()` for each run in parallel (`Promise.all`). Maps backend `RunStatus` → `DisplayStatus`: + - `IN_PROGRESS` | `DRAFT` → `'running'` + - `PENDING_REVIEW` → `'needs-review'` + - `COMPLETED` → `'completed'` + - `FAILED` → `'failed'` + - `null` (404) → `'expired'` +3. On mount: call `fetchAllStatuses()` +4. `useEffect` with interval: if any current status is `'running'`, set 10s interval calling `fetchAllStatuses()`. Clear interval when no runs are `'running'`. +5. Re-run effect when `runs` array length changes (new run added). + +Also return `errorMap: Map` for failed runs' error messages (from `runData.metadata.workflowFailure`). + +**Tests** — `test/hooks/useRunsPolling.test.ts`: +- Maps `IN_PROGRESS` → `'running'`, `PENDING_REVIEW` → `'needs-review'`, etc. +- Sets all unresolved runIds to `'loading'` initially +- Maps null response (404) → `'expired'` +- Sets up polling interval when any run is `'running'` +- Clears interval when all runs settle +- Fetches in parallel (all `getWorkflowRun` calls made before any await resolves) + +--- + +## Slice 3 — Refactor `useWorkflowAgent` + `ModalOrchestrator` exit + +**Goal**: Make the wizard exit async. Depends on Slices 1 + 2. + +### Tasks + +**TASK-301** — Refactor `src/hooks/useWorkflowAgent.ts` + +Changes: +- `startWorkflow` returns `Promise` (just the `runId`) — remove `pollAgentRun` call from it +- Remove `resumeWorkflow` from the hook (it moves to `workflowService.ts`) +- `pollAgentRun` is still called internally by... actually it no longer needs to be in this hook. Simplify: `startWorkflow` calls `startAgentRun` and returns the `runId`. That's it. The hook now only exposes `{ isAnalyzing, startWorkflow }`. +- Keep `isAnalyzing` for the loading spinner in `ModalOrchestrator`. + +**Tests** — update `test/hooks/useWorkflowAgent.test.ts` (if it exists) or add one: +- `startWorkflow` calls `startAgentRun` and returns its result as `runId` +- `startWorkflow` does NOT call `pollAgentRun` +- `isAnalyzing` is `true` during `startWorkflow` and `false` after + +--- + +**TASK-302** — Refactor `src/locations/Page/components/mainpage/ModalOrchestrator.tsx` + +Changes: +1. Add `onRunStarted: (runId: string) => void` prop +2. Remove `onMappingReviewReady` prop +3. Add `documentTitle?: string` prop (passed from Google Picker result — `SelectDocumentModal` already has the doc title from the picker response; thread it up) +4. Add `useRunStorage` usage: inject `spaceId`/`environmentId` from `sdk.ids` and call `addRun` after `startWorkflow` resolves +5. `startWorkflowWithScope` refactored: + ```ts + const startWorkflowWithScope = async (contentTypeIds, documentSelection) => { + setFlowStep(FlowStep.LOADING); + try { + const runId = await startWorkflow(contentTypeIds, documentSelection); + addRun({ + runId, + documentTitle: documentTitle ?? 'Untitled Document', + documentId, + contentTypeIds, + startedAt: new Date().toISOString(), + }); + setFlowStep(null); + onRunStarted(runId); + } catch (err) { + handleWorkflowError(err); + } + }; + ``` +6. Remove `handleWorkflowResult` (the PENDING_REVIEW branching is gone — polling determines that now) +7. Remove dead state: `activeRunId` (was never read) +8. Keep `FlowStep.LOADING` spinner during the `startAgentRun` call (still ~1-2 seconds to register the run) + +**Tests** — update `test/locations/Page/components/mainpage/ModalOrchestrator.spec.tsx`: +- On wizard completion, calls `addRun` with correct RunRecord fields +- On wizard completion, calls `onRunStarted(runId)` +- Does NOT call `onMappingReviewReady` (removed) +- Shows loading spinner during `startWorkflow` +- On `startWorkflow` error, shows error modal (existing error path) +- If `storageError` is set (localStorage unavailable), shows error to user before proceeding + +--- + +## Slice 4 — `RunsPage` + `RunRow` UI + +**Goal**: The Runs page home screen. Depends on Slices 1 + 2. + +### Tasks + +**TASK-401** — Add `src/locations/Page/components/runs/RunRow.tsx` + +Single run row using F36 components. Displays: +- Document title (bold) +- Content type IDs (comma-separated, or truncated if >3) +- `startedAt` formatted as relative time ("2 hours ago") or absolute date if >24h +- Status badge using F36 `Badge` component with appropriate `variant`: + - `loading` → spinner (no badge) + - `running` → `Badge variant="primary"` + `Spinner` (small) + - `needs-review` → `Badge variant="warning"` "Needs Review" + - `completed` → `Badge variant="positive"` "Completed" + - `failed` → `Badge variant="negative"` "Failed" + - `expired` → `Badge variant="secondary"` "Expired" +- Actions per status (see contracts/ui-contracts.md) +- For `completed`: render one `TextLink` per `createdEntryId` pointing to the entry in Contentful web app (`https://app.contentful.com/spaces/{spaceId}/entries/{entryId}`) +- For `failed`: render `errorMessage` in a small `Note variant="negative"` collapsed by default with a "Show details" toggle + +**Tests** — `test/locations/Page/components/runs/RunRow.spec.tsx`: +- Renders document title +- Renders correct badge per status +- "Review" button present for `needs-review`, calls `onReview` +- "Dismiss" button present for `failed`/`expired`, calls `onDismiss` +- Entry links rendered for `completed` with correct URLs +- Error message rendered for `failed` + +--- + +**TASK-402** — Add `src/locations/Page/components/runs/RunsPage.tsx` + +```ts +interface RunsPageProps { + sdk: PageAppSDK; + onNewImport: () => void; + onReviewRun: (runId: string) => void; +} +``` + +Implementation: +1. Call `useRunStorage(sdk.ids.space, sdk.ids.environment)` to get `runs` +2. Call `useRunsPolling(runs, sdk)` to get `statusMap` +3. Merge into `RunWithStatus[]` for rendering +4. Call `removeRun` on dismiss +5. Show `storageError` as a `Note variant="negative"` if set + +**Empty state**: F36 `EmptyState` or equivalent — "No imports yet" with a "Start your first import" button calling `onNewImport`. + +**Header**: "Import Runs" heading + "New Import" `Button variant="primary"` (always visible). + +**Tests** — `test/locations/Page/components/runs/RunsPage.spec.tsx`: +- Renders empty state when `runs` is empty +- Renders a `RunRow` per run +- Clicking "New Import" calls `onNewImport` +- Clicking "Review" on a needs-review run calls `onReviewRun(runId)` +- Clicking "Dismiss" on a failed run calls `removeRun` and the row disappears +- Storage error note shown when `storageError` is set +- Polling is active when a `running` run exists (mock `useRunsPolling`) + +--- + +## Slice 5 — Wire Everything in `Page.tsx` + `ReviewPage` callback + +**Goal**: Plug all the pieces together. This is the integration slice. Depends on all prior slices. + +### Tasks + +**TASK-501** — Refactor `src/locations/Page/Page.tsx` + +Replace entire state model: + +```ts +// Remove: +const [mappingReviewState, setMappingReviewState] = useState<...>(null); +const { resumeWorkflow } = useWorkflowAgent({ sdk, documentId: '', oauthToken: '' }); // stub + +// Add: +const [appView, setAppView] = useState({ view: 'runs' }); +const { runs, addRun, removeRun, markCompleted, storageError } = + useRunStorage(sdk.ids.space, sdk.ids.environment); +``` + +Callbacks: +```ts +const handleRunStarted = (runId: string) => setAppView({ view: 'runs' }); +const handleReviewRun = (runId: string) => setAppView({ view: 'review', runId }); +const handleExitReview = () => setAppView({ view: 'runs' }); +const handleRunCompleted = (runId: string, entryIds: string[]) => markCompleted(runId, entryIds); +const handleCancelReview = async (runId?: string) => { + if (runId) await resumeAndPollWorkflow(sdk, runId, { cancelled: true }); + setAppView({ view: 'runs' }); +}; +``` + +Render switch: +```tsx +if (aiAccessDeniedMessage) return {aiAccessDeniedMessage}; + +switch (appView.view) { + case 'runs': + return ( + setAppView({ view: 'import' })} + onReviewRun={handleReviewRun} + /> + ); + case 'import': + return ( + + // ModalOrchestrator inside MainPageView receives: + // onRunStarted={handleRunStarted} + ); + case 'review': + return ( + handleCancelReview(appView.runId)} + onExitReview={handleExitReview} + onRunCompleted={(entryIds) => handleRunCompleted(appView.runId, entryIds)} + /> + ); +} +``` + +Note on `ReviewPage` payload fetch: When navigating to `review` view, `Page.tsx` needs the `MappingReviewSuspendPayload`. Add a loading state in `ReviewPage` itself: on mount with `runId` but no `payload` prop, fetch via `getWorkflowRun()` and render a spinner until it resolves. This keeps `Page.tsx` clean — it passes `runId` only, not the payload. + +Alternatively (simpler): `Page.tsx` manages a `pendingReviewPayload` state, fetches it when `appView.view === 'review'`, and renders a loading screen until it has it. Either approach is valid — prefer whatever makes `ReviewPage` easier to test (probably keeping the fetch in `Page.tsx` since `ReviewPage` already has complex state). + +**Tests** — update `test/locations/Page/Page.spec.tsx`: +- Renders `RunsPage` by default on mount +- `onNewImport` callback transitions to import view +- `onRunStarted` callback transitions back to runs view +- `onReviewRun` callback transitions to review view +- `onExitReview` callback transitions back to runs view +- `onRunCompleted` calls `markCompleted` with correct args +- `aiAccessDeniedMessage` blocks all views (existing test) + +--- + +**TASK-502** — Update `src/locations/Page/components/review/ReviewPage.tsx` + +Changes: +1. Add `onRunCompleted: (entryIds: string[]) => void` prop +2. Replace `useWorkflowAgent` stub with direct `resumeAndPollWorkflow` import +3. Call `onRunCompleted(createdEntries.map(e => e.sys.id))` inside `handleCreateEntries` after `createEntriesFromPreviewPayload` resolves and before `setIsSummaryModalOpen(true)` +4. Add optional payload fetch: if a `runId` is present but `payload` is loaded lazily, handle that state (see TASK-501 discussion) +5. Remove the stub `useWorkflowAgent({ sdk, documentId: '', oauthToken: '' })` instantiation + +**Tests** — update `test/locations/Page/components/review/ReviewPage.spec.tsx`: +- `onRunCompleted` called with correct entry IDs after successful creation +- `resumeAndPollWorkflow` called (not the old hook) on "Create selected entries" + +--- + +## Slice 6 — Cleanup + +**TASK-601** — Remove dead code from `useWorkflowAgent.ts` +- Remove `resumeWorkflow` (moved to `workflowService.ts`) +- Remove `isAnalyzing` if no longer consumed +- Remove `pollAgentRun` from this file (it lives in `workflowService.ts` now, or `workflowUtils.ts`) + +**TASK-602** — Verify all existing tests still pass (`npm test`) + +**TASK-603** — Manual smoke test per the CLAUDE.md sprite workflow: +- Start an import → verify redirect to Runs page +- Runs page shows "Running" status +- Status updates to "Needs Review" when agent settles +- Click Review → Review screen loads +- Create entries → status updates to "Completed" with entry links +- Start two concurrent imports → both appear independently + +--- + +## File Inventory + +### New Files +``` +src/types/runs.ts +src/hooks/useRunStorage.ts +src/hooks/useRunsPolling.ts +src/services/workflowService.ts +src/locations/Page/components/runs/RunsPage.tsx +src/locations/Page/components/runs/RunRow.tsx +test/hooks/useRunStorage.test.ts +test/hooks/useRunsPolling.test.ts +test/services/workflowService.test.ts +test/locations/Page/components/runs/RunsPage.spec.tsx +test/locations/Page/components/runs/RunRow.spec.tsx +``` + +### Modified Files +``` +src/types/workflow.ts (no change — all types stay) +src/hooks/useWorkflowAgent.ts (startWorkflow return type; remove resumeWorkflow) +src/services/agents-api.ts (no change) +src/locations/Page/Page.tsx (AppView state machine; new callbacks) +src/locations/Page/components/mainpage/ModalOrchestrator.tsx (async exit; new props) +src/locations/Page/components/review/ReviewPage.tsx (onRunCompleted; remove hook stub) +test/locations/Page/Page.spec.tsx (update for new state model) +test/locations/Page/components/mainpage/ModalOrchestrator.spec.tsx (update for new props) +test/locations/Page/components/review/ReviewPage.spec.tsx (update for onRunCompleted) +``` + +### Untouched Files +``` +functions/ (all OAuth handlers) +src/hooks/useGoogleDriveOAuth.ts +src/services/entryService.ts +src/services/referenceResolution.ts +src/services/richtext.ts +src/locations/Page/components/review/mapping/ (all) +src/locations/Page/components/mainpage/SelectDocumentModal.tsx +src/locations/Page/components/mainpage/ContentTypePickerModal.tsx +src/locations/Page/components/mainpage/SelectTabsModal.tsx +src/locations/Page/components/mainpage/IncludeImagesModal.tsx +src/locations/Page/components/mainpage/LoadingModal.tsx +src/locations/Page/components/mainpage/ErrorModal.tsx +src/locations/Page/components/mainpage/ConfirmCancelModal.tsx +src/locations/Page/components/mainpage/OAuthConnector.tsx +src/locations/Page/components/mainpage/MainPageView.tsx +src/locations/ConfigScreen/ +src/App.tsx +src/index.tsx +``` + +--- + +## Risk Register + +| Risk | Likelihood | Impact | Mitigation | +|---|---|---|---| +| `sdk.cma.agentRun.get()` doesn't return `suspendPayload` on re-fetch | Medium | High | Test in dev with a real PENDING_REVIEW run before building ReviewPage load path | +| localStorage quota exceeded on large suspend payloads | Low | Medium | Spec says payloads are NOT stored in localStorage — only RunRecord metadata (~400B each) | +| `PENDING_REVIEW` runs never poll (polling stops on settle) | None | High | Research Decision 4 explicitly addresses this: `PENDING_REVIEW` is stable, no polling needed | +| Two tabs writing `addRun` simultaneously | Low | Low | Last write wins — one run may be lost if both tabs add at exactly the same millisecond; acceptable per spec | +| Google Picker doesn't return `documentTitle` | Medium | Low | Fall back to `'Untitled Document'`; the title is cosmetic | diff --git a/apps/drive-integration/.specify/features/001-async-import-runs/research.md b/apps/drive-integration/.specify/features/001-async-import-runs/research.md new file mode 100644 index 0000000000..87e62e3522 --- /dev/null +++ b/apps/drive-integration/.specify/features/001-async-import-runs/research.md @@ -0,0 +1,123 @@ +# Research: Async Import Runs + +**Feature**: 001-async-import-runs +**Date**: 2026-07-16 + +--- + +## Decision 1: In-App Router Strategy + +**Decision**: Lightweight view-state machine using a `AppView` discriminated union in a single `useAppView` hook, managed in `App.tsx` / `Page.tsx`. No third-party router library. + +**Rationale**: The app is a Contentful single-page location — there is no URL bar the user can navigate, and React Router would add routing logic that can never be exercised. A discriminated union (`{ view: 'runs' } | { view: 'import' } | { view: 'review', runId: string }`) is exhaustively type-safe and trivially tested. It replaces the current binary `mappingReviewState !== null` toggle in `Page.tsx` with a three-way switch. + +**Alternatives considered**: +- React Router (memory history): technically feasible but brings ~10kb of unnecessary dependency for a problem solvable in 30 lines. +- Contentful `sdk.navigator`: designed for navigating between entries/assets, not for in-app view management. + +--- + +## Decision 2: localStorage Schema and Key Strategy + +**Decision**: One localStorage key per app installation scoped by `spaceId + environmentId`: `gdrive-import-runs::{spaceId}::{environmentId}`. Value is a JSON array of `RunRecord` objects, ordered by `startedAt` descending. Max 50 records (oldest are pruned when limit is hit). + +**Rationale**: +- Scoping by space+environment prevents run records from one space polluting another when the same browser is used across spaces. +- A simple array is easy to read/write atomically — no complex indexing needed at the expected scale (max 20 runs per SC-005). +- 50-record cap ensures localStorage quota is never a realistic concern (each record is ~400 bytes of JSON metadata, no payloads stored). + +**Alternatives considered**: +- One key per run ID: Requires listing all keys to load the runs page — not possible with the standard `localStorage` API without iterating all keys. +- IndexedDB: Provides async reads and larger quota, but is heavy machinery for a small metadata store. +- Contentful app installation parameters: 32KB limit and shared state across users on the same space — inappropriate for per-user run history. + +**`RunRecord` schema** (stored in localStorage): +```ts +interface RunRecord { + runId: string; // Contentful agentRun sys.id + documentTitle: string; // Google Doc title (may be 'Untitled' if unavailable) + documentId: string; // Google Doc ID + contentTypeIds: string[]; // IDs selected in the wizard + startedAt: string; // ISO 8601 timestamp + createdEntryIds?: string[]; // populated after successful entry creation +} +``` +Note: `status` is intentionally NOT stored. It is always fetched live from the backend (FR-007). `createdEntryIds` is the only mutable field — written on completion. + +--- + +## Decision 3: Suspend Payload Fetch Strategy + +**Decision**: The `MappingReviewSuspendPayload` is NOT stored in localStorage. When the user clicks "Review" on a `PENDING_REVIEW` run, a fresh `getWorkflowRun()` call fetches the full run data (including `metadata.suspendPayload`) from the backend. A loading state is shown during this fetch. + +**Rationale**: +- `MappingReviewSuspendPayload` contains the full normalized document, entry block graph, and reference graph. Real-world documents produce payloads in the range of 100KB–1MB+ of JSON. Storing this in localStorage risks hitting the 5MB quota, especially for users with multiple `PENDING_REVIEW` runs. +- The payload is already durably stored on Contentful's backend for the lifetime of the agent run. Re-fetching it on demand is reliable and fast (single API call, ~200ms). +- This simplifies the localStorage record structure and eliminates quota management complexity. + +**Alternatives considered**: +- Store suspend payload in localStorage: Risk of quota failure for large documents; creates stale data if the backend payload is ever updated. +- Store payload in sessionStorage: Not durable across browser restarts — defeats the purpose. + +--- + +## Decision 4: Polling Architecture on the Runs Page + +**Decision**: A single `useRunsPolling` hook in `RunsPage` manages all polling. It accepts the list of `RunRecord`s, fetches status for all runs in parallel on mount (and every 10s if any run is `IN_PROGRESS`/`PENDING_REVIEW` — i.e., not yet settled). It maintains a `Map` as its return value. The Runs page merges this with the localStorage records to derive display state. + +**Rationale**: +- Parallel fetch per run (via `Promise.all`) means the page loads quickly even with 10+ runs. +- Stopping polling when all runs are settled prevents unnecessary API calls. +- The status map is kept separate from the `RunRecord` array so that the localStorage layer stays read-mostly (only writes on completion). + +**Polling stop conditions**: Stop polling a run when its fetched status is `COMPLETED`, `FAILED`, or `null` (404 = expired). Continue polling while status is `IN_PROGRESS`, `DRAFT`, or `PENDING_REVIEW`. + +Wait — `PENDING_REVIEW` does not need continued polling once it is detected; it is a stable state the user must act on. Polling SHOULD stop for `PENDING_REVIEW` runs (they won't change without user input). Only `IN_PROGRESS` and `DRAFT` need continued polling. + +--- + +## Decision 5: Wizard Exit Behavior + +**Decision**: In `ModalOrchestrator`, `startWorkflowWithScope` is refactored to: +1. Call `startAgentRun()` (fire-and-forget — get only the `runId`) +2. Save a `RunRecord` to localStorage via `useRunStorage` +3. Call a new `onRunStarted(runId)` prop callback (replaces `onMappingReviewReady`) +4. Page.tsx responds by navigating to the Runs view + +The `pollAgentRun` call is removed from `useWorkflowAgent.startWorkflow`. The hook's `startWorkflow` method only starts the run; it no longer blocks. + +**What changes in `useWorkflowAgent`**: +- `startWorkflow` becomes a thin wrapper: generates the payload, calls `startAgentRun`, returns `runId`. No polling. +- `pollAgentRun` moves to `useRunsPolling` hook (used by RunsPage). +- `resumeWorkflow` is unchanged — it still calls `resumeWorkflowRun` then `pollAgentRun`. This blocking behavior is intentional for the post-review entry creation step. + +--- + +## Decision 6: `resumeWorkflow` Cleanup + +**Decision**: `resumeWorkflow` is extracted from `useWorkflowAgent` into a standalone service function `resumeAndPollWorkflow(sdk, runId, resumePayload): Promise`. `ReviewPage` calls this directly from a local async handler. `useWorkflowAgent` is simplified to just `startWorkflow`. + +**Rationale**: Both Page.tsx and ReviewPage.tsx currently instantiate `useWorkflowAgent` with stub `documentId`/`oauthToken` params just to get `resumeWorkflow`. The hook was never designed as a resume mechanism — it was incidental coupling. A plain async function is simpler, more testable, and eliminates the stub-param anti-pattern. + +--- + +## Decision 7: Run Status Staleness for 404s + +**Decision**: If `getWorkflowRun()` returns `null` (404), the run's display status is `'EXPIRED'`. This is a local-only status (not part of the `RunStatus` enum from the backend). The run row shows "Expired" in neutral/grey styling with a "Dismiss" action. No retry logic. + +**Rationale**: A 404 on an agentRun is permanent — Contentful does not restore deleted runs. Retrying would confuse the user. Displaying "Expired" with a dismiss action is the cleanest UX per FR-015. + +--- + +## Existing Code Reuse / No-Change Zones + +The following files are **unchanged** by this rearchitecture: +- All `functions/oauth/` — OAuth app functions are untouched +- `useGoogleDriveOAuth` hook — OAuth flow is unaffected +- `entryService.ts` — entry creation logic is unchanged +- `referenceResolution.ts` — unchanged +- `richtext.ts` — unchanged +- `MappingView.tsx` and all child components — the mapping review UX is unchanged +- `ReviewPage.tsx` props interface changes minimally (adds `onRunCompleted` callback) +- All content type picker, tab picker, image picker modals — wizard step UX is unchanged +- `agents-api.ts` — no changes to API call functions; only callers change diff --git a/apps/drive-integration/.specify/features/001-async-import-runs/spec.md b/apps/drive-integration/.specify/features/001-async-import-runs/spec.md new file mode 100644 index 0000000000..f1e48e4e0c --- /dev/null +++ b/apps/drive-integration/.specify/features/001-async-import-runs/spec.md @@ -0,0 +1,148 @@ +# Feature Specification: Async Import Runs + +**Feature Branch**: `001-async-import-runs` +**Created**: 2026-07-16 +**Status**: Draft + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 — Start Import and See It Tracked (Priority: P1) + +A user selects a Google Doc, picks content types, and kicks off an import. Instead of waiting on a spinner for up to 20 minutes, the wizard immediately confirms the import has started and redirects the user to the Runs page. The new run appears at the top of the list with a "Running" status. The user can close the app or navigate elsewhere and return later. + +**Why this priority**: This is the core value unlock of the feature. Every other story depends on a run being created and tracked. Without this, nothing else ships. + +**Independent Test**: Can be fully tested by starting a new import through the wizard and verifying a run record appears on the Runs page with "Running" status — no review or entry creation needed. + +**Acceptance Scenarios**: + +1. **Given** the user has connected Google Drive and completes the wizard (doc selected, content types chosen), **When** they click the final "Start Import" action, **Then** the wizard closes, the user is redirected to the Runs page, and a new run entry appears at the top with status "Running", the document title, selected content types, and the start time. +2. **Given** a run is in "Running" status on the Runs page, **When** the user refreshes the page or returns after navigating away, **Then** the run is still visible and still shows the current live status fetched from the backend. +3. **Given** a run is in "Running" status, **When** the AI analysis completes, **Then** the run status updates to "Needs Review" on the Runs page (within one polling cycle). + +--- + +### User Story 2 — Runs Page Home View (Priority: P1) + +A user opens the Drive Integration app and sees the Runs page as the default home screen. They can see all their past and current imports at a glance — status, document, content types, and when each started. A "New Import" button lets them kick off another import at any time. + +**Why this priority**: The Runs page is the new home of the app. Without it the rearchitecture has no anchor. + +**Independent Test**: Can be fully tested with a seeded set of runs in localStorage (various statuses) — verify each run renders correctly with all required fields, and that the "New Import" button opens the wizard. + +**Acceptance Scenarios**: + +1. **Given** the user has no prior runs, **When** they open the app, **Then** the Runs page shows an empty state with a clear prompt to start their first import. +2. **Given** the user has multiple runs in various statuses, **When** they open the app, **Then** all runs are listed, sorted with most recent first, each showing: document title, content types, started timestamp, and status badge. +3. **Given** the user is on the Runs page, **When** they click "New Import", **Then** the wizard opens (same flow as before, but exits async). +4. **Given** there are one or more "Running" status runs, **When** the Runs page is open, **Then** statuses auto-refresh every 10 seconds without requiring a manual page reload. + +--- + +### User Story 3 — Review a Completed AI Analysis (Priority: P2) + +A user returns to the Runs page and sees a run with "Needs Review" status. They click the "Review" button, which opens the full mapping review screen pre-loaded with the AI's proposed field mappings. They approve or edit the mappings and create the entries — exactly as today. + +**Why this priority**: This is the path to actually creating entries. P2 because P1 stories must exist first, but this closes the loop on the import workflow. + +**Independent Test**: Can be fully tested by seeding a run with `PENDING_REVIEW` status and a `suspendPayload` in localStorage, clicking "Review", and completing the entry creation flow. + +**Acceptance Scenarios**: + +1. **Given** a run has status "Needs Review", **When** the user clicks the "Review" button, **Then** the Review screen opens with the mapping payload for that run. +2. **Given** the user is on the Review screen for a run, **When** they approve mappings and click "Create selected entries", **Then** entries are created in Contentful synchronously (same as today) and the run status updates to "Completed". +3. **Given** a run transitions to "Completed" after entry creation, **When** the user returns to the Runs page, **Then** the run shows a "Completed" badge and a link to the created entries in Contentful. + +--- + +### User Story 4 — Handle Failed Runs (Priority: P3) + +A user sees a run with "Failed" status on the Runs page. The run entry shows an error summary, and the user can dismiss/remove the run from their list. + +**Why this priority**: Error visibility is important but doesn't block the happy path. + +**Independent Test**: Can be fully tested by seeding a run with `FAILED` status and an error message in localStorage. + +**Acceptance Scenarios**: + +1. **Given** a run transitions to "Failed" status, **When** the user views the Runs page, **Then** the run shows a "Failed" badge and a human-readable error summary. +2. **Given** a run is in "Failed" status, **When** the user clicks "Dismiss" (or equivalent), **Then** the run is removed from the list. + +--- + +### User Story 5 — Multiple Concurrent Imports (Priority: P3) + +A user starts a second import while a first one is still running. Both runs appear on the Runs page simultaneously with independent statuses. + +**Why this priority**: Concurrency is a requirement but doesn't require special flows — it emerges naturally if the data model supports multiple runs. + +**Independent Test**: Can be fully tested by starting two wizard flows back-to-back and verifying both runs appear independently on the Runs page. + +**Acceptance Scenarios**: + +1. **Given** one run is already in "Running" status, **When** the user clicks "New Import" and completes the wizard, **Then** a second run appears on the Runs page with its own independent "Running" status, and the first run is unaffected. +2. **Given** two runs are running simultaneously, **When** one reaches "Needs Review", **Then** only that run's status changes; the other remains "Running". + +--- + +### Edge Cases + +- What happens if the user clears their browser data (localStorage wiped)? Runs list becomes empty with no way to recover in-progress runs. +- What happens if the backend run ID no longer exists when the Runs page tries to fetch its status? Show the run as "Unknown / Expired" rather than crashing. +- What happens if the user opens the app in two browser tabs simultaneously? Both tabs read from the same localStorage, but polling in both tabs is acceptable — no write conflict since runs are only added (not mutated) in localStorage. +- What happens if an import is started on one device and the user opens the app on another? The run will not appear (localStorage is device-specific). This is a known limitation. +- What happens if the Review screen is opened for a run whose suspend payload is very large? The payload is stored in localStorage alongside the run metadata; if localStorage quota is exceeded, the app must gracefully handle the write failure. + +--- + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The app MUST display the Runs page as the default home screen when opened. +- **FR-002**: The Runs page MUST list all tracked import runs, sorted by start time descending (most recent first). +- **FR-003**: Each run entry MUST display: document title, content type names, start timestamp, and status badge. +- **FR-004**: The Runs page MUST provide a "New Import" action that opens the import wizard. +- **FR-005**: When the wizard's final step is completed, the app MUST immediately create a run record in persistent local storage and redirect to the Runs page, without waiting for the AI analysis to complete. +- **FR-006**: Run records in local storage MUST contain at minimum: run ID, document title, document ID, selected content type IDs, and start timestamp. +- **FR-007**: The Runs page MUST fetch live status from the backend for each stored run ID on load. +- **FR-008**: When any run is in "Running" status, the Runs page MUST automatically refresh run statuses every 10 seconds. +- **FR-009**: A run in "Needs Review" status MUST show a "Review" action that opens the mapping review screen for that run. +- **FR-010**: The mapping review screen MUST be loadable from the Runs page (not only from the wizard flow). +- **FR-011**: After the user approves mappings and entries are created, the run status MUST update to "Completed" and the run entry MUST display links to the created Contentful entries. +- **FR-012**: A run in "Failed" status MUST display a human-readable error summary. +- **FR-013**: The user MUST be able to dismiss/remove a "Failed" run from the list. +- **FR-014**: Multiple runs MUST be trackable simultaneously with independent statuses. +- **FR-015**: If a run ID stored locally is no longer resolvable from the backend, the run MUST display an "Expired" or "Unknown" status rather than causing an error. +- **FR-016**: If local storage write fails (e.g., quota exceeded), the app MUST inform the user that run tracking is unavailable and the import cannot proceed, rather than silently losing the run record. + +### Key Entities + +- **Run Record**: Represents a single import attempt. Key attributes: unique run ID (from backend), document title, document ID, selected content type IDs (array), start timestamp, status (Running / Needs Review / Completed / Failed / Expired). Stored in local browser storage. Status is always fetched live from the backend — not persisted locally. +- **Suspend Payload**: The AI-generated field mapping proposal for a run in "Needs Review" state. Fetched from the backend run data when the Review screen is opened. Not stored in local storage (to avoid quota issues). +- **Created Entries Summary**: The list of Contentful entry IDs created after a successful review. Stored alongside the run record in local storage upon completion so the Runs page can show links without a backend call. + +--- + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: After completing the import wizard, the user is on the Runs page within 2 seconds — no waiting on AI analysis. +- **SC-002**: A user who navigates away from the app and returns finds all their previous runs still visible with up-to-date statuses. +- **SC-003**: Run status on the Runs page reflects actual backend state within 10 seconds of a status change. +- **SC-004**: A user can start, track, review, and complete an import without ever being blocked by a loading screen lasting more than 5 seconds (excluding the review screen's entry-creation step, which is bounded by Contentful API response times). +- **SC-005**: A user can have at least 20 run records tracked simultaneously without degraded performance on the Runs page. +- **SC-006**: 100% of runs started through the wizard appear on the Runs page immediately after wizard completion, with no data loss on navigation or refresh. + +--- + +## Assumptions + +- The Contentful `sdk.cma.agentRun.get()` API is sufficient to retrieve run status and suspend payload for any run ID. No new backend functions are required. +- The Contentful `sdk.cma.agentRun.resumeRun()` API continues to work as today for the review → entry creation step. +- `localStorage` is available and has sufficient quota for the expected volume of run records (metadata only; suspend payloads are not stored locally). +- Run records are user- and device-specific; cross-device sync is explicitly out of scope. +- The app continues to use the Contentful App Framework single-page location model; a lightweight in-app router (view state machine) will replace the current binary main/review toggle — no third-party routing library is required. +- The OAuth connect/disconnect flow is unaffected by this rearchitecture. +- The import wizard steps (file picker, content type picker, tabs, images) are unchanged; only the exit behavior changes. diff --git a/apps/drive-integration/.specify/features/001-async-import-runs/tasks.md b/apps/drive-integration/.specify/features/001-async-import-runs/tasks.md new file mode 100644 index 0000000000..771942398e --- /dev/null +++ b/apps/drive-integration/.specify/features/001-async-import-runs/tasks.md @@ -0,0 +1,275 @@ +# Tasks: Async Import Runs + +**Input**: Design documents from `.specify/features/001-async-import-runs/` +**Prerequisites**: plan.md ✅ spec.md ✅ data-model.md ✅ contracts/ui-contracts.md ✅ research.md ✅ + +**Approach**: Red/Green TDD per project CLAUDE.md — write failing tests first, then implement. + +**Organization**: Tasks grouped by user story. Foundation phase (Slices 1–2) blocks all user stories. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies on incomplete tasks) +- **[Story]**: Which user story this task belongs to (US1–US5) + +--- + +## Phase 1: Setup (Scaffolding) + +**Purpose**: Create all new files and directories so parallel work can begin. + +- [ ] T001 Create `src/types/runs.ts` (empty exports as scaffold) +- [ ] T002 Create `src/hooks/useRunStorage.ts` (empty export as scaffold) +- [ ] T003 [P] Create `src/hooks/useRunsPolling.ts` (empty export as scaffold) +- [ ] T004 [P] Create `src/services/workflowService.ts` (empty export as scaffold) +- [ ] T005 [P] Create `src/locations/Page/components/runs/` directory with empty `RunsPage.tsx` and `RunRow.tsx` +- [ ] T006 [P] Create `test/hooks/` directory +- [ ] T007 [P] Create `test/locations/Page/components/runs/` directory + +**Checkpoint**: Directory structure exists — parallel foundation work can begin + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Core data layer, storage, polling, and service extraction that ALL user stories depend on. No user story can be worked until this phase is complete. + +**⚠️ CRITICAL**: Phases 3–7 are blocked until this phase is complete. + +### Types + +- [ ] T008 [P] Define `RunRecord`, `DisplayStatus`, `RunWithStatus`, `AppView` types in `src/types/runs.ts` per data-model.md + +### `useRunStorage` — TDD + +- [ ] T009 [P] Write failing tests for `useRunStorage` in `test/hooks/useRunStorage.test.ts`: addRun persists + updates state, addRun idempotent on duplicate runId, addRun prunes to 50 records, removeRun removes correct record, markCompleted writes createdEntryIds, initializes from existing localStorage on mount, sets storageError on localStorage exception, key scoped by spaceId+environmentId +- [ ] T010 Implement `useRunStorage(spaceId, environmentId)` in `src/hooks/useRunStorage.ts` — localStorage key `gdrive-import-runs::${spaceId}::${environmentId}`, RunRecord[] state, addRun/removeRun/markCompleted/storageError per contracts/ui-contracts.md (depends on T008, T009) + +### `workflowService.ts` — TDD + +- [ ] T011 [P] Write failing tests for `resumeAndPollWorkflow` in `test/services/workflowService.test.ts`: calls resumeWorkflowRun with correct args, calls pollAgentRun after resume, returns WorkflowRunResult on success, throws WorkflowRunError on failure +- [ ] T012 Extract `resumeAndPollWorkflow(sdk, runId, resumePayload)` into `src/services/workflowService.ts` — move pollAgentRun, getWorkflowRunResult, getRunStatus logic from `src/hooks/useWorkflowAgent.ts` into this file; re-export from useWorkflowAgent for backward compat during transition (depends on T011) + +### `useRunsPolling` — TDD + +- [ ] T013 [P] Write failing tests for `useRunsPolling` in `test/hooks/useRunsPolling.test.ts`: maps IN_PROGRESS→'running', PENDING_REVIEW→'needs-review', COMPLETED→'completed', FAILED→'failed', null(404)→'expired'; initializes all runIds as 'loading'; sets up 10s polling interval when any run is 'running'; clears interval when all runs settled; fetches all runs in parallel +- [ ] T014 Implement `useRunsPolling(runs, sdk)` in `src/hooks/useRunsPolling.ts` — parallel Promise.all fetch via getWorkflowRun(), Map state, 10s interval while any 'running', stops when all settled; also returns errorMap: Map for failed run error messages (depends on T008, T013) + +### `useWorkflowAgent` refactor — TDD + +- [ ] T015 [P] Write failing tests for refactored `useWorkflowAgent` in `test/hooks/useWorkflowAgent.test.ts` (or update existing): startWorkflow calls startAgentRun and returns runId string only; startWorkflow does NOT call pollAgentRun; isAnalyzing is true during startWorkflow and false after +- [ ] T016 Refactor `src/hooks/useWorkflowAgent.ts`: change startWorkflow to return `Promise` (runId only, no poll); remove resumeWorkflow from hook (it now lives in workflowService.ts); keep isAnalyzing (depends on T012, T015) + +**Checkpoint**: Foundation complete — storage, polling, service extraction, and workflow hook refactor all tested and passing. User story phases can now begin. + +--- + +## Phase 3: User Story 1 — Start Import and See It Tracked (Priority: P1) 🎯 MVP + +**Goal**: Wizard fires off the agent run, saves to localStorage, redirects to Runs page immediately. No more 20-minute blocking spinner. + +**Independent Test**: Complete the wizard (mock agentRun start), verify a RunRecord appears in localStorage with correct fields, and verify the `onRunStarted` callback fires with the correct runId — no polling or UI rendering required. + +### ModalOrchestrator refactor — TDD + +- [ ] T017 [P] [US1] Write failing tests for refactored ModalOrchestrator in `test/locations/Page/components/mainpage/ModalOrchestrator.spec.tsx`: on wizard completion calls addRun with correct RunRecord fields (runId, documentTitle, documentId, contentTypeIds, startedAt); calls onRunStarted(runId); does NOT call onMappingReviewReady (removed); shows loading spinner during startWorkflow; shows error modal on startWorkflow failure; shows error note when storageError is set before proceeding +- [ ] T018 [US1] Refactor `src/locations/Page/components/mainpage/ModalOrchestrator.tsx`: add `onRunStarted: (runId: string) => void` prop; remove `onMappingReviewReady` prop; add `documentTitle?: string` prop (thread from Google Picker result); integrate `useRunStorage` (inject spaceId/environmentId from sdk.ids); refactor `startWorkflowWithScope` to fire-and-forget (call startWorkflow → addRun → onRunStarted); remove handleWorkflowResult and PENDING_REVIEW branching; remove dead activeRunId state (depends on T010, T016, T017) + +**Checkpoint**: Wizard completes async. RunRecord written to localStorage. onRunStarted fires. User Story 1 independently verifiable. + +--- + +## Phase 4: User Story 2 — Runs Page Home View (Priority: P1) + +**Goal**: Runs page is the new home screen showing all imports with live status, a "New Import" button, and 10s auto-refresh while any run is in-flight. + +**Independent Test**: Seed localStorage with 3 RunRecords (statuses: running, needs-review, completed), render RunsPage, verify all 3 rows appear with correct badges and actions, verify "New Import" button calls onNewImport, verify polling interval is active. + +### RunRow component — TDD + +- [ ] T019 [P] [US2] Write failing tests for `RunRow` in `test/locations/Page/components/runs/RunRow.spec.tsx`: renders document title; renders correct F36 Badge per DisplayStatus (loading=spinner, running=primary+spinner, needs-review=warning, completed=positive, failed=negative, expired=secondary); "Review" button present for needs-review, calls onReview(runId); "Dismiss" button present for failed/expired, calls onDismiss(runId); entry links rendered for completed with correct Contentful web app URLs; error message rendered for failed +- [ ] T020 [P] [US2] Implement `src/locations/Page/components/runs/RunRow.tsx`: RunWithStatus + onReview + onDismiss props per contracts/ui-contracts.md; F36 Badge, Button, TextLink, Note components; entry links as `https://app.contentful.com/spaces/${spaceId}/entries/${entryId}`; format startedAt as relative time (<24h) or absolute date (depends on T008, T019) + +### RunsPage component — TDD + +- [ ] T021 [P] [US2] Write failing tests for `RunsPage` in `test/locations/Page/components/runs/RunsPage.spec.tsx`: renders empty state when no runs with "Start your first import" CTA; renders RunRow per run record; "New Import" button always visible calls onNewImport; clicking Review on needs-review run calls onReviewRun(runId); clicking Dismiss on failed run calls removeRun and row disappears; storageError Note shown when storageError set; polling hook active when running run exists +- [ ] T022 [US2] Implement `src/locations/Page/components/runs/RunsPage.tsx`: call useRunStorage + useRunsPolling; merge into RunWithStatus[] for rendering; handle removeRun on dismiss; empty state with F36 EmptyState; "Import Runs" heading + "New Import" Button always visible; storageError Note (depends on T010, T014, T020, T021) + +**Checkpoint**: Runs page fully functional — shows all runs, live status, New Import button, dismiss. User Stories 1 + 2 independently verifiable. + +--- + +## Phase 5: User Story 3 — Review a Completed AI Analysis (Priority: P2) + +**Goal**: "Review" button on a needs-review run opens the mapping review screen. Entry creation updates the run to Completed with entry links. + +**Independent Test**: Seed a RunRecord with needs-review status; click Review; verify ReviewPage loads with correct payload (fetched via getWorkflowRun mock); approve mappings; verify onRunCompleted fires with entry IDs and run transitions to completed in localStorage. + +### ReviewPage update — TDD + +- [ ] T023 [P] [US3] Write failing tests for updated `ReviewPage` in `test/locations/Page/components/review/ReviewPage.spec.tsx`: onRunCompleted called with correct entry IDs after successful creation; resumeAndPollWorkflow called (not useWorkflowAgent hook) on "Create selected entries"; onRunCompleted fires before SummaryModal opens +- [ ] T024 [US3] Update `src/locations/Page/components/review/ReviewPage.tsx`: add `onRunCompleted: (entryIds: string[]) => void` prop; replace `useWorkflowAgent` stub instantiation with direct `resumeAndPollWorkflow` import from workflowService.ts; call `onRunCompleted(entries.map(e => e.sys.id))` inside handleCreateEntries after createEntriesFromPreviewPayload resolves (depends on T012, T023) + +### Page.tsx wiring — TDD + +- [ ] T025 [P] [US3] Write failing tests for updated `Page.tsx` in `test/locations/Page/Page.spec.tsx`: renders RunsPage by default on mount; onNewImport transitions to import view; onRunStarted transitions back to runs view; onReviewRun transitions to review view; onExitReview transitions back to runs view; onRunCompleted calls markCompleted with correct args; aiAccessDeniedMessage blocks all views; payload loading spinner shown while fetching suspendPayload for review view +- [ ] T026 [US3] Refactor `src/locations/Page/Page.tsx`: replace `mappingReviewState` toggle with `AppView` state machine (`{ view: 'runs' } | { view: 'import' } | { view: 'review', runId: string }`); remove `useWorkflowAgent` stub instantiation; add `useRunStorage`; add `pendingReviewPayload` state with fetch-on-review-nav via getWorkflowRun; implement handleRunStarted, handleReviewRun, handleExitReview, handleRunCompleted, handleCancelReview callbacks; render switch over AppView (depends on T010, T018, T022, T024, T025) + +**Checkpoint**: Full end-to-end async import flow works — start → runs page → review → create entries → completed. User Stories 1, 2, 3 all independently verifiable. + +--- + +## Phase 6: User Story 4 — Handle Failed Runs (Priority: P3) + +**Goal**: Failed runs show a human-readable error summary and can be dismissed from the list. + +**Independent Test**: Seed a RunRecord with FAILED backend status and a workflowFailure message; render RunsPage; verify "Failed" badge and error summary visible; click Dismiss; verify run removed from list and localStorage. + +> **Note**: RunRow already handles `failed` display status (built in Phase 4, T019–T020). This phase adds the error message extraction from `useRunsPolling`'s `errorMap` and wires it into `RunWithStatus`. + +- [ ] T027 [P] [US4] Write failing tests for error propagation: useRunsPolling returns error message from runData.metadata.workflowFailure for FAILED runs; RunRow renders workflowFailure message in collapsed Note for failed status; Dismiss removes the run and updates localStorage +- [ ] T028 [US4] Wire error messages in `src/hooks/useRunsPolling.ts`: extract `runData.metadata.workflowFailure` into `errorMap: Map`; pass human-readable failure reason string (map WorkflowFailureReason enum to user-facing strings) (depends on T014, T027) +- [ ] T029 [US4] Wire `errorMap` into RunsPage → RunWithStatus in `src/locations/Page/components/runs/RunsPage.tsx`: merge errorMap into RunWithStatus.errorMessage; confirm RunRow renders it (depends on T022, T028) + +**Checkpoint**: Failed runs show meaningful error messages. User Stories 1–4 independently verifiable. + +--- + +## Phase 7: User Story 5 — Multiple Concurrent Imports (Priority: P3) + +**Goal**: Multiple runs can be in-flight simultaneously with independent statuses. This emerges from the data model — no special orchestration needed. + +**Independent Test**: Call addRun twice with different runIds; render RunsPage; verify both rows appear with independent statuses; mock one transitioning to needs-review and verify the other is unaffected. + +> **Note**: Concurrency support is inherent in the array-based localStorage model (Slice 1) and the parallel polling in useRunsPolling (Slice 2). This phase is primarily integration verification and edge case hardening. + +- [ ] T030 [P] [US5] Write failing tests for concurrent run scenarios: two runs with different statuses render independently; one run transitioning status does not affect the other; addRun idempotency under rapid successive calls; polling fetches all runs in parallel (Promise.all fires all before any await) +- [ ] T031 [US5] Verify concurrent behavior in `src/hooks/useRunsPolling.ts` and `src/hooks/useRunStorage.ts` — fix any identified concurrency issues from T030; ensure statusMap updates are atomic (replace full map, not patch individual keys) (depends on T010, T014, T030) + +**Checkpoint**: All 5 user stories independently verifiable and working together. + +--- + +## Phase 8: Polish & Cross-Cutting Concerns + +**Purpose**: Dead code removal, smoke test, and final validation. + +- [ ] T032 [P] Remove dead code from `src/hooks/useWorkflowAgent.ts`: remove isAnalyzing if no longer consumed by any component; remove any remaining pollAgentRun references now fully in workflowService.ts +- [ ] T033 [P] Remove dead code from `src/locations/Page/components/mainpage/ModalOrchestrator.tsx`: confirm activeRunId state and onResetToMain prop are still needed or remove; clean up unused imports +- [ ] T034 Run full test suite (`npm test`) — all tests must pass with no regressions +- [ ] T035 Smoke test per CLAUDE.md sprite workflow: start import → verify redirect to Runs page with 'Running' status; wait for 'Needs Review'; click Review → verify payload loads; create entries → verify 'Completed' with entry links; start two concurrent imports → verify both appear independently; clear localStorage → verify empty state +- [ ] T036 [P] Verify TypeScript compiles with no errors (`npm run build` or `tsc --noEmit`) + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +``` +Phase 1 (Setup) → no dependencies +Phase 2 (Foundation) → depends on Phase 1 — BLOCKS all user story phases +Phase 3 (US1) → depends on Phase 2 (useRunStorage, useWorkflowAgent refactor) +Phase 4 (US2) → depends on Phase 2 (useRunStorage, useRunsPolling) +Phase 5 (US3) → depends on Phase 3 (ModalOrchestrator), Phase 4 (RunsPage) +Phase 6 (US4) → depends on Phase 4 (RunRow already built), Phase 2 (useRunsPolling errorMap) +Phase 7 (US5) → depends on Phase 2 (storage + polling foundation) +Phase 8 (Polish) → depends on all user story phases +``` + +### User Story Dependencies + +| Story | Depends On | Can Start After | +|---|---|---| +| US1 (P1) | Phase 2 complete | T016 merged | +| US2 (P1) | Phase 2 complete | T014 merged | +| US3 (P2) | US1 + US2 both complete | T022 + T018 merged | +| US4 (P3) | Phase 2 + US2 RunRow built | T022 merged | +| US5 (P3) | Phase 2 complete | T014 merged | + +### Within Each Phase + +- Tests (T-odd scaffold): write FIRST, ensure they FAIL before implementing +- T008 (types) must land before any hook/component that imports from `src/types/runs.ts` +- T012 (workflowService) must land before T016 (useWorkflowAgent refactor) and T024 (ReviewPage) +- T010 (useRunStorage) must land before T018 (ModalOrchestrator) and T022 (RunsPage) +- T014 (useRunsPolling) must land before T022 (RunsPage) + +### Parallel Opportunities + +**Phase 2 can parallelize**: +- T009 + T011 + T013 + T015 (all test-writing tasks) run fully in parallel +- T010 + T012 run in parallel (different files, T008 must land first) +- T014 runs in parallel with T016 (different files) + +**Phase 3 + Phase 4 can run in parallel** (different files, both only need Phase 2): +- Developer A: T017 → T018 (ModalOrchestrator) +- Developer B: T019 → T020 → T021 → T022 (RunRow + RunsPage) + +--- + +## Parallel Example: Foundation (Phase 2) + +``` +After T008 (types) lands: + +Parallel track A (storage): + T009 → T010 (useRunStorage test → impl) + +Parallel track B (service): + T011 → T012 (workflowService test → impl) + +Parallel track C (polling): + T013 → T014 (useRunsPolling test → impl) + +Parallel track D (workflow hook): + T015 → T016 (useWorkflowAgent test → refactor) +``` + +## Parallel Example: User Stories 1 + 2 (Phases 3 + 4) + +``` +After Phase 2 complete: + +Parallel track A (US1): + T017 → T018 (ModalOrchestrator tests → refactor) + +Parallel track B (US2): + T019 → T020 (RunRow tests → impl) + T021 → T022 (RunsPage tests → impl, depends on T020) +``` + +--- + +## Implementation Strategy + +### MVP (User Stories 1 + 2 only) + +1. Complete Phase 1: Setup +2. Complete Phase 2: Foundation — T008 → [T009, T011, T013, T015] → [T010, T012, T014, T016] +3. Complete Phase 3: US1 — T017 → T018 +4. Complete Phase 4: US2 — T019 → T020 → T021 → T022 +5. **STOP and VALIDATE**: Wizard exits async, Runs page shows live status +6. US3 wires it all together (Phase 5) + +### Incremental Delivery + +1. Phase 1+2 (Foundation) → core data layer ready +2. Phase 3 (US1) → async wizard exit +3. Phase 4 (US2) → Runs page home +4. Phase 5 (US3) → review flow reconnected +5. Phase 6 (US4) → failed run UX +6. Phase 7 (US5) → concurrency validated +7. Phase 8 (Polish) → cleanup + smoke test + +--- + +## Notes + +- [P] = different files, no dependency conflicts — safe to parallelize +- Story labels map to spec.md: US1=Story1, US2=Story2, US3=Story3, US4=Story4, US5=Story5 +- TDD: every implementation task has a paired test task that must be written and FAILING first +- `src/types/runs.ts` (T008) is a shared dependency — land it before any parallel foundation work +- Do not store `MappingReviewSuspendPayload` in localStorage — re-fetch from backend on Review nav (research.md Decision 3) +- Files explicitly NOT touched: `functions/`, `entryService.ts`, `referenceResolution.ts`, `richtext.ts`, `MappingView.tsx`, all modal step components, `agents-api.ts` +- Total tasks: **36** across 8 phases diff --git a/apps/drive-integration/src/fixtures/FixtureHarness.tsx b/apps/drive-integration/src/fixtures/FixtureHarness.tsx new file mode 100644 index 0000000000..79d30c0062 --- /dev/null +++ b/apps/drive-integration/src/fixtures/FixtureHarness.tsx @@ -0,0 +1,23 @@ +import { Layout } from '@contentful/f36-components'; +import type { PageAppSDK } from '@contentful/app-sdk'; +import { ReviewPage } from '../locations/Page/components/review/ReviewPage'; +import type { MappingReviewSuspendPayload } from '../types/workflow'; +import fixtureData from './googleDocsReview/fixture.json'; + +const stubSdk = { + locales: { default: 'en-US' }, +} as unknown as PageAppSDK; + +export const FixtureHarness = () => { + return ( + + {}} + onExitReview={() => {}} + /> + + ); +}; diff --git a/apps/drive-integration/src/fixtures/googleDocsReview/fixture.json b/apps/drive-integration/src/fixtures/googleDocsReview/fixture.json new file mode 100644 index 0000000000..42f4a1cca9 --- /dev/null +++ b/apps/drive-integration/src/fixtures/googleDocsReview/fixture.json @@ -0,0 +1,289 @@ +{ + "suspendStepId": "mapping-review", + "documentId": "fixture-doc-001", + "documentTitle": "Product Launch Blog Post", + "referenceGraph": { "edges": [], "creationOrder": [] }, + "validationFindings": [ + { + "code": "REQUIRED_FIELD_EMPTY", + "message": "The required field \"Title\" has no mapped content. This entry cannot be created until it is assigned.", + "severity": "block", + "entryIndex": 0, + "fieldId": "title" + }, + { + "code": "FIELD_TYPE_MISMATCH", + "message": "Field \"Body\" is a Rich Text field but the mapped content contains only plain text. Some formatting may be lost on publish.", + "severity": "warn", + "entryIndex": 0, + "fieldId": "body" + }, + { + "code": "CONTENT_TRUNCATED", + "message": "Mapped content for \"Summary\" exceeds the field's character limit (500). It will be truncated on entry creation.", + "severity": "warn", + "entryIndex": 0, + "fieldId": "summary" + }, + { + "code": "DUPLICATE_ENTRY", + "message": "An entry with the same content type and title already exists in this space. Creating this entry may result in a duplicate.", + "severity": "warn", + "entryIndex": 1 + } + ], + "normalizedDocument": { + "documentId": "fixture-doc-001", + "title": "Product Launch Blog Post", + "contentBlocks": [ + { + "id": "block-1", + "position": 0, + "type": "heading", + "headingLevel": 1, + "textRuns": [{ "text": "Introducing Our New Product" }], + "flattenedTextRuns": [{ "text": "Introducing Our New Product", "start": 0, "end": 27 }], + "designValueIds": [], + "imageIds": [] + }, + { + "id": "block-2", + "position": 1, + "type": "paragraph", + "textRuns": [ + { "text": "We are thrilled to announce the launch of " }, + { "text": "Contentful Studio", "styles": { "bold": true } }, + { "text": ", the next generation content platform. This release brings powerful new tools for content teams everywhere." } + ], + "flattenedTextRuns": [ + { "text": "We are thrilled to announce the launch of ", "start": 0, "end": 41 }, + { "text": "Contentful Studio", "start": 41, "end": 58, "styles": { "bold": true } }, + { "text": ", the next generation content platform. This release brings powerful new tools for content teams everywhere.", "start": 58, "end": 167 } + ], + "designValueIds": [], + "imageIds": [] + }, + { + "id": "block-3", + "position": 2, + "type": "paragraph", + "textRuns": [{ "text": "Available starting March 2025 for all Enterprise customers." }], + "flattenedTextRuns": [{ "text": "Available starting March 2025 for all Enterprise customers.", "start": 0, "end": 58 }], + "designValueIds": [], + "imageIds": [] + }, + { + "id": "block-4", + "position": 3, + "type": "heading", + "headingLevel": 2, + "textRuns": [{ "text": "Key Features" }], + "flattenedTextRuns": [{ "text": "Key Features", "start": 0, "end": 12 }], + "designValueIds": [], + "imageIds": [] + }, + { + "id": "block-5", + "position": 4, + "type": "listItem", + "bullet": { "nestingLevel": 0, "ordered": false }, + "textRuns": [{ "text": "Visual content editing with live preview" }], + "flattenedTextRuns": [{ "text": "Visual content editing with live preview", "start": 0, "end": 40 }], + "designValueIds": [], + "imageIds": [] + }, + { + "id": "block-6", + "position": 5, + "type": "listItem", + "bullet": { "nestingLevel": 0, "ordered": false }, + "textRuns": [{ "text": "AI-powered content suggestions" }], + "flattenedTextRuns": [{ "text": "AI-powered content suggestions", "start": 0, "end": 30 }], + "designValueIds": [], + "imageIds": [] + }, + { + "id": "block-7", + "position": 6, + "type": "listItem", + "bullet": { "nestingLevel": 0, "ordered": false }, + "textRuns": [{ "text": "One-click multi-channel publishing" }], + "flattenedTextRuns": [{ "text": "One-click multi-channel publishing", "start": 0, "end": 34 }], + "designValueIds": [], + "imageIds": [] + } + ], + "tables": [ + { + "id": "table-1", + "position": 7, + "headers": ["Plan", "Price", "Users"], + "designValueIds": [], + "imageIds": [], + "rows": [ + { + "id": "row-1", + "cells": [ + { + "id": "cell-1-1", + "parts": [{ "id": "part-1-1-1", "type": "text", "textRuns": [{ "text": "Starter" }], "flattenedTextRuns": [{ "text": "Starter", "start": 0, "end": 7 }] }] + }, + { + "id": "cell-1-2", + "parts": [{ "id": "part-1-2-1", "type": "text", "textRuns": [{ "text": "$99/mo" }], "flattenedTextRuns": [{ "text": "$99/mo", "start": 0, "end": 6 }] }] + }, + { + "id": "cell-1-3", + "parts": [{ "id": "part-1-3-1", "type": "text", "textRuns": [{ "text": "Up to 5" }], "flattenedTextRuns": [{ "text": "Up to 5", "start": 0, "end": 7 }] }] + } + ] + } + ] + } + ], + "images": [] + }, + "contentTypes": [ + { + "sys": { "id": "blogPost" }, + "name": "Blog Post", + "displayField": "title", + "fields": [ + { "id": "title", "name": "Title", "type": "Symbol", "required": true }, + { "id": "body", "name": "Body", "type": "RichText" }, + { "id": "summary", "name": "Summary", "type": "Text" } + ] + }, + { + "sys": { "id": "pricingTable" }, + "name": "Pricing Table", + "displayField": "planName", + "fields": [ + { "id": "planName", "name": "Plan Name", "type": "Symbol", "required": true }, + { "id": "price", "name": "Price", "type": "Symbol" }, + { "id": "userLimit", "name": "User Limit", "type": "Symbol" } + ] + } + ], + "entryBlockGraph": { + "excludedSourceRefs": [], + "entries": [ + { + "contentTypeId": "blogPost", + "tempId": "entry-blog-1", + "fieldMappings": [ + { + "fieldId": "body", + "fieldType": "RichText", + "confidence": 0.88, + "sourceRefs": [ + { + "type": "blockText", + "blockId": "block-2", + "start": 0, + "end": 167, + "flattenedRuns": [ + { "text": "We are thrilled to announce the launch of ", "start": 0, "end": 41 }, + { "text": "Contentful Studio", "start": 41, "end": 58, "styles": { "bold": true } }, + { "text": ", the next generation content platform. This release brings powerful new tools for content teams everywhere.", "start": 58, "end": 167 } + ] + }, + { + "type": "blockText", + "blockId": "block-5", + "start": 0, + "end": 40, + "flattenedRuns": [{ "text": "Visual content editing with live preview", "start": 0, "end": 40 }] + }, + { + "type": "blockText", + "blockId": "block-6", + "start": 0, + "end": 30, + "flattenedRuns": [{ "text": "AI-powered content suggestions", "start": 0, "end": 30 }] + }, + { + "type": "blockText", + "blockId": "block-7", + "start": 0, + "end": 34, + "flattenedRuns": [{ "text": "One-click multi-channel publishing", "start": 0, "end": 34 }] + } + ] + }, + { + "fieldId": "summary", + "fieldType": "Text", + "confidence": 0.80, + "sourceRefs": [ + { + "type": "blockText", + "blockId": "block-3", + "start": 0, + "end": 58, + "flattenedRuns": [{ "text": "Available starting March 2025 for all Enterprise customers.", "start": 0, "end": 58 }] + } + ] + } + ] + }, + { + "contentTypeId": "pricingTable", + "tempId": "entry-pricing-1", + "fieldMappings": [ + { + "fieldId": "planName", + "fieldType": "Symbol", + "confidence": 0.90, + "sourceRefs": [ + { + "type": "tableText", + "tableId": "table-1", + "rowId": "row-1", + "cellId": "cell-1-1", + "partId": "part-1-1-1", + "start": 0, + "end": 7, + "flattenedRuns": [{ "text": "Starter", "start": 0, "end": 7 }] + } + ] + }, + { + "fieldId": "price", + "fieldType": "Symbol", + "confidence": 0.90, + "sourceRefs": [ + { + "type": "tableText", + "tableId": "table-1", + "rowId": "row-1", + "cellId": "cell-1-2", + "partId": "part-1-2-1", + "start": 0, + "end": 6, + "flattenedRuns": [{ "text": "$99/mo", "start": 0, "end": 6 }] + } + ] + }, + { + "fieldId": "userLimit", + "fieldType": "Symbol", + "confidence": 0.90, + "sourceRefs": [ + { + "type": "tableText", + "tableId": "table-1", + "rowId": "row-1", + "cellId": "cell-1-3", + "partId": "part-1-3-1", + "start": 0, + "end": 7, + "flattenedRuns": [{ "text": "Up to 5", "start": 0, "end": 7 }] + } + ] + } + ] + } + ] + } +} diff --git a/apps/drive-integration/src/hooks/useRunStorage.ts b/apps/drive-integration/src/hooks/useRunStorage.ts new file mode 100644 index 0000000000..5207b5b91a --- /dev/null +++ b/apps/drive-integration/src/hooks/useRunStorage.ts @@ -0,0 +1,99 @@ +import { useState, useCallback } from 'react'; +import type { RunRecord } from '../types/runs'; + +const MAX_RUNS = 50; + +function makeKey(spaceId: string, environmentId: string): string { + return `gdrive-import-runs::${spaceId}::${environmentId}`; +} + +function readFromStorage(key: string): RunRecord[] { + try { + const raw = localStorage.getItem(key); + if (!raw) return []; + return JSON.parse(raw) as RunRecord[]; + } catch { + return []; + } +} + +function writeToStorage(key: string, records: RunRecord[]): void { + localStorage.setItem(key, JSON.stringify(records)); +} + +export interface UseRunStorage { + runs: RunRecord[]; + addRun(record: RunRecord): void; + removeRun(runId: string): void; + markCompleted(runId: string, entryIds: string[]): void; + storageError: string | null; +} + +export function useRunStorage(spaceId: string, environmentId: string): UseRunStorage { + const key = makeKey(spaceId, environmentId); + const [runs, setRuns] = useState(() => readFromStorage(key)); + const [storageError, setStorageError] = useState(null); + + const persist = useCallback( + (next: RunRecord[]) => { + try { + writeToStorage(key, next); + setStorageError(null); + } catch (err) { + const msg = + err instanceof Error ? err.message : 'Unable to save import history. Storage may be full.'; + setStorageError(msg); + } + setRuns(next); + }, + [key] + ); + + const addRun = useCallback( + (record: RunRecord) => { + setRuns((current) => { + if (current.some((r) => r.runId === record.runId)) return current; + const next = [record, ...current]; + if (next.length > MAX_RUNS) next.splice(MAX_RUNS); + try { + writeToStorage(key, next); + setStorageError(null); + } catch (err) { + const msg = + err instanceof Error + ? err.message + : 'Unable to save import history. Storage may be full.'; + setStorageError(msg); + } + return next; + }); + }, + [key] + ); + + const removeRun = useCallback( + (runId: string) => { + setRuns((current) => { + const next = current.filter((r) => r.runId !== runId); + persist(next); + return next; + }); + }, + [persist] + ); + + const markCompleted = useCallback( + (runId: string, entryIds: string[]) => { + setRuns((current) => { + const next = current.map((r) => + r.runId === runId ? { ...r, createdEntryIds: entryIds } : r + ); + persist(next); + return next; + }); + }, + [persist] + ); + + return { runs, addRun, removeRun, markCompleted, storageError }; +} diff --git a/apps/drive-integration/src/hooks/useRunsPolling.ts b/apps/drive-integration/src/hooks/useRunsPolling.ts new file mode 100644 index 0000000000..6642b83697 --- /dev/null +++ b/apps/drive-integration/src/hooks/useRunsPolling.ts @@ -0,0 +1,109 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { PageAppSDK } from '@contentful/app-sdk'; +import { RunStatus } from '@types'; +import { getWorkflowRun, AgentRunData } from '../services/agents-api'; +import type { DisplayStatus, RunRecord } from '../types/runs'; + +const POLL_INTERVAL_MS = 10_000; + +function toDisplayStatus(runData: AgentRunData | null): DisplayStatus { + if (!runData) return 'expired'; + const status = runData.sys?.status ?? runData.metadata?.status; + switch (status) { + case RunStatus.IN_PROGRESS: + case RunStatus.DRAFT: + return 'running'; + case RunStatus.PENDING_REVIEW: + return 'needs-review'; + case RunStatus.COMPLETED: + return 'completed'; + case RunStatus.FAILED: + return 'failed'; + default: + return 'expired'; + } +} + +function extractErrorMessage(runData: AgentRunData | null): string | undefined { + return runData?.metadata?.workflowFailure?.message ?? undefined; +} + +export interface UseRunsPollingResult { + statusMap: Map; + errorMap: Map; +} + +export function useRunsPolling(runs: RunRecord[], sdk: PageAppSDK): UseRunsPollingResult { + const [statusMap, setStatusMap] = useState>( + () => new Map(runs.map((r) => [r.runId, 'loading' as DisplayStatus])) + ); + const [errorMap, setErrorMap] = useState>(new Map()); + const intervalRef = useRef | null>(null); + + const fetchAllStatuses = useCallback(async () => { + if (runs.length === 0) return; + + const spaceId = sdk.ids.space; + const environmentId = sdk.ids.environmentAlias ?? sdk.ids.environment; + + const results = await Promise.all( + runs.map((r) => getWorkflowRun(sdk, spaceId, environmentId, r.runId)) + ); + + const nextStatus = new Map(); + const nextErrors = new Map(); + + for (let i = 0; i < runs.length; i++) { + const runId = runs[i].runId; + const data = results[i]; + nextStatus.set(runId, toDisplayStatus(data)); + const errMsg = extractErrorMessage(data); + if (errMsg) nextErrors.set(runId, errMsg); + } + + setStatusMap(nextStatus); + setErrorMap(nextErrors); + return nextStatus; + }, [runs, sdk]); + + // Initial fetch + reactive refetch when run list changes + useEffect(() => { + // Reset new runs to 'loading' + setStatusMap((prev) => { + const next = new Map(prev); + for (const r of runs) { + if (!next.has(r.runId)) next.set(r.runId, 'loading'); + } + return next; + }); + + void fetchAllStatuses().then((nextStatus) => { + if (!nextStatus) return; + const hasRunning = [...nextStatus.values()].some((s) => s === 'running'); + + if (intervalRef.current) clearInterval(intervalRef.current); + + if (hasRunning) { + intervalRef.current = setInterval(() => { + void fetchAllStatuses().then((updated) => { + if (!updated) return; + const stillRunning = [...updated.values()].some((s) => s === 'running'); + if (!stillRunning && intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }); + }, POLL_INTERVAL_MS); + } + }); + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }; + }, [fetchAllStatuses]); + + return { statusMap, errorMap }; +} diff --git a/apps/drive-integration/src/hooks/useWorkflowAgent.ts b/apps/drive-integration/src/hooks/useWorkflowAgent.ts index 36cca69458..c4fd945b10 100644 --- a/apps/drive-integration/src/hooks/useWorkflowAgent.ts +++ b/apps/drive-integration/src/hooks/useWorkflowAgent.ts @@ -1,32 +1,8 @@ import { useState, useCallback } from 'react'; import { PageAppSDK } from '@contentful/app-sdk'; -import { - POLL_INTERVAL_MS, - MAX_POLL_ATTEMPTS, - EXTENDED_MAX_POLL_ATTEMPTS, - WORKFLOW_AGENT_ID, - MAX_PENDING_REVIEW_MISSING_PAYLOAD_RETRIES, -} from '../utils/constants/agent'; -import { useGoogleDocsAgentFlags } from './useGoogleDocsAgentFlags'; -import { - MappingReviewSuspendPayload, - ResumePayload, - CompletedWorkflowPayload, - WorkflowRunResult, - RunStatus, - WorkflowFailureReason, - WorkflowRunError, -} from '@types'; -import { - AgentGeneratePayload, - AgentRunData, - DocumentSelection, - getWorkflowRun, - resumeWorkflowRun, - startAgentRun, -} from '../services/agents-api'; -import { validatePayloadShape } from '../utils/createEntries'; -import { ERROR_MESSAGES } from '@constants/messages'; +import { WORKFLOW_AGENT_ID } from '../utils/constants/agent'; +import { WorkflowFailureReason } from '@types'; +import { AgentGeneratePayload, DocumentSelection, startAgentRun } from '../services/agents-api'; interface UseWorkflowParams { sdk: PageAppSDK; @@ -39,204 +15,18 @@ interface WorkflowHook { startWorkflow: ( contentTypeIds: string[], documentSelection: DocumentSelection - ) => Promise; - resumeWorkflow: (runId: string, resumePayload: ResumePayload) => Promise; + ) => Promise; } -const wait = async (ms: number): Promise => { - await new Promise((resolve) => setTimeout(resolve, ms)); -}; - -const getRunStatus = (runData: AgentRunData): RunStatus | null => { - return runData.sys?.status ?? runData.metadata?.status ?? null; -}; - -const getAgentPayload = (runData: AgentRunData): string | null => { - if (runData.payload && typeof runData.payload === 'string') { - return runData.payload; - } - - if (!runData.messages || !Array.isArray(runData.messages)) { - return null; - } - - const assistantMessage = runData.messages.find((message) => message.role === 'assistant'); - if (!assistantMessage?.content?.parts) { - return null; - } - - const textPart = assistantMessage.content.parts.find((part) => part.type === 'text' && part.text); - return textPart?.text || null; -}; - -const previewPayloadFromCompletedRun = (runData: AgentRunData): CompletedWorkflowPayload => { - const googleDocPayload = runData.metadata?.googleDocPayload; - if (googleDocPayload == null) { - throw new Error('Workflow completed but result payload was missing.'); - } - - if ( - typeof googleDocPayload === 'object' && - googleDocPayload !== null && - 'cancelled' in googleDocPayload && - (googleDocPayload as { cancelled?: unknown }).cancelled === true - ) { - // Cancelled runs complete without full preview payload; return a no-op preview shape. - return { - entries: [], - assets: [], - referenceGraph: {}, - }; - } - - return validatePayloadShape(googleDocPayload); -}; - -const getRunErrorMessage = (runData: AgentRunData): string => { - const workflowFailureMessage = runData.metadata?.workflowFailure?.message; - if (typeof workflowFailureMessage === 'string' && workflowFailureMessage.trim().length > 0) { - return workflowFailureMessage; - } - - const payload = getAgentPayload(runData); - if (payload) { - return payload; - } - - return 'Workflow failed'; -}; - -const KNOWN_FAILURE_REASONS = new Set(Object.values(WorkflowFailureReason)); - -const getBackendWorkflowFailureReason = (runData: AgentRunData): WorkflowFailureReason | null => { - const workflowFailure = runData.metadata?.workflowFailure; - if (!workflowFailure) return null; - return KNOWN_FAILURE_REASONS.has(workflowFailure.code) - ? (workflowFailure.code as WorkflowFailureReason) - : null; -}; - -// document-too-complex and out-of-domain are not yet emitted by the backend; handlers are in place for when they ship. -const FAILURE_REASON_MESSAGES: Partial> = { - [WorkflowFailureReason.GOOGLE_DRIVE_AUTH_EXPIRED]: ERROR_MESSAGES.GOOGLE_DRIVE_AUTH_ERROR, - [WorkflowFailureReason.GOOGLE_DOCS_NOT_FOUND]: ERROR_MESSAGES.GOOGLE_DOCS_NOT_FOUND, - [WorkflowFailureReason.AI_SERVICE_UNAVAILABLE]: ERROR_MESSAGES.AI_SERVICE_UNAVAILABLE, - [WorkflowFailureReason.APP_NOT_INSTALLED]: ERROR_MESSAGES.APP_NOT_INSTALLED, - [WorkflowFailureReason.DOCUMENT_TOO_COMPLEX]: ERROR_MESSAGES.DOCUMENT_TOO_COMPLEX, - [WorkflowFailureReason.PROCESSING_TIMEOUT]: ERROR_MESSAGES.PROCESSING_TIMEOUT, - [WorkflowFailureReason.OUT_OF_DOMAIN]: ERROR_MESSAGES.OUT_OF_DOMAIN, -}; - -const getWorkflowFailureMessage = ( - runData: AgentRunData, - failureReason: WorkflowFailureReason -): string => FAILURE_REASON_MESSAGES[failureReason] ?? getRunErrorMessage(runData); - -const getSuspendPayload = (runData: AgentRunData): MappingReviewSuspendPayload | undefined => { - return runData.metadata?.suspendPayload; -}; - -const getWorkflowRunResult = ( - runData: AgentRunData, - threadId: string, - pendingReviewMissingPayloadCount: number -): WorkflowRunResult | null => { - const status = getRunStatus(runData); - - switch (status) { - case RunStatus.FAILED: { - const failureReason = - getBackendWorkflowFailureReason(runData) ?? WorkflowFailureReason.GENERIC; - throw new WorkflowRunError(getWorkflowFailureMessage(runData, failureReason), failureReason); - } - - case RunStatus.PENDING_REVIEW: { - const suspendPayload = getSuspendPayload(runData); - if (!suspendPayload) { - if (pendingReviewMissingPayloadCount < MAX_PENDING_REVIEW_MISSING_PAYLOAD_RETRIES) { - return null; // suspendPayload not flushed yet; poller will retry - } - throw new Error('Workflow paused for review, but suspend payload was missing.'); - } - - return { - status, - runId: threadId, - suspendPayload, - messages: runData.messages ?? [], - }; - } - - case RunStatus.COMPLETED: { - const messages = runData.messages ?? []; - - return { - status, - runId: threadId, - messages, - googleDocPayload: previewPayloadFromCompletedRun(runData), - }; - } - - default: - return null; - } -}; - -const elapsedSec = (startMs: number) => `${((Date.now() - startMs) / 1000).toFixed(1)}s`; - -const pollAgentRun = async ( - sdk: PageAppSDK, - spaceId: string, - environmentId: string, - runId: string, - maxAttempts: number -): Promise => { - const startMs = Date.now(); - let pendingReviewMissingPayloadCount = 0; - - for (let attempt = 0; attempt < maxAttempts; attempt++) { - const runData = await getWorkflowRun(sdk, spaceId, environmentId, runId); - - if (!runData) { - await wait(POLL_INTERVAL_MS); - continue; - } - - const status = getRunStatus(runData); - - if (status === RunStatus.PENDING_REVIEW && !getSuspendPayload(runData)) { - pendingReviewMissingPayloadCount++; - } else { - pendingReviewMissingPayloadCount = 0; - } - - const workflowRun = getWorkflowRunResult(runData, runId, pendingReviewMissingPayloadCount); - if (workflowRun) { - return workflowRun; - } - - await wait(POLL_INTERVAL_MS); - } - - console.error(`✗ Run [${runId}] timed out after ${elapsedSec(startMs)}`); - throw new WorkflowRunError( - ERROR_MESSAGES.PROCESSING_TIMEOUT, - WorkflowFailureReason.PROCESSING_TIMEOUT - ); -}; - export const useWorkflowAgent = ({ sdk, documentId, oauthToken, }: UseWorkflowParams): WorkflowHook => { const [isAnalyzing, setIsAnalyzing] = useState(false); - const { 'google-docs-agent-improvements': extendedTimeout } = useGoogleDocsAgentFlags(); - const maxPollAttempts = extendedTimeout ? EXTENDED_MAX_POLL_ATTEMPTS : MAX_POLL_ATTEMPTS; const startWorkflow = useCallback( - async (contentTypeIds: string[], documentSelection: DocumentSelection) => { + async (contentTypeIds: string[], documentSelection: DocumentSelection): Promise => { setIsAnalyzing(true); const spaceId = sdk.ids.space; @@ -250,9 +40,7 @@ export const useWorkflowAgent = ({ parts: [ { type: 'text' as const, - text: `Analyze the following google docs document ${documentId} and extract the Contentful entries and assets for the following content types: ${contentTypeIds.join( - ', ' - )}`, + text: `Analyze the following google docs document ${documentId} and extract the Contentful entries and assets for the following content types: ${contentTypeIds.join(', ')}`, }, ], }, @@ -267,8 +55,7 @@ export const useWorkflowAgent = ({ }; try { - const runId = await startAgentRun(sdk, spaceId, environmentId, payload); - return await pollAgentRun(sdk, spaceId, environmentId, runId, maxPollAttempts); + return await startAgentRun(sdk, spaceId, environmentId, payload); } catch (err) { const error = err instanceof Error ? err : new Error('Workflow failed'); throw error; @@ -279,30 +66,8 @@ export const useWorkflowAgent = ({ [sdk, documentId, oauthToken] ); - const resumeWorkflow = useCallback( - async (runId: string, resumePayload: ResumePayload) => { - setIsAnalyzing(true); - - const spaceId = sdk.ids.space; - const environmentId = sdk.ids.environmentAlias ?? sdk.ids.environment; - - try { - await resumeWorkflowRun(sdk, spaceId, environmentId, runId, resumePayload); - return await pollAgentRun(sdk, spaceId, environmentId, runId, maxPollAttempts); - } catch (err) { - console.error(`✗ resumeWorkflow [${runId}] failed`, err); - const error = err instanceof Error ? err : new Error('Workflow failed'); - throw error; - } finally { - setIsAnalyzing(false); - } - }, - [sdk] - ); - - return { - isAnalyzing, - startWorkflow, - resumeWorkflow, - }; + return { isAnalyzing, startWorkflow }; }; + +// Re-export WorkflowFailureReason for backward compatibility with callers that imported it here +export { WorkflowFailureReason }; diff --git a/apps/drive-integration/src/index.tsx b/apps/drive-integration/src/index.tsx index 71cfdd9355..a2badedae1 100644 --- a/apps/drive-integration/src/index.tsx +++ b/apps/drive-integration/src/index.tsx @@ -4,6 +4,7 @@ import { withLDProvider } from 'launchdarkly-react-client-sdk'; import { createRoot } from 'react-dom/client'; import App from './App'; import LocalhostWarning from './locations/LocalhostWarning'; +import { FixtureHarness } from './fixtures/FixtureHarness'; const AppWithLD = withLDProvider({ clientSideID: import.meta.env.VITE_LD_CLIENT_ID ?? '', @@ -34,7 +35,9 @@ if (window.location.search.includes('code=') && window.location.search.includes( handleOAuthCallback(); } -if (process.env.NODE_ENV === 'development' && window.self === window.top) { +if (import.meta.env.VITE_ENABLE_MOCK_REVIEW_PAYLOAD === 'true') { + root.render(); +} else if (process.env.NODE_ENV === 'development' && window.self === window.top) { // You can remove this if block before deploying your app root.render(); } else { diff --git a/apps/drive-integration/src/locations/Page/Page.tsx b/apps/drive-integration/src/locations/Page/Page.tsx index a0d70fd6ca..c527c0ea19 100644 --- a/apps/drive-integration/src/locations/Page/Page.tsx +++ b/apps/drive-integration/src/locations/Page/Page.tsx @@ -1,33 +1,67 @@ -import { useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { PageAppSDK } from '@contentful/app-sdk'; import { useSDK } from '@contentful/react-apps-toolkit'; -import { Flex, Heading, Layout, Note } from '@contentful/f36-components'; +import { Flex, Layout, Note, Spinner } from '@contentful/f36-components'; import { ModalOrchestrator, ModalOrchestratorHandle, } from './components/mainpage/ModalOrchestrator'; import { MainPageView } from './components/mainpage/MainPageView'; import { ReviewPage } from './components/review/ReviewPage'; -import type { EntryBlockGraph, MappingReviewSuspendPayload } from '@types'; -import { useWorkflowAgent } from '@hooks/useWorkflowAgent'; +import { RunsPage } from './components/runs/RunsPage'; +import type { AppView, MappingReviewSuspendPayload } from '@types'; import { useGoogleDriveOAuth } from '@hooks/useGoogleDriveOAuth'; import { isAiAccessDeniedError } from '../../utils/aiAccess'; +import { resumeAndPollWorkflow } from '../../services/workflowService'; +import { useRunStorage } from '../../hooks/useRunStorage'; +import { getWorkflowRun } from '../../services/agents-api'; const Page = () => { const sdk = useSDK(); const modalOrchestratorRef = useRef(null); + const [aiAccessDeniedMessage, setAiAccessDeniedMessage] = useState(null); - const [mappingReviewState, setMappingReviewState] = useState<{ - payload: MappingReviewSuspendPayload; - runId?: string; - } | null>(null); + const [appView, setAppView] = useState({ view: 'runs' }); + const [pendingReviewPayload, setPendingReviewPayload] = + useState(null); + const [isLoadingReviewPayload, setIsLoadingReviewPayload] = useState(false); + + const spaceId = sdk.ids.space; + const environmentId = sdk.ids.environmentAlias ?? sdk.ids.environment; + + const { runs, addRun, removeRun, markCompleted, storageError } = useRunStorage(spaceId, environmentId); + const { oauthToken, isOAuthConnected, isOAuthLoading, isOAuthBusy, startOAuth, disconnectOAuth } = useGoogleDriveOAuth(sdk); - const { resumeWorkflow } = useWorkflowAgent({ - sdk, - documentId: '', - oauthToken: '', - }); + + // When navigating to the review view, fetch the suspend payload from the backend + useEffect(() => { + if (appView.view !== 'review') { + setPendingReviewPayload(null); + return; + } + + let isCancelled = false; + setIsLoadingReviewPayload(true); + + void getWorkflowRun(sdk, spaceId, environmentId, appView.runId) + .then((runData) => { + if (isCancelled) return; + const payload = runData?.metadata?.suspendPayload ?? null; + setPendingReviewPayload(payload); + }) + .catch(() => { + if (!isCancelled) setPendingReviewPayload(null); + }) + .finally(() => { + if (!isCancelled) setIsLoadingReviewPayload(false); + }); + + return () => { + isCancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- only re-fetch when the runId changes + }, [appView.view === 'review' ? appView.runId : null]); const handleSelectFile = () => { modalOrchestratorRef.current?.startFlow(); @@ -35,7 +69,6 @@ const Page = () => { const handleAiAccessDenied = (message: string) => { setAiAccessDeniedMessage(message); - setMappingReviewState(null); }; const handleAiAccessRestored = () => { @@ -44,32 +77,33 @@ const Page = () => { } }; - const handleMappingReviewReady = (payload: MappingReviewSuspendPayload, runId: string) => { - setMappingReviewState({ payload, runId }); + const handleRunStarted = (_runId: string) => { + setAppView({ view: 'runs' }); }; - const handleReturnToMainPage = () => { - setMappingReviewState(null); + const handleReviewRun = (runId: string) => { + setAppView({ view: 'review', runId }); }; - const resetFlowAndReturnToMainPage = () => { + const handleExitReview = () => { modalOrchestratorRef.current?.resetFlow(); - handleReturnToMainPage(); + setAppView({ view: 'runs' }); }; - const handleCancelMappingReview = async (graph: EntryBlockGraph) => { - if (!mappingReviewState?.runId) { - resetFlowAndReturnToMainPage(); - return; - } + const handleRunCompleted = (runId: string, entryIds: string[]) => { + markCompleted(runId, entryIds); + }; - try { - await resumeWorkflow(mappingReviewState.runId, { cancelled: true, entryBlockGraph: graph }); - } catch (error) { - console.error(error); - } finally { - resetFlowAndReturnToMainPage(); + const handleCancelReview = async (runId?: string) => { + if (runId) { + try { + await resumeAndPollWorkflow(sdk, runId, { cancelled: true }); + } catch (error) { + console.error(error); + } } + modalOrchestratorRef.current?.resetFlow(); + setAppView({ view: 'runs' }); }; const handleConnectGoogleDrive = async () => { @@ -101,7 +135,6 @@ const Page = () => { flexDirection="column" gap="spacingM" style={{ maxWidth: '900px', margin: '24px auto' }}> - Drive Integration {aiAccessDeniedMessage} @@ -109,30 +142,63 @@ const Page = () => { ); } - return ( - <> - - {mappingReviewState ? ( + const renderView = () => { + switch (appView.view) { + case 'runs': + return ( + setAppView({ view: 'import' })} + onReviewRun={handleReviewRun} + /> + ); + + case 'import': + return ( + + ); + + case 'review': { + if (isLoadingReviewPayload || !pendingReviewPayload) { + return ( + + + + ); + } + + return ( handleCancelReview(appView.runId)} + onExitReview={handleExitReview} + onRunCompleted={(entryIds) => handleRunCompleted(appView.runId, entryIds)} /> - ) : ( - <> - - - )} + ); + } + } + }; + + return ( + <> + + {renderView()} { isOAuthBusy={isOAuthBusy} onReconnectGoogleDrive={startOAuth} onAiAccessDenied={handleAiAccessDenied} - onMappingReviewReady={handleMappingReviewReady} - onResetToMain={handleReturnToMainPage} + onRunStarted={handleRunStarted} + onResetToMain={() => setAppView({ view: 'runs' })} + addRun={addRun} + storageError={storageError} /> ); diff --git a/apps/drive-integration/src/locations/Page/components/mainpage/ModalOrchestrator.tsx b/apps/drive-integration/src/locations/Page/components/mainpage/ModalOrchestrator.tsx index 436299e5b4..5e88378b02 100644 --- a/apps/drive-integration/src/locations/Page/components/mainpage/ModalOrchestrator.tsx +++ b/apps/drive-integration/src/locations/Page/components/mainpage/ModalOrchestrator.tsx @@ -10,9 +10,6 @@ import { ERROR_MESSAGES } from '@constants/messages'; import { SelectTabsModal } from '../modals/step_3/SelectTabsModal'; import { DocumentTabProps, - MappingReviewSuspendPayload, - RunStatus, - WorkflowRunResult, WorkflowFailureReason, WorkflowRunError, } from '@types'; @@ -25,6 +22,7 @@ import { DocumentSelectionConfig, } from '../../../../utils/fetchDocumentSelection'; import { isAiAccessDeniedError } from '../../../../utils/aiAccess'; +import type { RunRecord } from '../../../../types/runs'; export interface ModalOrchestratorHandle { startFlow: () => void; @@ -45,8 +43,10 @@ interface ModalOrchestratorProps { isOAuthBusy?: boolean; onReconnectGoogleDrive?: () => Promise; onAiAccessDenied?: (message: string) => void; - onMappingReviewReady: (payload: MappingReviewSuspendPayload, runId: string) => void; + onRunStarted: (runId: string) => void; onResetToMain: () => void; + addRun: (record: RunRecord) => void; + storageError: string | null; } interface PreviewErrorState { @@ -63,9 +63,11 @@ export const ModalOrchestrator = forwardRef undefined, - onMappingReviewReady, + onRunStarted, onResetToMain, onAiAccessDenied, + addRun, + storageError, }, ref ) => { @@ -75,12 +77,14 @@ export const ModalOrchestrator = forwardRef(null); const [documentId, setDocumentId] = useState(''); + const [documentTitle, setDocumentTitle] = useState('Untitled Document'); const [selectedContentTypes, setSelectedContentTypes] = useState([]); const [availableTabs, setAvailableTabs] = useState([]); const [selectedTabs, setSelectedTabs] = useState([]); const [useAllTabs, setUseAllTabs] = useState(null); const [includeImages, setIncludeImages] = useState(null); const [requiresImageSelection, setRequiresImageSelection] = useState(false); + const { startWorkflow } = useWorkflowAgent({ sdk, documentId, @@ -109,6 +113,7 @@ export const ModalOrchestrator = forwardRef { setDocumentId(''); + setDocumentTitle('Untitled Document'); setSelectedContentTypes([]); resetDocumentSelection(); setFlowStep(null); @@ -291,23 +296,39 @@ export const ModalOrchestrator = forwardRef { - if (workflowRun.status === RunStatus.PENDING_REVIEW) { - setFlowStep(null); - onMappingReviewReady(workflowRun.suspendPayload, workflowRun.runId); - return; - } - - setFlowStep(null); - }; - const startWorkflowWithScope = async ( contentTypeIds: string[], documentSelection: DocumentSelection ) => { setFlowStep(FlowStep.LOADING); - const result = await startWorkflow(contentTypeIds, documentSelection); - handleWorkflowResult(result); + try { + const runId = await startWorkflow(contentTypeIds, documentSelection); + + if (storageError) { + // Storage unavailable — surface error before proceeding + showWorkflowError( + new WorkflowRunError( + 'Unable to track this import: browser storage is unavailable or full.', + WorkflowFailureReason.GENERIC + ) + ); + return; + } + + addRun({ + runId, + documentTitle, + documentId, + contentTypeIds, + startedAt: new Date().toISOString(), + }); + + setFlowStep(null); + resetProgress(); + onRunStarted(runId); + } catch (err) { + handleWorkflowError(err); + } }; const handleContentTypeContinue = async (contentTypeIds: string[]) => { @@ -442,7 +463,7 @@ export const ModalOrchestrator = forwardRef ); diff --git a/apps/drive-integration/src/locations/Page/components/review/ReviewPage.tsx b/apps/drive-integration/src/locations/Page/components/review/ReviewPage.tsx index 337a7c62a9..e37ba48ef7 100644 --- a/apps/drive-integration/src/locations/Page/components/review/ReviewPage.tsx +++ b/apps/drive-integration/src/locations/Page/components/review/ReviewPage.tsx @@ -7,7 +7,7 @@ import { cx } from '@emotion/css'; import type { EntryProps } from 'contentful-management'; import type { EntryBlockGraph, MappingReviewSuspendPayload, ReviewedReferenceGraph } from '@types'; import { RunStatus } from '@types'; -import { useWorkflowAgent } from '@hooks/useWorkflowAgent'; +import { resumeAndPollWorkflow } from '../../../../services/workflowService'; import { createEntriesFromPreviewPayload } from '../../../../services/entryService'; import type { ContentTypeDisplayInfoMap } from '../../../../utils/overviewEntryList'; import { @@ -35,6 +35,7 @@ interface ReviewPageProps { runId?: string; onCancelReview: (graph: EntryBlockGraph) => Promise; onExitReview: () => void; + onRunCompleted?: (entryIds: string[]) => void; } export const ReviewPage = ({ @@ -43,6 +44,7 @@ export const ReviewPage = ({ runId, onCancelReview, onExitReview, + onRunCompleted, }: ReviewPageProps) => { const [isConfirmCancelModalOpen, setIsConfirmCancelModalOpen] = useState(false); const [selectedEntryIndex, setSelectedEntryIndex] = useState(null); @@ -94,8 +96,6 @@ export const ReviewPage = ({ ); const hasSelectedEntries = selectedEntryCount > 0; - const { resumeWorkflow } = useWorkflowAgent({ sdk, documentId: '', oauthToken: '' }); - const handleToggleEntrySelection = (entryKey: string, isSelected: boolean) => { setSelectedEntryKeys((previous) => { const next = new Set(previous); @@ -125,7 +125,7 @@ export const ReviewPage = ({ entryBlockGraph, selectedEntryKeys ); - const result = await resumeWorkflow(runId, { + const result = await resumeAndPollWorkflow(sdk, runId, { entryBlockGraph: selectedEntryBlockGraph, }); @@ -143,6 +143,8 @@ export const ReviewPage = ({ return; } + const entryIds = entries.map((e) => e.sys.id); + onRunCompleted?.(entryIds); setCreatedEntries(entries); setIsSummaryModalOpen(true); return; @@ -166,9 +168,9 @@ export const ReviewPage = ({ hasSelectedEntries, entryBlockGraph, selectedEntryKeys, - resumeWorkflow, sdk, onExitReview, + onRunCompleted, ]); const handleConfirmCancel = useCallback(async () => { diff --git a/apps/drive-integration/src/locations/Page/components/runs/RunRow.tsx b/apps/drive-integration/src/locations/Page/components/runs/RunRow.tsx new file mode 100644 index 0000000000..fc0b5bb7d6 --- /dev/null +++ b/apps/drive-integration/src/locations/Page/components/runs/RunRow.tsx @@ -0,0 +1,113 @@ +import { Badge, Box, Button, Flex, Spinner, Text, TextLink } from '@contentful/f36-components'; +import type { RunWithStatus } from '../../../../types/runs'; + +interface RunRowProps { + run: RunWithStatus; + spaceId: string; + onReview: (runId: string) => void; + onDismiss: (runId: string) => void; +} + +function formatDate(iso: string): string { + const date = new Date(iso); + const now = Date.now(); + const diffMs = now - date.getTime(); + const diffMinutes = Math.floor(diffMs / 60_000); + const diffHours = Math.floor(diffMs / 3_600_000); + const diffDays = Math.floor(diffMs / 86_400_000); + + if (diffMinutes < 1) return 'just now'; + if (diffMinutes < 60) return `${diffMinutes}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + return date.toLocaleDateString(); +} + +function StatusBadge({ status }: { status: RunWithStatus['displayStatus'] }) { + switch (status) { + case 'loading': + return ; + case 'running': + return ( + + Running + + + ); + case 'needs-review': + return Needs Review; + case 'completed': + return Completed; + case 'failed': + return Failed; + case 'expired': + return Expired; + } +} + +export function RunRow({ run, spaceId, onReview, onDismiss }: RunRowProps) { + const contentTypesLabel = + run.contentTypeIds.length <= 3 + ? run.contentTypeIds.join(', ') + : `${run.contentTypeIds.slice(0, 3).join(', ')} +${run.contentTypeIds.length - 3} more`; + + return ( + + + {/* Left: metadata */} + + + {run.documentTitle} + + + {contentTypesLabel} + + + {formatDate(run.startedAt)} + + + {/* Entry links for completed runs */} + {run.displayStatus === 'completed' && run.createdEntryIds && run.createdEntryIds.length > 0 && ( + + {run.createdEntryIds.map((entryId) => ( + + {entryId} + + ))} + + )} + + {/* Error message for failed runs */} + {run.displayStatus === 'failed' && run.errorMessage && ( + + {run.errorMessage} + + )} + + + {/* Right: status + actions */} + + + + {run.displayStatus === 'needs-review' && ( + + )} + + {(run.displayStatus === 'failed' || run.displayStatus === 'expired') && ( + + )} + + + + ); +} diff --git a/apps/drive-integration/src/locations/Page/components/runs/RunsPage.tsx b/apps/drive-integration/src/locations/Page/components/runs/RunsPage.tsx new file mode 100644 index 0000000000..2c4011d8f0 --- /dev/null +++ b/apps/drive-integration/src/locations/Page/components/runs/RunsPage.tsx @@ -0,0 +1,75 @@ +import { Box, Button, Flex, Heading, Note, Text } from '@contentful/f36-components'; +import { PageAppSDK } from '@contentful/app-sdk'; +import { useRunStorage } from '../../../../hooks/useRunStorage'; +import { useRunsPolling } from '../../../../hooks/useRunsPolling'; +import type { RunWithStatus } from '../../../../types/runs'; +import { RunRow } from './RunRow'; + +interface RunsPageProps { + sdk: PageAppSDK; + onNewImport: () => void; + onReviewRun: (runId: string) => void; +} + +export function RunsPage({ sdk, onNewImport, onReviewRun }: RunsPageProps) { + const spaceId = sdk.ids.space; + const environmentId = sdk.ids.environmentAlias ?? sdk.ids.environment; + + const { runs, removeRun, storageError } = useRunStorage(spaceId, environmentId); + const { statusMap, errorMap } = useRunsPolling(runs, sdk); + + const runsWithStatus: RunWithStatus[] = runs.map((r) => ({ + ...r, + displayStatus: statusMap.get(r.runId) ?? 'loading', + errorMessage: errorMap.get(r.runId), + })); + + return ( + + {/* Header */} + + Import Runs + + + + {/* Storage error */} + {storageError && ( + + {storageError} + + )} + + {/* Run list or empty state */} + {runsWithStatus.length === 0 ? ( + + No imports yet + + + ) : ( + runsWithStatus.map((run) => ( + + )) + )} + + ); +} diff --git a/apps/drive-integration/src/services/workflowService.ts b/apps/drive-integration/src/services/workflowService.ts new file mode 100644 index 0000000000..e81cef3048 --- /dev/null +++ b/apps/drive-integration/src/services/workflowService.ts @@ -0,0 +1,190 @@ +import { PageAppSDK } from '@contentful/app-sdk'; +import { + ResumePayload, + WorkflowRunResult, + RunStatus, + WorkflowFailureReason, + WorkflowRunError, + MappingReviewSuspendPayload, + CompletedWorkflowPayload, + AgentRunMessage, +} from '@types'; +import { AgentRunData, getWorkflowRun, resumeWorkflowRun } from './agents-api'; +import { validatePayloadShape } from '../utils/createEntries'; +import { ERROR_MESSAGES } from '@constants/messages'; +import { + POLL_INTERVAL_MS, + MAX_POLL_ATTEMPTS, + MAX_PENDING_REVIEW_MISSING_PAYLOAD_RETRIES, +} from '../utils/constants/agent'; + +// ─── Helpers (shared between workflowService and useWorkflowAgent) ─────────── + +const wait = async (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); + +export const getRunStatus = (runData: AgentRunData): RunStatus | null => + runData.sys?.status ?? runData.metadata?.status ?? null; + +const getAgentPayload = (runData: AgentRunData): string | null => { + if (runData.payload && typeof runData.payload === 'string') return runData.payload; + if (!runData.messages || !Array.isArray(runData.messages)) return null; + const assistantMessage = runData.messages.find((m) => m.role === 'assistant'); + if (!assistantMessage?.content?.parts) return null; + const textPart = assistantMessage.content.parts.find((p) => p.type === 'text' && p.text); + return textPart?.text ?? null; +}; + +const previewPayloadFromCompletedRun = (runData: AgentRunData): CompletedWorkflowPayload => { + const googleDocPayload = runData.metadata?.googleDocPayload; + if (googleDocPayload == null) throw new Error('Workflow completed but result payload was missing.'); + if ( + typeof googleDocPayload === 'object' && + googleDocPayload !== null && + 'cancelled' in googleDocPayload && + (googleDocPayload as { cancelled?: unknown }).cancelled === true + ) { + return { entries: [], assets: [], referenceGraph: {} }; + } + return validatePayloadShape(googleDocPayload); +}; + +const getRunErrorMessage = (runData: AgentRunData): string => { + const failureMessage = runData.metadata?.workflowFailure?.message; + if (typeof failureMessage === 'string' && failureMessage.trim().length > 0) return failureMessage; + const payload = getAgentPayload(runData); + if (payload) return payload; + return 'Workflow failed'; +}; + +const KNOWN_FAILURE_REASONS = new Set(Object.values(WorkflowFailureReason)); + +export const getBackendWorkflowFailureReason = ( + runData: AgentRunData +): WorkflowFailureReason | null => { + const workflowFailure = runData.metadata?.workflowFailure; + if (!workflowFailure) return null; + return KNOWN_FAILURE_REASONS.has(workflowFailure.code) + ? (workflowFailure.code as WorkflowFailureReason) + : null; +}; + +const FAILURE_REASON_MESSAGES: Partial> = { + [WorkflowFailureReason.GOOGLE_DRIVE_AUTH_EXPIRED]: ERROR_MESSAGES.GOOGLE_DRIVE_AUTH_ERROR, + [WorkflowFailureReason.GOOGLE_DOCS_NOT_FOUND]: ERROR_MESSAGES.GOOGLE_DOCS_NOT_FOUND, + [WorkflowFailureReason.AI_SERVICE_UNAVAILABLE]: ERROR_MESSAGES.AI_SERVICE_UNAVAILABLE, + [WorkflowFailureReason.APP_NOT_INSTALLED]: ERROR_MESSAGES.APP_NOT_INSTALLED, + [WorkflowFailureReason.DOCUMENT_TOO_COMPLEX]: ERROR_MESSAGES.DOCUMENT_TOO_COMPLEX, + [WorkflowFailureReason.PROCESSING_TIMEOUT]: ERROR_MESSAGES.PROCESSING_TIMEOUT, + [WorkflowFailureReason.OUT_OF_DOMAIN]: ERROR_MESSAGES.OUT_OF_DOMAIN, +}; + +const getWorkflowFailureMessage = ( + runData: AgentRunData, + failureReason: WorkflowFailureReason +): string => FAILURE_REASON_MESSAGES[failureReason] ?? getRunErrorMessage(runData); + +export const getSuspendPayload = ( + runData: AgentRunData +): MappingReviewSuspendPayload | undefined => runData.metadata?.suspendPayload; + +export const getWorkflowRunResult = ( + runData: AgentRunData, + runId: string, + pendingReviewMissingPayloadCount: number +): WorkflowRunResult | null => { + const status = getRunStatus(runData); + + switch (status) { + case RunStatus.FAILED: { + const failureReason = + getBackendWorkflowFailureReason(runData) ?? WorkflowFailureReason.GENERIC; + throw new WorkflowRunError(getWorkflowFailureMessage(runData, failureReason), failureReason); + } + + case RunStatus.PENDING_REVIEW: { + const suspendPayload = getSuspendPayload(runData); + if (!suspendPayload) { + if (pendingReviewMissingPayloadCount < MAX_PENDING_REVIEW_MISSING_PAYLOAD_RETRIES) { + return null; + } + throw new Error('Workflow paused for review, but suspend payload was missing.'); + } + return { + status, + runId, + suspendPayload, + messages: runData.messages ?? [], + }; + } + + case RunStatus.COMPLETED: { + return { + status, + runId, + messages: runData.messages ?? [], + googleDocPayload: previewPayloadFromCompletedRun(runData), + }; + } + + default: + return null; + } +}; + +const elapsedSec = (startMs: number) => `${((Date.now() - startMs) / 1000).toFixed(1)}s`; + +export const pollAgentRun = async ( + sdk: PageAppSDK, + spaceId: string, + environmentId: string, + runId: string +): Promise => { + const startMs = Date.now(); + let pendingReviewMissingPayloadCount = 0; + console.log(`⏳ Polling run [${runId}]`); + + for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt++) { + const runData = await getWorkflowRun(sdk, spaceId, environmentId, runId); + + if (!runData) { + console.log(` #${attempt + 1} — not found yet (${elapsedSec(startMs)})`); + await wait(POLL_INTERVAL_MS); + continue; + } + + const status = getRunStatus(runData); + console.log(` #${attempt + 1} — status: ${status} (${elapsedSec(startMs)})`); + + if (status === RunStatus.PENDING_REVIEW && !getSuspendPayload(runData)) { + pendingReviewMissingPayloadCount++; + } else { + pendingReviewMissingPayloadCount = 0; + } + + const workflowRun = getWorkflowRunResult(runData, runId, pendingReviewMissingPayloadCount); + if (workflowRun) { + console.log(`✓ Run [${runId}] settled: ${status} in ${elapsedSec(startMs)}`); + return workflowRun; + } + + await wait(POLL_INTERVAL_MS); + } + + console.error(`✗ Run [${runId}] timed out after ${elapsedSec(startMs)}`); + throw new WorkflowRunError(ERROR_MESSAGES.PROCESSING_TIMEOUT, WorkflowFailureReason.PROCESSING_TIMEOUT); +}; + +// ─── Public API ─────────────────────────────────────────────────────────────── + +export async function resumeAndPollWorkflow( + sdk: PageAppSDK, + runId: string, + resumePayload: ResumePayload +): Promise { + const spaceId = sdk.ids.space; + const environmentId = sdk.ids.environmentAlias ?? sdk.ids.environment; + + await resumeWorkflowRun(sdk, spaceId, environmentId, runId, resumePayload); + return pollAgentRun(sdk, spaceId, environmentId, runId); +} diff --git a/apps/drive-integration/src/types/index.ts b/apps/drive-integration/src/types/index.ts index 19f2ccee9c..c9095a0211 100644 --- a/apps/drive-integration/src/types/index.ts +++ b/apps/drive-integration/src/types/index.ts @@ -3,3 +3,4 @@ export * from './entryBlockGraph'; export * from './editModal'; export * from './normalizedDocument'; export * from './workflow'; +export * from './runs'; diff --git a/apps/drive-integration/src/types/runs.ts b/apps/drive-integration/src/types/runs.ts new file mode 100644 index 0000000000..2a17ea1b7b --- /dev/null +++ b/apps/drive-integration/src/types/runs.ts @@ -0,0 +1,26 @@ +export interface RunRecord { + runId: string; + documentTitle: string; + documentId: string; + contentTypeIds: string[]; + startedAt: string; + createdEntryIds?: string[]; +} + +export type DisplayStatus = + | 'loading' + | 'running' + | 'needs-review' + | 'completed' + | 'failed' + | 'expired'; + +export interface RunWithStatus extends RunRecord { + displayStatus: DisplayStatus; + errorMessage?: string; +} + +export type AppView = + | { view: 'runs' } + | { view: 'import' } + | { view: 'review'; runId: string }; diff --git a/apps/drive-integration/test/hooks/useRunStorage.test.ts b/apps/drive-integration/test/hooks/useRunStorage.test.ts new file mode 100644 index 0000000000..0aeb0cc5e1 --- /dev/null +++ b/apps/drive-integration/test/hooks/useRunStorage.test.ts @@ -0,0 +1,129 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { useRunStorage } from '../../src/hooks/useRunStorage'; +import type { RunRecord } from '../../src/types/runs'; + +const SPACE_ID = 'space-1'; +const ENV_ID = 'env-1'; +const STORAGE_KEY = `gdrive-import-runs::${SPACE_ID}::${ENV_ID}`; + +const makeRecord = (overrides?: Partial): RunRecord => ({ + runId: 'run-' + Math.random().toString(36).slice(2), + documentTitle: 'Test Doc', + documentId: 'doc-id', + contentTypeIds: ['ct-1'], + startedAt: new Date().toISOString(), + ...overrides, +}); + +beforeEach(() => { + localStorage.clear(); +}); + +afterEach(() => { + localStorage.clear(); +}); + +describe('useRunStorage', () => { + it('initialises with empty runs when localStorage is empty', () => { + const { result } = renderHook(() => useRunStorage(SPACE_ID, ENV_ID)); + expect(result.current.runs).toEqual([]); + expect(result.current.storageError).toBeNull(); + }); + + it('initialises from existing localStorage data on mount', () => { + const existing: RunRecord[] = [makeRecord({ runId: 'existing-1' })]; + localStorage.setItem(STORAGE_KEY, JSON.stringify(existing)); + const { result } = renderHook(() => useRunStorage(SPACE_ID, ENV_ID)); + expect(result.current.runs).toHaveLength(1); + expect(result.current.runs[0].runId).toBe('existing-1'); + }); + + it('addRun prepends and persists to localStorage', () => { + const { result } = renderHook(() => useRunStorage(SPACE_ID, ENV_ID)); + const record = makeRecord({ runId: 'new-run' }); + + act(() => result.current.addRun(record)); + + expect(result.current.runs[0].runId).toBe('new-run'); + const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]') as RunRecord[]; + expect(stored[0].runId).toBe('new-run'); + }); + + it('addRun is idempotent on duplicate runId', () => { + const { result } = renderHook(() => useRunStorage(SPACE_ID, ENV_ID)); + const record = makeRecord({ runId: 'dup-run' }); + + act(() => result.current.addRun(record)); + act(() => result.current.addRun(record)); + + expect(result.current.runs.filter((r) => r.runId === 'dup-run')).toHaveLength(1); + }); + + it('addRun prunes to 50 records when at capacity', () => { + // Store 50 records: run-0 is most recent (index 0), run-49 is oldest (index 49) + const existing = Array.from({ length: 50 }, (_, i) => + makeRecord({ runId: `run-${i}`, startedAt: new Date((50 - i) * 1000).toISOString() }) + ); + localStorage.setItem(STORAGE_KEY, JSON.stringify(existing)); + const { result } = renderHook(() => useRunStorage(SPACE_ID, ENV_ID)); + + act(() => result.current.addRun(makeRecord({ runId: 'newest' }))); + + expect(result.current.runs).toHaveLength(50); + expect(result.current.runs[0].runId).toBe('newest'); + // last/oldest run (run-49) should be evicted + expect(result.current.runs.find((r) => r.runId === 'run-49')).toBeUndefined(); + // most recent stored run (run-0) should be retained + expect(result.current.runs.find((r) => r.runId === 'run-0')).toBeDefined(); + }); + + it('removeRun removes the correct record', () => { + const { result } = renderHook(() => useRunStorage(SPACE_ID, ENV_ID)); + const a = makeRecord({ runId: 'a' }); + const b = makeRecord({ runId: 'b' }); + + act(() => result.current.addRun(a)); + act(() => result.current.addRun(b)); + act(() => result.current.removeRun('a')); + + expect(result.current.runs.find((r) => r.runId === 'a')).toBeUndefined(); + expect(result.current.runs.find((r) => r.runId === 'b')).toBeDefined(); + }); + + it('markCompleted writes createdEntryIds without overwriting other fields', () => { + const { result } = renderHook(() => useRunStorage(SPACE_ID, ENV_ID)); + const record = makeRecord({ runId: 'target', documentTitle: 'Keep me' }); + + act(() => result.current.addRun(record)); + act(() => result.current.markCompleted('target', ['entry-1', 'entry-2'])); + + const updated = result.current.runs.find((r) => r.runId === 'target'); + expect(updated?.createdEntryIds).toEqual(['entry-1', 'entry-2']); + expect(updated?.documentTitle).toBe('Keep me'); + }); + + it('key is scoped by spaceId + environmentId', () => { + const { result: hook1 } = renderHook(() => useRunStorage('space-A', 'env-X')); + const { result: hook2 } = renderHook(() => useRunStorage('space-B', 'env-X')); + + act(() => hook1.current.addRun(makeRecord({ runId: 'only-in-A' }))); + + expect(hook2.current.runs.find((r) => r.runId === 'only-in-A')).toBeUndefined(); + }); + + it('sets storageError when localStorage.setItem throws', () => { + const originalSetItem = localStorage.setItem.bind(localStorage); + vi.spyOn(Storage.prototype, 'setItem').mockImplementationOnce(() => { + throw new DOMException('QuotaExceededError'); + }); + + const { result } = renderHook(() => useRunStorage(SPACE_ID, ENV_ID)); + act(() => result.current.addRun(makeRecord())); + + expect(result.current.storageError).not.toBeNull(); + + vi.restoreAllMocks(); + void originalSetItem; + }); +}); diff --git a/apps/drive-integration/test/hooks/useRunsPolling.test.ts b/apps/drive-integration/test/hooks/useRunsPolling.test.ts new file mode 100644 index 0000000000..53a2c5ccad --- /dev/null +++ b/apps/drive-integration/test/hooks/useRunsPolling.test.ts @@ -0,0 +1,113 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { useRunsPolling } from '../../src/hooks/useRunsPolling'; +import { RunStatus } from '@types'; +import { createMockSDK } from '../mocks'; +import type { RunRecord } from '../../src/types/runs'; + +const mockGetWorkflowRun = vi.fn(); + +vi.mock('../../src/services/agents-api', () => ({ + getWorkflowRun: (...args: unknown[]) => mockGetWorkflowRun(...args), +})); + +const mockSdk = createMockSDK() as any; + +// Stable references — must NOT be created inside renderHook callbacks or they +// change on every re-render, causing fetchAllStatuses to recreate and loop. +const SINGLE_RUN: RunRecord[] = [ + { runId: 'run-1', documentTitle: 'Test', documentId: 'doc-1', contentTypeIds: ['ct-1'], startedAt: '2026-01-01T00:00:00.000Z' }, +]; +const THREE_RUNS: RunRecord[] = [ + { runId: 'run-1', documentTitle: 'Test', documentId: 'doc-1', contentTypeIds: ['ct-1'], startedAt: '2026-01-01T00:00:00.000Z' }, + { runId: 'run-2', documentTitle: 'Test', documentId: 'doc-2', contentTypeIds: ['ct-1'], startedAt: '2026-01-01T00:00:00.000Z' }, + { runId: 'run-3', documentTitle: 'Test', documentId: 'doc-3', contentTypeIds: ['ct-1'], startedAt: '2026-01-01T00:00:00.000Z' }, +]; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('useRunsPolling', () => { + it('initialises all runIds as loading', () => { + mockGetWorkflowRun.mockResolvedValue({ sys: { status: RunStatus.COMPLETED } }); + const { result } = renderHook(() => useRunsPolling(SINGLE_RUN, mockSdk)); + expect(result.current.statusMap.get('run-1')).toBe('loading'); + }); + + it('maps IN_PROGRESS to running', async () => { + mockGetWorkflowRun.mockResolvedValue({ sys: { status: RunStatus.IN_PROGRESS } }); + const { result, unmount } = renderHook(() => useRunsPolling(SINGLE_RUN, mockSdk)); + await waitFor(() => expect(result.current.statusMap.get('run-1')).toBe('running')); + unmount(); + }); + + it('maps DRAFT to running', async () => { + mockGetWorkflowRun.mockResolvedValue({ sys: { status: RunStatus.DRAFT } }); + const { result, unmount } = renderHook(() => useRunsPolling(SINGLE_RUN, mockSdk)); + await waitFor(() => expect(result.current.statusMap.get('run-1')).toBe('running')); + unmount(); + }); + + it('maps PENDING_REVIEW to needs-review', async () => { + mockGetWorkflowRun.mockResolvedValue({ sys: { status: RunStatus.PENDING_REVIEW } }); + const { result } = renderHook(() => useRunsPolling(SINGLE_RUN, mockSdk)); + await waitFor(() => expect(result.current.statusMap.get('run-1')).toBe('needs-review')); + }); + + it('maps COMPLETED to completed', async () => { + mockGetWorkflowRun.mockResolvedValue({ sys: { status: RunStatus.COMPLETED } }); + const { result } = renderHook(() => useRunsPolling(SINGLE_RUN, mockSdk)); + await waitFor(() => expect(result.current.statusMap.get('run-1')).toBe('completed')); + }); + + it('maps FAILED to failed', async () => { + mockGetWorkflowRun.mockResolvedValue({ + sys: { status: RunStatus.FAILED }, + metadata: { workflowFailure: { code: 'generic', message: 'Something went wrong' } }, + }); + const { result } = renderHook(() => useRunsPolling(SINGLE_RUN, mockSdk)); + await waitFor(() => expect(result.current.statusMap.get('run-1')).toBe('failed')); + }); + + it('maps null response (404) to expired', async () => { + mockGetWorkflowRun.mockResolvedValue(null); + const { result } = renderHook(() => useRunsPolling(SINGLE_RUN, mockSdk)); + await waitFor(() => expect(result.current.statusMap.get('run-1')).toBe('expired')); + }); + + it('populates errorMap for failed runs', async () => { + mockGetWorkflowRun.mockResolvedValue({ + sys: { status: RunStatus.FAILED }, + metadata: { workflowFailure: { code: 'generic', message: 'Failure detail' } }, + }); + const { result } = renderHook(() => useRunsPolling(SINGLE_RUN, mockSdk)); + await waitFor(() => expect(result.current.errorMap.get('run-1')).toBe('Failure detail')); + }); + + it('fetches all runs in parallel (Promise.all fires all before any await)', () => { + let callCount = 0; + mockGetWorkflowRun.mockImplementation(() => { + callCount++; + return Promise.resolve({ sys: { status: RunStatus.COMPLETED } }); + }); + + renderHook(() => useRunsPolling(THREE_RUNS, mockSdk)); + + // All 3 calls should have been initiated synchronously by Promise.all + expect(callCount).toBe(3); + }); + + it('does not re-fetch after settling when all runs complete', async () => { + mockGetWorkflowRun.mockResolvedValue({ sys: { status: RunStatus.COMPLETED } }); + renderHook(() => useRunsPolling(SINGLE_RUN, mockSdk)); + + await waitFor(() => expect(mockGetWorkflowRun.mock.calls.length).toBeGreaterThanOrEqual(1)); + + // Give time for any spurious re-fetches to occur + await new Promise((r) => setTimeout(r, 50)); + + // Only the initial fetch, no repeated polling + expect(mockGetWorkflowRun.mock.calls.length).toBe(1); + }); +}); diff --git a/apps/drive-integration/test/hooks/useWorkflowAgent.test.ts b/apps/drive-integration/test/hooks/useWorkflowAgent.test.ts new file mode 100644 index 0000000000..b705e6d6bf --- /dev/null +++ b/apps/drive-integration/test/hooks/useWorkflowAgent.test.ts @@ -0,0 +1,110 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { useWorkflowAgent } from '../../src/hooks/useWorkflowAgent'; +import { RunStatus } from '@types'; +import { createMockSDK } from '../mocks'; + +const mockStartAgentRun = vi.fn(); +const mockPollAgentRun = vi.fn(); + +vi.mock('../../src/services/agents-api', () => ({ + startAgentRun: (...args: unknown[]) => mockStartAgentRun(...args), + getWorkflowRun: vi.fn(), +})); + +vi.mock('../../src/services/workflowService', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + pollAgentRun: (...args: unknown[]) => mockPollAgentRun(...args), + }; +}); + +const mockSdk = createMockSDK() as any; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('useWorkflowAgent', () => { + it('startWorkflow calls startAgentRun and returns runId string', async () => { + mockStartAgentRun.mockResolvedValue('run-abc-123'); + + const { result } = renderHook(() => + useWorkflowAgent({ sdk: mockSdk, documentId: 'doc-1', oauthToken: 'token' }) + ); + + let returnedRunId: string | undefined; + await act(async () => { + returnedRunId = await result.current.startWorkflow(['ct-1'], { + includeImages: false, + selectedTabIds: [], + }); + }); + + expect(mockStartAgentRun).toHaveBeenCalledOnce(); + expect(returnedRunId).toBe('run-abc-123'); + }); + + it('startWorkflow does NOT call pollAgentRun', async () => { + mockStartAgentRun.mockResolvedValue('run-abc-123'); + + const { result } = renderHook(() => + useWorkflowAgent({ sdk: mockSdk, documentId: 'doc-1', oauthToken: 'token' }) + ); + + await act(async () => { + await result.current.startWorkflow(['ct-1'], { includeImages: false, selectedTabIds: [] }); + }); + + expect(mockPollAgentRun).not.toHaveBeenCalled(); + }); + + it('isAnalyzing is true during startWorkflow and false after', async () => { + let resolveRun: (v: string) => void; + mockStartAgentRun.mockReturnValue( + new Promise((resolve) => { + resolveRun = resolve; + }) + ); + + const { result } = renderHook(() => + useWorkflowAgent({ sdk: mockSdk, documentId: 'doc-1', oauthToken: 'token' }) + ); + + expect(result.current.isAnalyzing).toBe(false); + + let workflowPromise: Promise; + act(() => { + workflowPromise = result.current.startWorkflow(['ct-1'], { + includeImages: false, + selectedTabIds: [], + }); + }); + + expect(result.current.isAnalyzing).toBe(true); + + await act(async () => { + resolveRun!('run-xyz'); + await workflowPromise!; + }); + + expect(result.current.isAnalyzing).toBe(false); + }); + + it('startWorkflow throws if startAgentRun throws', async () => { + mockStartAgentRun.mockRejectedValue(new Error('Network error')); + + const { result } = renderHook(() => + useWorkflowAgent({ sdk: mockSdk, documentId: 'doc-1', oauthToken: 'token' }) + ); + + await expect( + act(() => + result.current.startWorkflow(['ct-1'], { includeImages: false, selectedTabIds: [] }) + ) + ).rejects.toThrow('Network error'); + + expect(result.current.isAnalyzing).toBe(false); + }); +}); diff --git a/apps/drive-integration/test/locations/Page/Page.spec.tsx b/apps/drive-integration/test/locations/Page/Page.spec.tsx index c05f00e349..7ba7107ac7 100644 --- a/apps/drive-integration/test/locations/Page/Page.spec.tsx +++ b/apps/drive-integration/test/locations/Page/Page.spec.tsx @@ -7,32 +7,42 @@ import Page from '../../../src/locations/Page/Page'; const mockSdk = createMockSDK(); -const mappingReviewPayloadMock: MappingReviewSuspendPayload = { - suspendStepId: 'mapping-review', - reason: 'Mapping review required before CMA payload generation continues', - documentId: 'doc-test', - documentTitle: 'Document mapping review', - normalizedDocument: { +const { + mockResumeAndPollWorkflow, + mockMarkCompleted, + mockResetFlow, + mappingReviewPayloadMock, +} = vi.hoisted(() => { + const payload: MappingReviewSuspendPayload = { + suspendStepId: 'mapping-review', + reason: 'Mapping review required', documentId: 'doc-test', - title: 'Document mapping review', - designValues: [], - contentBlocks: [], - images: [], - tables: [], - assets: [], - }, - entryBlockGraph: { - entries: [], - excludedSourceRefs: [], - }, - referenceGraph: { - edges: [], - creationOrder: [], - deferredFields: [], - hasCircularDependency: false, - }, - contentTypes: [], -}; + documentTitle: 'Document mapping review', + normalizedDocument: { + documentId: 'doc-test', + title: 'Document mapping review', + designValues: [], + contentBlocks: [], + images: [], + tables: [], + assets: [], + }, + entryBlockGraph: { entries: [], excludedSourceRefs: [] }, + referenceGraph: { + edges: [], + creationOrder: [], + deferredFields: [], + hasCircularDependency: false, + }, + contentTypes: [], + }; + return { + mockResumeAndPollWorkflow: vi.fn(), + mockMarkCompleted: vi.fn(), + mockResetFlow: vi.fn(), + mappingReviewPayloadMock: payload, + }; +}); vi.mock('@contentful/react-apps-toolkit', () => ({ useSDK: () => mockSdk, @@ -42,78 +52,94 @@ vi.mock('../../../src/locations/Page/components/mainpage/OAuthConnector', () => OAuthConnector: () =>
Mock OAuth Connector
, })); -const { mockModalOrchestrator, mockResumeWorkflow, mockResetFlow } = vi.hoisted(() => ({ - mockModalOrchestrator: vi.fn(), - mockResumeWorkflow: vi.fn(), - mockResetFlow: vi.fn(), +vi.mock('../../../src/services/workflowService', () => ({ + resumeAndPollWorkflow: (...args: unknown[]) => mockResumeAndPollWorkflow(...args), +})); + +vi.mock('../../../src/hooks/useRunStorage', () => ({ + useRunStorage: () => ({ + runs: [], + addRun: vi.fn(), + removeRun: vi.fn(), + markCompleted: mockMarkCompleted, + storageError: null, + }), +})); + +vi.mock('../../../src/hooks/useRunsPolling', () => ({ + useRunsPolling: () => ({ + statusMap: new Map(), + errorMap: new Map(), + }), })); -vi.mock('@hooks/useWorkflowAgent', () => ({ - useWorkflowAgent: () => ({ - resumeWorkflow: mockResumeWorkflow, +vi.mock('../../../src/services/agents-api', () => ({ + getWorkflowRun: vi.fn().mockResolvedValue({ + sys: { status: 'PENDING_REVIEW' }, + metadata: { suspendPayload: mappingReviewPayloadMock }, }), })); vi.mock('../../../src/locations/Page/components/review/ReviewPage', () => ({ ReviewPage: ({ payload, + runId, onCancelReview, onExitReview, + onRunCompleted, }: { payload: MappingReviewSuspendPayload; + runId: string; onCancelReview: () => Promise; onExitReview: () => void; + onRunCompleted: (entryIds: string[]) => void; }) => (
-
{`Mock review page for ${payload.documentTitle}`}
+
{`Mock review page for ${payload.documentTitle} run:${runId}`}
+
), })); +const { mockModalOrchestrator } = vi.hoisted(() => ({ + mockModalOrchestrator: vi.fn(), +})); + vi.mock('../../../src/locations/Page/components/mainpage/ModalOrchestrator', () => ({ // eslint-disable-next-line @typescript-eslint/no-require-imports ModalOrchestrator: require('react').forwardRef( ( props: { onAiAccessDenied: (message: string) => void; - onMappingReviewReady: (payload: MappingReviewSuspendPayload, runId: string) => void; + onRunStarted: (runId: string) => void; onResetToMain: () => void; oauthToken: string; }, - ref: React.ForwardedRef<{ - startFlow: () => void; - resetFlow: () => void; - }> + ref: React.ForwardedRef<{ startFlow: () => void; resetFlow: () => void }> ) => { - const handle = { - startFlow: vi.fn(), - resetFlow: mockResetFlow, - }; - if (typeof ref === 'function') { - ref(handle); - } else if (ref) { - ref.current = handle; - } + const handle = { startFlow: vi.fn(), resetFlow: mockResetFlow }; + if (typeof ref === 'function') ref(handle); + else if (ref) ref.current = handle; mockModalOrchestrator(props); return ( <> - @@ -123,6 +149,26 @@ vi.mock('../../../src/locations/Page/components/mainpage/ModalOrchestrator', () ), })); +vi.mock('../../../src/locations/Page/components/runs/RunsPage', () => ({ + RunsPage: ({ + onNewImport, + onReviewRun, + }: { + onNewImport: () => void; + onReviewRun: (runId: string) => void; + }) => ( +
+
Runs Page
+ + +
+ ), +})); + describe('Page component', () => { afterEach(() => { cleanup(); @@ -130,94 +176,108 @@ describe('Page component', () => { beforeEach(() => { vi.clearAllMocks(); - mockResumeWorkflow.mockResolvedValue({}); + mockResumeAndPollWorkflow.mockResolvedValue({}); }); - it('renders MainPageView by default', async () => { + it('renders RunsPage by default on mount', async () => { render(); - await waitFor(() => { - expect(screen.getByRole('heading', { name: 'Drive Integration' })).toBeTruthy(); - expect(screen.queryByText(/Create from document "Selected document"/)).toBeNull(); + expect(screen.getByText('Runs Page')).toBeTruthy(); }); }); - it('returns to main view when flow reset is requested by modal orchestrator', async () => { + it('onNewImport transitions to import view with MainPageView', async () => { render(); - + fireEvent.click(screen.getByRole('button', { name: 'New Import' })); await waitFor(() => { expect(screen.getByRole('heading', { name: 'Drive Integration' })).toBeTruthy(); }); + }); - fireEvent.click(screen.getByRole('button', { name: 'Trigger Mapping Review Ready' })); - await waitFor(() => { - expect(screen.getByText('Mock review page for Document mapping review')).toBeTruthy(); - }); - - fireEvent.click(screen.getByRole('button', { name: 'Trigger Reset To Main' })); + it('onRunStarted callback transitions back to runs view', async () => { + render(); + // Go to import view first + fireEvent.click(screen.getByRole('button', { name: 'New Import' })); + await waitFor(() => screen.getByRole('heading', { name: 'Drive Integration' })); + // Run started → back to runs + fireEvent.click(screen.getByRole('button', { name: 'Trigger Run Started' })); await waitFor(() => { - expect(screen.getByRole('heading', { name: 'Drive Integration' })).toBeTruthy(); - expect(screen.queryByText(/Create from document "Selected document"/)).toBeNull(); + expect(screen.getByText('Runs Page')).toBeTruthy(); }); }); - it('switches to the mapping review screen when the workflow pauses for mapping review', async () => { + it('onReviewRun transitions to review view (loading spinner, then ReviewPage)', async () => { render(); - - fireEvent.click(screen.getByRole('button', { name: 'Trigger Mapping Review Ready' })); - + fireEvent.click(screen.getByRole('button', { name: 'Review run-123' })); await waitFor(() => { - expect(screen.getByText('Mock review page for Document mapping review')).toBeTruthy(); - expect(screen.queryByRole('heading', { name: 'Drive Integration' })).toBeNull(); + expect( + screen.getByText('Mock review page for Document mapping review run:run-123') + ).toBeTruthy(); }); }); - it('returns to the main view when exiting the review page after creation', async () => { + it('onExitReview callback transitions back to runs view', async () => { render(); + fireEvent.click(screen.getByRole('button', { name: 'Review run-123' })); + await waitFor(() => + screen.getByText('Mock review page for Document mapping review run:run-123') + ); - fireEvent.click(screen.getByRole('button', { name: 'Trigger Mapping Review Ready' })); - + fireEvent.click(screen.getByRole('button', { name: 'Trigger review exit' })); await waitFor(() => { - expect(screen.getByText('Mock review page for Document mapping review')).toBeTruthy(); + expect(screen.getByText('Runs Page')).toBeTruthy(); }); + expect(mockResetFlow).toHaveBeenCalled(); + }); - fireEvent.click(screen.getByRole('button', { name: 'Trigger review exit' })); + it('onRunCompleted calls markCompleted with correct args', async () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Review run-123' })); + await waitFor(() => + screen.getByText('Mock review page for Document mapping review run:run-123') + ); + fireEvent.click(screen.getByRole('button', { name: 'Trigger run completed' })); await waitFor(() => { - expect(screen.getByRole('heading', { name: 'Drive Integration' })).toBeTruthy(); + expect(mockMarkCompleted).toHaveBeenCalledWith('run-123', ['entry-1', 'entry-2']); }); - - expect(mockResumeWorkflow).not.toHaveBeenCalled(); - expect(mockResetFlow).toHaveBeenCalledTimes(1); }); - it('cancels the workflow and returns to the main view when review cancel is triggered', async () => { + it('onCancelReview calls resumeAndPollWorkflow and returns to runs', async () => { render(); + fireEvent.click(screen.getByRole('button', { name: 'Review run-123' })); + await waitFor(() => + screen.getByText('Mock review page for Document mapping review run:run-123') + ); - fireEvent.click(screen.getByRole('button', { name: 'Trigger Mapping Review Ready' })); - + fireEvent.click(screen.getByRole('button', { name: 'Trigger review cancel' })); await waitFor(() => { - expect(screen.getByText('Mock review page for Document mapping review')).toBeTruthy(); + expect(mockResumeAndPollWorkflow).toHaveBeenCalledWith( + expect.anything(), + 'run-123', + { cancelled: true } + ); + expect(screen.getByText('Runs Page')).toBeTruthy(); }); + }); - fireEvent.click(screen.getByRole('button', { name: 'Trigger review cancel' })); - + it('aiAccessDeniedMessage blocks all views with warning note', async () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Trigger Modal AI Access Denied' })); await waitFor(() => { - expect(mockResumeWorkflow).toHaveBeenCalledWith('run-123', { cancelled: true }); - expect(screen.getByRole('heading', { name: 'Drive Integration' })).toBeTruthy(); + expect(screen.getByText(/AI features are currently disabled/)).toBeTruthy(); }); - - expect(mockResetFlow).toHaveBeenCalledTimes(1); }); - it('renders a blocked state when AI access is denied from the workflow modal', async () => { + it('Trigger Reset To Main returns to runs view', async () => { render(); + fireEvent.click(screen.getByRole('button', { name: 'New Import' })); + await waitFor(() => screen.getByRole('heading', { name: 'Drive Integration' })); - fireEvent.click(screen.getByRole('button', { name: 'Trigger Modal AI Access Denied' })); - + fireEvent.click(screen.getByRole('button', { name: 'Trigger Reset To Main' })); await waitFor(() => { - expect(screen.getByText(/AI features are currently disabled/)).toBeTruthy(); + expect(screen.getByText('Runs Page')).toBeTruthy(); }); }); }); diff --git a/apps/drive-integration/test/locations/Page/components/mainpage/ModalOrchestrator.spec.tsx b/apps/drive-integration/test/locations/Page/components/mainpage/ModalOrchestrator.spec.tsx index b06fe9f8a1..555af4641f 100644 --- a/apps/drive-integration/test/locations/Page/components/mainpage/ModalOrchestrator.spec.tsx +++ b/apps/drive-integration/test/locations/Page/components/mainpage/ModalOrchestrator.spec.tsx @@ -1,30 +1,18 @@ -import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { createRef } from 'react'; -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; import { Box, Button } from '@contentful/f36-components'; import { ModalOrchestrator, ModalOrchestratorHandle, } from '../../../../../src/locations/Page/components/mainpage/ModalOrchestrator'; -import { - MappingReviewSuspendPayload, - CompletedWorkflowPayload, - WorkflowRunResult, - RunStatus, -} from '@types'; import { mockSdk } from '../../../../mocks'; import { DocumentSelectionConfig } from '../../../../../src/utils/fetchDocumentSelection'; const mockStartWorkflow = vi.fn(); -const mockResumeWorkflow = vi.fn(); +const mockAddRun = vi.fn(); const mockFetchDocumentSelection = vi.fn(); -const mockWorkflowPayload = { - entries: [], - assets: [], - referenceGraph: {}, -} satisfies CompletedWorkflowPayload; - const mockDocumentSelectionConfig: DocumentSelectionConfig = { tabs: [ { id: 'tab-1', title: 'Introduction', index: 0 }, @@ -53,9 +41,7 @@ vi.mock('../../../../../src/locations/Page/components/modals/step_1/SelectDocume vi.mock('@hooks/useWorkflowAgent', () => ({ useWorkflowAgent: () => ({ isAnalyzing: false, - error: null, startWorkflow: mockStartWorkflow, - resumeWorkflow: mockResumeWorkflow, }), })); @@ -76,35 +62,10 @@ const defaultProps = { sdk: mockSdk, oauthToken: 'mock-oauth-token', isOAuthConnected: true, - onMappingReviewReady: vi.fn(), + onRunStarted: vi.fn(), onResetToMain: vi.fn(), -}; - -const mappingReviewSuspendPayload: MappingReviewSuspendPayload = { - suspendStepId: 'mapping-review', - reason: 'Mapping review required before CMA payload generation continues', - documentId: 'mock-doc-id-123', - documentTitle: 'Mock Mapping Review', - normalizedDocument: { - documentId: 'mock-doc-id-123', - title: 'Mock Mapping Review', - designValues: [], - contentBlocks: [], - images: [], - tables: [], - assets: [], - }, - entryBlockGraph: { - entries: [], - excludedSourceRefs: [], - }, - referenceGraph: { - edges: [], - creationOrder: [], - deferredFields: [], - hasCircularDependency: false, - }, - contentTypes: [], + addRun: mockAddRun, + storageError: null, }; // Helper: pick a document and reach the content type picker @@ -142,7 +103,7 @@ async function completePreflight(options: { useAllTabs: boolean; includeImages: if (options.useAllTabs) { fireEvent.click(screen.getByLabelText('No, import all tabs')); } else { - fireEvent.click(screen.getByLabelText('Yes, let me pick')); + fireEvent.click(screen.getByLabelText('Yes, select specific tabs')); } fireEvent.click(screen.getByRole('button', { name: 'Next' })); @@ -153,46 +114,23 @@ async function completePreflight(options: { useAllTabs: boolean; includeImages: if (options.includeImages) { fireEvent.click(screen.getByLabelText('Yes, include images')); } else { - fireEvent.click(screen.getByLabelText('No, skip images')); + fireEvent.click(screen.getByLabelText('No, do not include images')); } fireEvent.click(screen.getByRole('button', { name: 'Next' })); } describe('ModalOrchestrator', () => { beforeEach(() => { - vi.useFakeTimers({ shouldAdvanceTime: true }); vi.clearAllMocks(); - defaultProps.onMappingReviewReady.mockReset(); + defaultProps.onRunStarted.mockReset(); defaultProps.onResetToMain.mockReset(); mockFetchDocumentSelection.mockResolvedValue(mockDocumentSelectionConfig); vi.mocked(mockSdk.cma.contentType.getMany).mockResolvedValue({ items: mockContentTypes, total: mockContentTypes.length, }); - mockStartWorkflow.mockResolvedValue({ - status: RunStatus.COMPLETED, - runId: 'run-123', - messages: [], - googleDocPayload: mockWorkflowPayload, - } satisfies WorkflowRunResult); - mockResumeWorkflow.mockResolvedValue({ - status: RunStatus.COMPLETED, - runId: 'run-123', - messages: [], - googleDocPayload: mockWorkflowPayload, - } satisfies WorkflowRunResult); - vi.mocked(mockSdk.cma.space.get).mockResolvedValue({ sys: { id: 'test-space-id' } } as any); - vi.mocked(mockSdk.cma.environment.get).mockResolvedValue({ sys: { id: 'test-env-id' } } as any); - vi.mocked(mockSdk.cma.contentType.getMany).mockResolvedValue({ - items: mockContentTypes, - total: mockContentTypes.length, - } as any); - }); - - afterEach(() => { - cleanup(); - vi.runAllTimers(); - vi.useRealTimers(); + // startWorkflow now returns just a runId string + mockStartWorkflow.mockResolvedValue('run-123'); }); it('shows ContentTypePickerModal after document is picked', async () => { @@ -282,7 +220,6 @@ describe('ModalOrchestrator', () => { expect( screen.queryByRole('heading', { name: "You're about to lose your progress" }) ).toBeNull(); - expect(mockResumeWorkflow).not.toHaveBeenCalled(); }); }); @@ -325,7 +262,7 @@ describe('ModalOrchestrator', () => { }); }); - it('discards at pre-flight tab step without calling resumeWorkflow', async () => { + it('discards at pre-flight tab step without calling addRun', async () => { const ref = createRef(); render(); @@ -346,8 +283,7 @@ describe('ModalOrchestrator', () => { fireEvent.click(screen.getByRole('button', { name: 'Cancel without creating' })); await waitFor(() => { - // No active workflow run exists yet — nothing to resume as cancelled - expect(mockResumeWorkflow).not.toHaveBeenCalled(); + expect(mockAddRun).not.toHaveBeenCalled(); expect(screen.queryByRole('heading', { name: 'Document tabs' })).toBeNull(); }); }); @@ -366,16 +302,13 @@ describe('ModalOrchestrator', () => { ); }); - // Tabs shown because fetchDocumentSelection returned 2 tabs await waitFor(() => { expect(screen.getByRole('heading', { name: 'Document tabs' })).toBeTruthy(); }); - // "No, import all tabs" = useAllTabs=true → onContinue called with all available tabs fireEvent.click(screen.getByLabelText('No, import all tabs')); fireEvent.click(screen.getByRole('button', { name: 'Next' })); - // Images shown because imageCount > 0 await waitFor(() => { expect(screen.getByRole('heading', { name: 'Images' })).toBeTruthy(); }); @@ -391,6 +324,54 @@ describe('ModalOrchestrator', () => { }); }); + it('calls addRun with correct RunRecord fields after startWorkflow resolves', async () => { + const ref = createRef(); + render(); + + await pickDocument(ref); + await selectContentTypeAndNext(); + await completePreflight({ useAllTabs: true, includeImages: false }); + + await waitFor(() => { + expect(mockAddRun).toHaveBeenCalledWith( + expect.objectContaining({ + runId: 'run-123', + documentId: 'mock-doc-id-123', + contentTypeIds: ['ct-1'], + }) + ); + }); + }); + + it('calls onRunStarted with runId after startWorkflow resolves', async () => { + const ref = createRef(); + render(); + + await pickDocument(ref); + await selectContentTypeAndNext(); + await completePreflight({ useAllTabs: true, includeImages: false }); + + await waitFor(() => { + expect(defaultProps.onRunStarted).toHaveBeenCalledWith('run-123'); + }); + }); + + it('does NOT call onMappingReviewReady (prop removed)', async () => { + // onMappingReviewReady is no longer a prop — verify the component still works after workflow + const ref = createRef(); + const propsWithoutReviewReady = { ...defaultProps }; + render(); + + await pickDocument(ref); + await selectContentTypeAndNext(); + await completePreflight({ useAllTabs: true, includeImages: false }); + + await waitFor(() => { + expect(defaultProps.onRunStarted).toHaveBeenCalledWith('run-123'); + }); + // No crash, no review-ready call + }); + it('skips image step and starts workflow directly when document has no images', async () => { mockFetchDocumentSelection.mockResolvedValue({ tabs: [ @@ -440,31 +421,7 @@ describe('ModalOrchestrator', () => { }); }); - // No tab or image modal shown expect(screen.queryByRole('heading', { name: 'Document tabs' })).toBeNull(); expect(screen.queryByRole('heading', { name: 'Images' })).toBeNull(); }); - - it('routes mapping-review suspend to onMappingReviewReady after pre-flight selection', async () => { - mockStartWorkflow.mockResolvedValue({ - status: RunStatus.PENDING_REVIEW, - runId: 'run-123', - messages: [], - suspendPayload: mappingReviewSuspendPayload, - } satisfies WorkflowRunResult); - - const ref = createRef(); - render(); - - await pickDocument(ref); - await selectContentTypeAndNext(); - await completePreflight({ useAllTabs: true, includeImages: true }); - - await waitFor(() => { - expect(defaultProps.onMappingReviewReady).toHaveBeenCalledWith( - mappingReviewSuspendPayload, - 'run-123' - ); - }); - }); }); diff --git a/apps/drive-integration/test/locations/Page/components/review/ReviewPage.spec.tsx b/apps/drive-integration/test/locations/Page/components/review/ReviewPage.spec.tsx index f40ed2295f..17608341a2 100644 --- a/apps/drive-integration/test/locations/Page/components/review/ReviewPage.spec.tsx +++ b/apps/drive-integration/test/locations/Page/components/review/ReviewPage.spec.tsx @@ -12,10 +12,8 @@ const { mockResumeWorkflow, mockCreateEntriesFromPreviewPayload } = vi.hoisted(( mockCreateEntriesFromPreviewPayload: vi.fn(), })); -vi.mock('@hooks/useWorkflowAgent', () => ({ - useWorkflowAgent: () => ({ - resumeWorkflow: mockResumeWorkflow, - }), +vi.mock('../../../../../src/services/workflowService', () => ({ + resumeAndPollWorkflow: (...args: unknown[]) => mockResumeWorkflow(...args), })); vi.mock('../../../../../src/services/entryService', () => ({ @@ -184,7 +182,8 @@ describe('ReviewPage entry selection', () => { await waitFor(() => expect(mockResumeWorkflow).toHaveBeenCalledTimes(1)); - const resumePayload = mockResumeWorkflow.mock.calls[0][1]; + // resumeAndPollWorkflow signature: (sdk, runId, payload) + const resumePayload = mockResumeWorkflow.mock.calls[0][2]; expect(resumePayload.entryBlockGraph.entries).toHaveLength(1); expect(resumePayload.entryBlockGraph.entries[0].tempId).toBe('page-1'); expect(resumePayload.entryBlockGraph.entries[0].fieldMappings[1].sourceEntryIds).toEqual([]); diff --git a/apps/drive-integration/test/locations/Page/components/runs/RunRow.spec.tsx b/apps/drive-integration/test/locations/Page/components/runs/RunRow.spec.tsx new file mode 100644 index 0000000000..f3d18a16db --- /dev/null +++ b/apps/drive-integration/test/locations/Page/components/runs/RunRow.spec.tsx @@ -0,0 +1,142 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { RunRow } from '../../../../../src/locations/Page/components/runs/RunRow'; +import type { RunWithStatus } from '../../../../../src/types/runs'; + +const makeRun = (overrides?: Partial): RunWithStatus => ({ + runId: 'run-1', + documentTitle: 'My Document', + documentId: 'doc-1', + contentTypeIds: ['blogPost', 'article'], + startedAt: new Date().toISOString(), + displayStatus: 'running', + ...overrides, +}); + +describe('RunRow', () => { + it('renders document title', () => { + render(); + expect(screen.getByText('My Document')).toBeTruthy(); + }); + + it('renders content type IDs', () => { + render(); + expect(screen.getByText(/blogPost/)).toBeTruthy(); + }); + + it('shows running badge for running status', () => { + render(); + expect(screen.getByText('Running')).toBeTruthy(); + }); + + it('shows Needs Review badge for needs-review status', () => { + render(); + expect(screen.getByText('Needs Review')).toBeTruthy(); + }); + + it('shows Completed badge for completed status', () => { + render( + + ); + expect(screen.getByText('Completed')).toBeTruthy(); + }); + + it('shows Failed badge for failed status', () => { + render( + + ); + expect(screen.getByText('Failed')).toBeTruthy(); + }); + + it('shows Expired badge for expired status', () => { + render(); + expect(screen.getByText('Expired')).toBeTruthy(); + }); + + it('shows Review button for needs-review and calls onReview', () => { + const onReview = vi.fn(); + render( + + ); + fireEvent.click(screen.getByText('Review')); + expect(onReview).toHaveBeenCalledWith('run-1'); + }); + + it('shows Dismiss button for failed and calls onDismiss', () => { + const onDismiss = vi.fn(); + render( + + ); + fireEvent.click(screen.getByText('Dismiss')); + expect(onDismiss).toHaveBeenCalledWith('run-1'); + }); + + it('shows Dismiss button for expired and calls onDismiss', () => { + const onDismiss = vi.fn(); + render( + + ); + fireEvent.click(screen.getByText('Dismiss')); + expect(onDismiss).toHaveBeenCalledWith('run-1'); + }); + + it('renders entry links for completed runs with createdEntryIds', () => { + render( + + ); + const links = screen.getAllByRole('link'); + expect(links.length).toBeGreaterThanOrEqual(2); + expect(links[0]).toHaveAttribute( + 'href', + expect.stringContaining('entry-abc') + ); + }); + + it('renders error message for failed runs', () => { + render( + + ); + expect(screen.getByText('Processing timed out')).toBeTruthy(); + }); + + it('does not show Review or Dismiss for running status', () => { + render(); + expect(screen.queryByText('Review')).toBeNull(); + expect(screen.queryByText('Dismiss')).toBeNull(); + }); +}); diff --git a/apps/drive-integration/test/locations/Page/components/runs/RunsPage.spec.tsx b/apps/drive-integration/test/locations/Page/components/runs/RunsPage.spec.tsx new file mode 100644 index 0000000000..9ac37ca9e1 --- /dev/null +++ b/apps/drive-integration/test/locations/Page/components/runs/RunsPage.spec.tsx @@ -0,0 +1,182 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { RunRecord } from '../../../../../src/types/runs'; + +const mockRuns: RunRecord[] = []; +const mockAddRun = vi.fn(); +const mockRemoveRun = vi.fn(); +const mockMarkCompleted = vi.fn(); +let mockStorageError: string | null = null; + +vi.mock('../../../../../src/hooks/useRunStorage', () => ({ + useRunStorage: () => ({ + runs: mockRuns, + addRun: mockAddRun, + removeRun: mockRemoveRun, + markCompleted: mockMarkCompleted, + storageError: mockStorageError, + }), +})); + +const mockStatusMap = new Map(); +const mockErrorMap = new Map(); + +vi.mock('../../../../../src/hooks/useRunsPolling', () => ({ + useRunsPolling: () => ({ + statusMap: mockStatusMap, + errorMap: mockErrorMap, + }), +})); + +vi.mock('@contentful/react-apps-toolkit', () => ({ + useSDK: () => ({ ids: { space: 'sp-1', environment: 'env-1' } }), +})); + +import { RunsPage } from '../../../../../src/locations/Page/components/runs/RunsPage'; +import { createMockSDK } from '../../../../mocks'; + +const mockSdk = createMockSDK() as any; + +beforeEach(() => { + mockRuns.length = 0; + mockStatusMap.clear(); + mockErrorMap.clear(); + mockStorageError = null; + vi.clearAllMocks(); +}); + +describe('RunsPage', () => { + it('renders empty state when no runs', () => { + render(); + expect(screen.getByText(/no imports yet/i)).toBeTruthy(); + }); + + it('shows New Import button and calls onNewImport', () => { + const onNewImport = vi.fn(); + render(); + fireEvent.click(screen.getByText('New Import')); + expect(onNewImport).toHaveBeenCalled(); + }); + + it('renders a row per run', () => { + mockRuns.push( + { + runId: 'run-1', + documentTitle: 'Doc A', + documentId: 'd1', + contentTypeIds: ['ct-1'], + startedAt: new Date().toISOString(), + }, + { + runId: 'run-2', + documentTitle: 'Doc B', + documentId: 'd2', + contentTypeIds: ['ct-2'], + startedAt: new Date().toISOString(), + } + ); + mockStatusMap.set('run-1', 'running'); + mockStatusMap.set('run-2', 'completed'); + + render(); + expect(screen.getByText('Doc A')).toBeTruthy(); + expect(screen.getByText('Doc B')).toBeTruthy(); + }); + + it('clicking Review on a needs-review run calls onReviewRun', () => { + mockRuns.push({ + runId: 'run-review', + documentTitle: 'Review Me', + documentId: 'd1', + contentTypeIds: ['ct-1'], + startedAt: new Date().toISOString(), + }); + mockStatusMap.set('run-review', 'needs-review'); + + const onReviewRun = vi.fn(); + render(); + fireEvent.click(screen.getByText('Review')); + expect(onReviewRun).toHaveBeenCalledWith('run-review'); + }); + + it('clicking Dismiss on a failed run calls removeRun', () => { + mockRuns.push({ + runId: 'run-fail', + documentTitle: 'Failed Doc', + documentId: 'd1', + contentTypeIds: ['ct-1'], + startedAt: new Date().toISOString(), + }); + mockStatusMap.set('run-fail', 'failed'); + mockErrorMap.set('run-fail', 'Timed out'); + + render(); + fireEvent.click(screen.getByText('Dismiss')); + expect(mockRemoveRun).toHaveBeenCalledWith('run-fail'); + }); + + it('shows storage error note when storageError is set', () => { + mockStorageError = 'Storage full'; + render(); + expect(screen.getByText(/storage full/i)).toBeTruthy(); + }); + + it('renders two concurrent runs with independent statuses', () => { + mockRuns.push( + { + runId: 'concurrent-1', + documentTitle: 'First Import', + documentId: 'd1', + contentTypeIds: ['ct-1'], + startedAt: new Date().toISOString(), + }, + { + runId: 'concurrent-2', + documentTitle: 'Second Import', + documentId: 'd2', + contentTypeIds: ['ct-2'], + startedAt: new Date().toISOString(), + } + ); + mockStatusMap.set('concurrent-1', 'running'); + mockStatusMap.set('concurrent-2', 'needs-review'); + + render(); + + expect(screen.getByText('First Import')).toBeTruthy(); + expect(screen.getByText('Second Import')).toBeTruthy(); + expect(screen.getByText('Running')).toBeTruthy(); + expect(screen.getByText('Needs Review')).toBeTruthy(); + // Only the needs-review run has a Review button + expect(screen.getAllByText('Review').length).toBe(1); + }); + + it('one run transitioning status does not affect the other', () => { + mockRuns.push( + { + runId: 'stable-run', + documentTitle: 'Stable Doc', + documentId: 'd1', + contentTypeIds: ['ct-1'], + startedAt: new Date().toISOString(), + }, + { + runId: 'transitioning-run', + documentTitle: 'Transitioning Doc', + documentId: 'd2', + contentTypeIds: ['ct-2'], + startedAt: new Date().toISOString(), + } + ); + mockStatusMap.set('stable-run', 'completed'); + mockStatusMap.set('transitioning-run', 'running'); + + render(); + + expect(screen.getByText('Completed')).toBeTruthy(); + expect(screen.getByText('Running')).toBeTruthy(); + // Stable run has no action buttons (completed, no entries) + expect(screen.queryByText('Dismiss')).toBeNull(); + expect(screen.queryByText('Review')).toBeNull(); + }); +}); diff --git a/apps/drive-integration/test/services/workflowService.test.ts b/apps/drive-integration/test/services/workflowService.test.ts new file mode 100644 index 0000000000..f2197512ba --- /dev/null +++ b/apps/drive-integration/test/services/workflowService.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { resumeAndPollWorkflow } from '../../src/services/workflowService'; +import { RunStatus, WorkflowRunError, WorkflowFailureReason } from '@types'; +import { createMockSDK } from '../mocks'; + +const mockResumeWorkflowRun = vi.fn(); +const mockGetWorkflowRun = vi.fn(); + +vi.mock('../../src/services/agents-api', () => ({ + resumeWorkflowRun: (...args: unknown[]) => mockResumeWorkflowRun(...args), + getWorkflowRun: (...args: unknown[]) => mockGetWorkflowRun(...args), +})); + +const mockSdk = createMockSDK() as any; + +const makeSuspendPayload = () => ({ + suspendStepId: 'mapping-review' as const, + documentId: 'doc-1', + normalizedDocument: { + documentId: 'doc-1', + title: 'Test', + designValues: [], + contentBlocks: [], + images: [], + tables: [], + assets: [], + }, + entryBlockGraph: { entries: [], excludedSourceRefs: [] }, + referenceGraph: {}, + contentTypes: [], +}); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('resumeAndPollWorkflow', () => { + it('calls resumeWorkflowRun with correct args', async () => { + mockResumeWorkflowRun.mockResolvedValue(undefined); + mockGetWorkflowRun.mockResolvedValue({ + sys: { id: 'run-1', status: RunStatus.PENDING_REVIEW }, + metadata: { suspendPayload: makeSuspendPayload() }, + messages: [], + }); + + await resumeAndPollWorkflow(mockSdk, 'run-1', { entryBlockGraph: { entries: [], excludedSourceRefs: [] } }); + + expect(mockResumeWorkflowRun).toHaveBeenCalledWith( + mockSdk, + 'test-space-id', + 'test-environment-id', + 'run-1', + expect.objectContaining({ entryBlockGraph: expect.any(Object) }) + ); + }); + + it('polls after resume and returns WorkflowRunResult on PENDING_REVIEW', async () => { + mockResumeWorkflowRun.mockResolvedValue(undefined); + mockGetWorkflowRun.mockResolvedValue({ + sys: { id: 'run-1', status: RunStatus.PENDING_REVIEW }, + metadata: { suspendPayload: makeSuspendPayload() }, + messages: [], + }); + + const result = await resumeAndPollWorkflow(mockSdk, 'run-1', {}); + + expect(result.status).toBe(RunStatus.PENDING_REVIEW); + expect(result.runId).toBe('run-1'); + expect(mockGetWorkflowRun).toHaveBeenCalled(); + }); + + it('returns COMPLETED WorkflowRunResult', async () => { + mockResumeWorkflowRun.mockResolvedValue(undefined); + mockGetWorkflowRun.mockResolvedValue({ + sys: { id: 'run-1', status: RunStatus.COMPLETED }, + metadata: { + googleDocPayload: { entries: [], assets: [], referenceGraph: {} }, + }, + messages: [], + }); + + const result = await resumeAndPollWorkflow(mockSdk, 'run-1', {}); + + expect(result.status).toBe(RunStatus.COMPLETED); + }); + + it('throws WorkflowRunError on FAILED status', async () => { + mockResumeWorkflowRun.mockResolvedValue(undefined); + mockGetWorkflowRun.mockResolvedValue({ + sys: { id: 'run-1', status: RunStatus.FAILED }, + metadata: { + workflowFailure: { + code: WorkflowFailureReason.PROCESSING_TIMEOUT, + message: 'Timed out', + }, + }, + messages: [], + }); + + await expect(resumeAndPollWorkflow(mockSdk, 'run-1', {})).rejects.toBeInstanceOf(WorkflowRunError); + }); + + it('throws if resumeWorkflowRun itself throws', async () => { + mockResumeWorkflowRun.mockRejectedValue(new Error('Network error')); + + await expect(resumeAndPollWorkflow(mockSdk, 'run-1', {})).rejects.toThrow('Network error'); + }); +}); From 8fac7a8524135a18ea597d362f0cdc08f8344918 Mon Sep 17 00:00:00 2001 From: David Shibley Date: Thu, 16 Jul 2026 15:42:29 -0600 Subject: [PATCH 02/20] fix(drive-integration): lift useRunStorage to Page to fix stale runs table after import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three independent useRunStorage instances (Page, RunsPage, ModalOrchestrator) meant addRun() only updated local React state — RunsPage wouldn't see the new run without a page refresh. Lift the hook to Page as the single owner and pass runs/addRun/ removeRun/storageError down as props. Co-Authored-By: Claude Sonnet 4.6 --- .../Page/components/runs/RunsPage.tsx | 10 ++-- .../Page/components/runs/RunsPage.spec.tsx | 53 ++++++++++--------- 2 files changed, 32 insertions(+), 31 deletions(-) diff --git a/apps/drive-integration/src/locations/Page/components/runs/RunsPage.tsx b/apps/drive-integration/src/locations/Page/components/runs/RunsPage.tsx index 2c4011d8f0..2d09bd2ced 100644 --- a/apps/drive-integration/src/locations/Page/components/runs/RunsPage.tsx +++ b/apps/drive-integration/src/locations/Page/components/runs/RunsPage.tsx @@ -1,21 +1,21 @@ import { Box, Button, Flex, Heading, Note, Text } from '@contentful/f36-components'; import { PageAppSDK } from '@contentful/app-sdk'; -import { useRunStorage } from '../../../../hooks/useRunStorage'; import { useRunsPolling } from '../../../../hooks/useRunsPolling'; -import type { RunWithStatus } from '../../../../types/runs'; +import type { RunRecord, RunWithStatus } from '../../../../types/runs'; import { RunRow } from './RunRow'; interface RunsPageProps { sdk: PageAppSDK; + runs: RunRecord[]; + removeRun: (runId: string) => void; + storageError: string | null; onNewImport: () => void; onReviewRun: (runId: string) => void; } -export function RunsPage({ sdk, onNewImport, onReviewRun }: RunsPageProps) { +export function RunsPage({ sdk, runs, removeRun, storageError, onNewImport, onReviewRun }: RunsPageProps) { const spaceId = sdk.ids.space; - const environmentId = sdk.ids.environmentAlias ?? sdk.ids.environment; - const { runs, removeRun, storageError } = useRunStorage(spaceId, environmentId); const { statusMap, errorMap } = useRunsPolling(runs, sdk); const runsWithStatus: RunWithStatus[] = runs.map((r) => ({ diff --git a/apps/drive-integration/test/locations/Page/components/runs/RunsPage.spec.tsx b/apps/drive-integration/test/locations/Page/components/runs/RunsPage.spec.tsx index 9ac37ca9e1..6404836a8c 100644 --- a/apps/drive-integration/test/locations/Page/components/runs/RunsPage.spec.tsx +++ b/apps/drive-integration/test/locations/Page/components/runs/RunsPage.spec.tsx @@ -1,23 +1,7 @@ -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { render, screen, fireEvent } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { RunRecord } from '../../../../../src/types/runs'; -const mockRuns: RunRecord[] = []; -const mockAddRun = vi.fn(); -const mockRemoveRun = vi.fn(); -const mockMarkCompleted = vi.fn(); -let mockStorageError: string | null = null; - -vi.mock('../../../../../src/hooks/useRunStorage', () => ({ - useRunStorage: () => ({ - runs: mockRuns, - addRun: mockAddRun, - removeRun: mockRemoveRun, - markCompleted: mockMarkCompleted, - storageError: mockStorageError, - }), -})); - const mockStatusMap = new Map(); const mockErrorMap = new Map(); @@ -36,24 +20,41 @@ import { RunsPage } from '../../../../../src/locations/Page/components/runs/Runs import { createMockSDK } from '../../../../mocks'; const mockSdk = createMockSDK() as any; +const mockRemoveRun = vi.fn(); + +let mockRuns: RunRecord[] = []; +let mockStorageError: string | null = null; beforeEach(() => { - mockRuns.length = 0; + mockRuns = []; mockStatusMap.clear(); mockErrorMap.clear(); mockStorageError = null; vi.clearAllMocks(); }); +function renderRunsPage(overrides: { onNewImport?: () => void; onReviewRun?: (id: string) => void } = {}) { + return render( + + ); +} + describe('RunsPage', () => { it('renders empty state when no runs', () => { - render(); + renderRunsPage(); expect(screen.getByText(/no imports yet/i)).toBeTruthy(); }); it('shows New Import button and calls onNewImport', () => { const onNewImport = vi.fn(); - render(); + renderRunsPage({ onNewImport }); fireEvent.click(screen.getByText('New Import')); expect(onNewImport).toHaveBeenCalled(); }); @@ -78,7 +79,7 @@ describe('RunsPage', () => { mockStatusMap.set('run-1', 'running'); mockStatusMap.set('run-2', 'completed'); - render(); + renderRunsPage(); expect(screen.getByText('Doc A')).toBeTruthy(); expect(screen.getByText('Doc B')).toBeTruthy(); }); @@ -94,7 +95,7 @@ describe('RunsPage', () => { mockStatusMap.set('run-review', 'needs-review'); const onReviewRun = vi.fn(); - render(); + renderRunsPage({ onReviewRun }); fireEvent.click(screen.getByText('Review')); expect(onReviewRun).toHaveBeenCalledWith('run-review'); }); @@ -110,14 +111,14 @@ describe('RunsPage', () => { mockStatusMap.set('run-fail', 'failed'); mockErrorMap.set('run-fail', 'Timed out'); - render(); + renderRunsPage(); fireEvent.click(screen.getByText('Dismiss')); expect(mockRemoveRun).toHaveBeenCalledWith('run-fail'); }); it('shows storage error note when storageError is set', () => { mockStorageError = 'Storage full'; - render(); + renderRunsPage(); expect(screen.getByText(/storage full/i)).toBeTruthy(); }); @@ -141,7 +142,7 @@ describe('RunsPage', () => { mockStatusMap.set('concurrent-1', 'running'); mockStatusMap.set('concurrent-2', 'needs-review'); - render(); + renderRunsPage(); expect(screen.getByText('First Import')).toBeTruthy(); expect(screen.getByText('Second Import')).toBeTruthy(); @@ -171,7 +172,7 @@ describe('RunsPage', () => { mockStatusMap.set('stable-run', 'completed'); mockStatusMap.set('transitioning-run', 'running'); - render(); + renderRunsPage(); expect(screen.getByText('Completed')).toBeTruthy(); expect(screen.getByText('Running')).toBeTruthy(); From 2d23ce13c9b0f1b203174f7244dcf1cb82dd43b2 Mon Sep 17 00:00:00 2001 From: David Shibley Date: Thu, 16 Jul 2026 15:56:53 -0600 Subject: [PATCH 03/20] chore(drive-integration): fix prettier formatting violations Co-Authored-By: Claude Sonnet 4.6 --- .../src/hooks/useRunStorage.ts | 4 +- .../src/hooks/useWorkflowAgent.ts | 4 +- .../src/locations/Page/Page.tsx | 10 +-- .../components/mainpage/ModalOrchestrator.tsx | 6 +- .../locations/Page/components/runs/RunRow.tsx | 32 ++++---- .../Page/components/runs/RunsPage.tsx | 9 ++- .../src/services/workflowService.ts | 16 ++-- apps/drive-integration/src/types/runs.ts | 5 +- .../test/hooks/useRunsPolling.test.ts | 32 +++++++- .../test/locations/Page/Page.spec.tsx | 74 +++++++++---------- .../Page/components/runs/RunRow.spec.tsx | 41 ++++++++-- .../Page/components/runs/RunsPage.spec.tsx | 4 +- .../test/services/workflowService.test.ts | 8 +- 13 files changed, 152 insertions(+), 93 deletions(-) diff --git a/apps/drive-integration/src/hooks/useRunStorage.ts b/apps/drive-integration/src/hooks/useRunStorage.ts index 5207b5b91a..4947829c0f 100644 --- a/apps/drive-integration/src/hooks/useRunStorage.ts +++ b/apps/drive-integration/src/hooks/useRunStorage.ts @@ -41,7 +41,9 @@ export function useRunStorage(spaceId: string, environmentId: string): UseRunSto setStorageError(null); } catch (err) { const msg = - err instanceof Error ? err.message : 'Unable to save import history. Storage may be full.'; + err instanceof Error + ? err.message + : 'Unable to save import history. Storage may be full.'; setStorageError(msg); } setRuns(next); diff --git a/apps/drive-integration/src/hooks/useWorkflowAgent.ts b/apps/drive-integration/src/hooks/useWorkflowAgent.ts index c4fd945b10..d895709a95 100644 --- a/apps/drive-integration/src/hooks/useWorkflowAgent.ts +++ b/apps/drive-integration/src/hooks/useWorkflowAgent.ts @@ -40,7 +40,9 @@ export const useWorkflowAgent = ({ parts: [ { type: 'text' as const, - text: `Analyze the following google docs document ${documentId} and extract the Contentful entries and assets for the following content types: ${contentTypeIds.join(', ')}`, + text: `Analyze the following google docs document ${documentId} and extract the Contentful entries and assets for the following content types: ${contentTypeIds.join( + ', ' + )}`, }, ], }, diff --git a/apps/drive-integration/src/locations/Page/Page.tsx b/apps/drive-integration/src/locations/Page/Page.tsx index c527c0ea19..e9ba8ec29a 100644 --- a/apps/drive-integration/src/locations/Page/Page.tsx +++ b/apps/drive-integration/src/locations/Page/Page.tsx @@ -29,7 +29,10 @@ const Page = () => { const spaceId = sdk.ids.space; const environmentId = sdk.ids.environmentAlias ?? sdk.ids.environment; - const { runs, addRun, removeRun, markCompleted, storageError } = useRunStorage(spaceId, environmentId); + const { runs, addRun, removeRun, markCompleted, storageError } = useRunStorage( + spaceId, + environmentId + ); const { oauthToken, isOAuthConnected, isOAuthLoading, isOAuthBusy, startOAuth, disconnectOAuth } = useGoogleDriveOAuth(sdk); @@ -172,10 +175,7 @@ const Page = () => { case 'review': { if (isLoadingReviewPayload || !pendingReviewPayload) { return ( - + ); diff --git a/apps/drive-integration/src/locations/Page/components/mainpage/ModalOrchestrator.tsx b/apps/drive-integration/src/locations/Page/components/mainpage/ModalOrchestrator.tsx index 5e88378b02..73d4967443 100644 --- a/apps/drive-integration/src/locations/Page/components/mainpage/ModalOrchestrator.tsx +++ b/apps/drive-integration/src/locations/Page/components/mainpage/ModalOrchestrator.tsx @@ -8,11 +8,7 @@ import SelectDocumentModal from '../modals/step_1/SelectDocumentModal'; import { LoadingModal } from '../modals/LoadingModal'; import { ERROR_MESSAGES } from '@constants/messages'; import { SelectTabsModal } from '../modals/step_3/SelectTabsModal'; -import { - DocumentTabProps, - WorkflowFailureReason, - WorkflowRunError, -} from '@types'; +import { DocumentTabProps, WorkflowFailureReason, WorkflowRunError } from '@types'; import { ContentTypePickerModal } from '../modals/step_2/ContentTypePickerModal'; import { IncludeImagesModal } from '../modals/step_4/IncludeImagesModal'; import { useWorkflowAgent } from '@hooks/useWorkflowAgent'; diff --git a/apps/drive-integration/src/locations/Page/components/runs/RunRow.tsx b/apps/drive-integration/src/locations/Page/components/runs/RunRow.tsx index fc0b5bb7d6..793ca1585e 100644 --- a/apps/drive-integration/src/locations/Page/components/runs/RunRow.tsx +++ b/apps/drive-integration/src/locations/Page/components/runs/RunRow.tsx @@ -58,7 +58,9 @@ export function RunRow({ run, spaceId, onReview, onDismiss }: RunRowProps) { {/* Left: metadata */} - + {run.documentTitle} @@ -69,19 +71,21 @@ export function RunRow({ run, spaceId, onReview, onDismiss }: RunRowProps) { {/* Entry links for completed runs */} - {run.displayStatus === 'completed' && run.createdEntryIds && run.createdEntryIds.length > 0 && ( - - {run.createdEntryIds.map((entryId) => ( - - {entryId} - - ))} - - )} + {run.displayStatus === 'completed' && + run.createdEntryIds && + run.createdEntryIds.length > 0 && ( + + {run.createdEntryIds.map((entryId) => ( + + {entryId} + + ))} + + )} {/* Error message for failed runs */} {run.displayStatus === 'failed' && run.errorMessage && ( diff --git a/apps/drive-integration/src/locations/Page/components/runs/RunsPage.tsx b/apps/drive-integration/src/locations/Page/components/runs/RunsPage.tsx index 2d09bd2ced..1956dfd412 100644 --- a/apps/drive-integration/src/locations/Page/components/runs/RunsPage.tsx +++ b/apps/drive-integration/src/locations/Page/components/runs/RunsPage.tsx @@ -13,7 +13,14 @@ interface RunsPageProps { onReviewRun: (runId: string) => void; } -export function RunsPage({ sdk, runs, removeRun, storageError, onNewImport, onReviewRun }: RunsPageProps) { +export function RunsPage({ + sdk, + runs, + removeRun, + storageError, + onNewImport, + onReviewRun, +}: RunsPageProps) { const spaceId = sdk.ids.space; const { statusMap, errorMap } = useRunsPolling(runs, sdk); diff --git a/apps/drive-integration/src/services/workflowService.ts b/apps/drive-integration/src/services/workflowService.ts index e81cef3048..d75545cd86 100644 --- a/apps/drive-integration/src/services/workflowService.ts +++ b/apps/drive-integration/src/services/workflowService.ts @@ -20,8 +20,7 @@ import { // ─── Helpers (shared between workflowService and useWorkflowAgent) ─────────── -const wait = async (ms: number): Promise => - new Promise((resolve) => setTimeout(resolve, ms)); +const wait = async (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); export const getRunStatus = (runData: AgentRunData): RunStatus | null => runData.sys?.status ?? runData.metadata?.status ?? null; @@ -37,7 +36,8 @@ const getAgentPayload = (runData: AgentRunData): string | null => { const previewPayloadFromCompletedRun = (runData: AgentRunData): CompletedWorkflowPayload => { const googleDocPayload = runData.metadata?.googleDocPayload; - if (googleDocPayload == null) throw new Error('Workflow completed but result payload was missing.'); + if (googleDocPayload == null) + throw new Error('Workflow completed but result payload was missing.'); if ( typeof googleDocPayload === 'object' && googleDocPayload !== null && @@ -84,9 +84,8 @@ const getWorkflowFailureMessage = ( failureReason: WorkflowFailureReason ): string => FAILURE_REASON_MESSAGES[failureReason] ?? getRunErrorMessage(runData); -export const getSuspendPayload = ( - runData: AgentRunData -): MappingReviewSuspendPayload | undefined => runData.metadata?.suspendPayload; +export const getSuspendPayload = (runData: AgentRunData): MappingReviewSuspendPayload | undefined => + runData.metadata?.suspendPayload; export const getWorkflowRunResult = ( runData: AgentRunData, @@ -172,7 +171,10 @@ export const pollAgentRun = async ( } console.error(`✗ Run [${runId}] timed out after ${elapsedSec(startMs)}`); - throw new WorkflowRunError(ERROR_MESSAGES.PROCESSING_TIMEOUT, WorkflowFailureReason.PROCESSING_TIMEOUT); + throw new WorkflowRunError( + ERROR_MESSAGES.PROCESSING_TIMEOUT, + WorkflowFailureReason.PROCESSING_TIMEOUT + ); }; // ─── Public API ─────────────────────────────────────────────────────────────── diff --git a/apps/drive-integration/src/types/runs.ts b/apps/drive-integration/src/types/runs.ts index 2a17ea1b7b..ab92911c9a 100644 --- a/apps/drive-integration/src/types/runs.ts +++ b/apps/drive-integration/src/types/runs.ts @@ -20,7 +20,4 @@ export interface RunWithStatus extends RunRecord { errorMessage?: string; } -export type AppView = - | { view: 'runs' } - | { view: 'import' } - | { view: 'review'; runId: string }; +export type AppView = { view: 'runs' } | { view: 'import' } | { view: 'review'; runId: string }; diff --git a/apps/drive-integration/test/hooks/useRunsPolling.test.ts b/apps/drive-integration/test/hooks/useRunsPolling.test.ts index 53a2c5ccad..d3ed766ea5 100644 --- a/apps/drive-integration/test/hooks/useRunsPolling.test.ts +++ b/apps/drive-integration/test/hooks/useRunsPolling.test.ts @@ -16,12 +16,36 @@ const mockSdk = createMockSDK() as any; // Stable references — must NOT be created inside renderHook callbacks or they // change on every re-render, causing fetchAllStatuses to recreate and loop. const SINGLE_RUN: RunRecord[] = [ - { runId: 'run-1', documentTitle: 'Test', documentId: 'doc-1', contentTypeIds: ['ct-1'], startedAt: '2026-01-01T00:00:00.000Z' }, + { + runId: 'run-1', + documentTitle: 'Test', + documentId: 'doc-1', + contentTypeIds: ['ct-1'], + startedAt: '2026-01-01T00:00:00.000Z', + }, ]; const THREE_RUNS: RunRecord[] = [ - { runId: 'run-1', documentTitle: 'Test', documentId: 'doc-1', contentTypeIds: ['ct-1'], startedAt: '2026-01-01T00:00:00.000Z' }, - { runId: 'run-2', documentTitle: 'Test', documentId: 'doc-2', contentTypeIds: ['ct-1'], startedAt: '2026-01-01T00:00:00.000Z' }, - { runId: 'run-3', documentTitle: 'Test', documentId: 'doc-3', contentTypeIds: ['ct-1'], startedAt: '2026-01-01T00:00:00.000Z' }, + { + runId: 'run-1', + documentTitle: 'Test', + documentId: 'doc-1', + contentTypeIds: ['ct-1'], + startedAt: '2026-01-01T00:00:00.000Z', + }, + { + runId: 'run-2', + documentTitle: 'Test', + documentId: 'doc-2', + contentTypeIds: ['ct-1'], + startedAt: '2026-01-01T00:00:00.000Z', + }, + { + runId: 'run-3', + documentTitle: 'Test', + documentId: 'doc-3', + contentTypeIds: ['ct-1'], + startedAt: '2026-01-01T00:00:00.000Z', + }, ]; beforeEach(() => { diff --git a/apps/drive-integration/test/locations/Page/Page.spec.tsx b/apps/drive-integration/test/locations/Page/Page.spec.tsx index 7ba7107ac7..a7a86e42bb 100644 --- a/apps/drive-integration/test/locations/Page/Page.spec.tsx +++ b/apps/drive-integration/test/locations/Page/Page.spec.tsx @@ -7,42 +7,38 @@ import Page from '../../../src/locations/Page/Page'; const mockSdk = createMockSDK(); -const { - mockResumeAndPollWorkflow, - mockMarkCompleted, - mockResetFlow, - mappingReviewPayloadMock, -} = vi.hoisted(() => { - const payload: MappingReviewSuspendPayload = { - suspendStepId: 'mapping-review', - reason: 'Mapping review required', - documentId: 'doc-test', - documentTitle: 'Document mapping review', - normalizedDocument: { +const { mockResumeAndPollWorkflow, mockMarkCompleted, mockResetFlow, mappingReviewPayloadMock } = + vi.hoisted(() => { + const payload: MappingReviewSuspendPayload = { + suspendStepId: 'mapping-review', + reason: 'Mapping review required', documentId: 'doc-test', - title: 'Document mapping review', - designValues: [], - contentBlocks: [], - images: [], - tables: [], - assets: [], - }, - entryBlockGraph: { entries: [], excludedSourceRefs: [] }, - referenceGraph: { - edges: [], - creationOrder: [], - deferredFields: [], - hasCircularDependency: false, - }, - contentTypes: [], - }; - return { - mockResumeAndPollWorkflow: vi.fn(), - mockMarkCompleted: vi.fn(), - mockResetFlow: vi.fn(), - mappingReviewPayloadMock: payload, - }; -}); + documentTitle: 'Document mapping review', + normalizedDocument: { + documentId: 'doc-test', + title: 'Document mapping review', + designValues: [], + contentBlocks: [], + images: [], + tables: [], + assets: [], + }, + entryBlockGraph: { entries: [], excludedSourceRefs: [] }, + referenceGraph: { + edges: [], + creationOrder: [], + deferredFields: [], + hasCircularDependency: false, + }, + contentTypes: [], + }; + return { + mockResumeAndPollWorkflow: vi.fn(), + mockMarkCompleted: vi.fn(), + mockResetFlow: vi.fn(), + mappingReviewPayloadMock: payload, + }; + }); vi.mock('@contentful/react-apps-toolkit', () => ({ useSDK: () => mockSdk, @@ -253,11 +249,9 @@ describe('Page component', () => { fireEvent.click(screen.getByRole('button', { name: 'Trigger review cancel' })); await waitFor(() => { - expect(mockResumeAndPollWorkflow).toHaveBeenCalledWith( - expect.anything(), - 'run-123', - { cancelled: true } - ); + expect(mockResumeAndPollWorkflow).toHaveBeenCalledWith(expect.anything(), 'run-123', { + cancelled: true, + }); expect(screen.getByText('Runs Page')).toBeTruthy(); }); }); diff --git a/apps/drive-integration/test/locations/Page/components/runs/RunRow.spec.tsx b/apps/drive-integration/test/locations/Page/components/runs/RunRow.spec.tsx index f3d18a16db..0b569f1488 100644 --- a/apps/drive-integration/test/locations/Page/components/runs/RunRow.spec.tsx +++ b/apps/drive-integration/test/locations/Page/components/runs/RunRow.spec.tsx @@ -25,12 +25,26 @@ describe('RunRow', () => { }); it('shows running badge for running status', () => { - render(); + render( + + ); expect(screen.getByText('Running')).toBeTruthy(); }); it('shows Needs Review badge for needs-review status', () => { - render(); + render( + + ); expect(screen.getByText('Needs Review')).toBeTruthy(); }); @@ -59,7 +73,14 @@ describe('RunRow', () => { }); it('shows Expired badge for expired status', () => { - render(); + render( + + ); expect(screen.getByText('Expired')).toBeTruthy(); }); @@ -116,10 +137,7 @@ describe('RunRow', () => { ); const links = screen.getAllByRole('link'); expect(links.length).toBeGreaterThanOrEqual(2); - expect(links[0]).toHaveAttribute( - 'href', - expect.stringContaining('entry-abc') - ); + expect(links[0]).toHaveAttribute('href', expect.stringContaining('entry-abc')); }); it('renders error message for failed runs', () => { @@ -135,7 +153,14 @@ describe('RunRow', () => { }); it('does not show Review or Dismiss for running status', () => { - render(); + render( + + ); expect(screen.queryByText('Review')).toBeNull(); expect(screen.queryByText('Dismiss')).toBeNull(); }); diff --git a/apps/drive-integration/test/locations/Page/components/runs/RunsPage.spec.tsx b/apps/drive-integration/test/locations/Page/components/runs/RunsPage.spec.tsx index 6404836a8c..fb75bf823a 100644 --- a/apps/drive-integration/test/locations/Page/components/runs/RunsPage.spec.tsx +++ b/apps/drive-integration/test/locations/Page/components/runs/RunsPage.spec.tsx @@ -33,7 +33,9 @@ beforeEach(() => { vi.clearAllMocks(); }); -function renderRunsPage(overrides: { onNewImport?: () => void; onReviewRun?: (id: string) => void } = {}) { +function renderRunsPage( + overrides: { onNewImport?: () => void; onReviewRun?: (id: string) => void } = {} +) { return render( { messages: [], }); - await resumeAndPollWorkflow(mockSdk, 'run-1', { entryBlockGraph: { entries: [], excludedSourceRefs: [] } }); + await resumeAndPollWorkflow(mockSdk, 'run-1', { + entryBlockGraph: { entries: [], excludedSourceRefs: [] }, + }); expect(mockResumeWorkflowRun).toHaveBeenCalledWith( mockSdk, @@ -97,7 +99,9 @@ describe('resumeAndPollWorkflow', () => { messages: [], }); - await expect(resumeAndPollWorkflow(mockSdk, 'run-1', {})).rejects.toBeInstanceOf(WorkflowRunError); + await expect(resumeAndPollWorkflow(mockSdk, 'run-1', {})).rejects.toBeInstanceOf( + WorkflowRunError + ); }); it('throws if resumeWorkflowRun itself throws', async () => { From cd4c6506bff0fa4a954423645bd5402f79b1b0bc Mon Sep 17 00:00:00 2001 From: David Shibley Date: Thu, 16 Jul 2026 16:04:37 -0600 Subject: [PATCH 04/20] fix(drive-integration): remove unused imports and variables flagged by ESLint Co-Authored-By: Claude Sonnet 4.6 --- apps/drive-integration/src/locations/Page/Page.tsx | 2 +- apps/drive-integration/src/services/workflowService.ts | 1 - apps/drive-integration/test/hooks/useWorkflowAgent.test.ts | 1 - .../test/locations/Page/components/runs/RunRow.spec.tsx | 2 +- 4 files changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/drive-integration/src/locations/Page/Page.tsx b/apps/drive-integration/src/locations/Page/Page.tsx index e9ba8ec29a..8b27f70dd1 100644 --- a/apps/drive-integration/src/locations/Page/Page.tsx +++ b/apps/drive-integration/src/locations/Page/Page.tsx @@ -80,7 +80,7 @@ const Page = () => { } }; - const handleRunStarted = (_runId: string) => { + const handleRunStarted = () => { setAppView({ view: 'runs' }); }; diff --git a/apps/drive-integration/src/services/workflowService.ts b/apps/drive-integration/src/services/workflowService.ts index d75545cd86..111358351f 100644 --- a/apps/drive-integration/src/services/workflowService.ts +++ b/apps/drive-integration/src/services/workflowService.ts @@ -7,7 +7,6 @@ import { WorkflowRunError, MappingReviewSuspendPayload, CompletedWorkflowPayload, - AgentRunMessage, } from '@types'; import { AgentRunData, getWorkflowRun, resumeWorkflowRun } from './agents-api'; import { validatePayloadShape } from '../utils/createEntries'; diff --git a/apps/drive-integration/test/hooks/useWorkflowAgent.test.ts b/apps/drive-integration/test/hooks/useWorkflowAgent.test.ts index b705e6d6bf..e4bb06e9f9 100644 --- a/apps/drive-integration/test/hooks/useWorkflowAgent.test.ts +++ b/apps/drive-integration/test/hooks/useWorkflowAgent.test.ts @@ -1,7 +1,6 @@ import { renderHook, act } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { useWorkflowAgent } from '../../src/hooks/useWorkflowAgent'; -import { RunStatus } from '@types'; import { createMockSDK } from '../mocks'; const mockStartAgentRun = vi.fn(); diff --git a/apps/drive-integration/test/locations/Page/components/runs/RunRow.spec.tsx b/apps/drive-integration/test/locations/Page/components/runs/RunRow.spec.tsx index 0b569f1488..67d36b629d 100644 --- a/apps/drive-integration/test/locations/Page/components/runs/RunRow.spec.tsx +++ b/apps/drive-integration/test/locations/Page/components/runs/RunRow.spec.tsx @@ -1,5 +1,5 @@ import { render, screen, fireEvent } from '@testing-library/react'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { RunRow } from '../../../../../src/locations/Page/components/runs/RunRow'; import type { RunWithStatus } from '../../../../../src/types/runs'; From 66a8b7d6532d7a1eb1a841766669b14363da3961 Mon Sep 17 00:00:00 2001 From: David Shibley Date: Fri, 17 Jul 2026 09:11:29 -0600 Subject: [PATCH 05/20] chore(drive-integration): remove speckit planning artifacts and dev fixtures from branch Co-Authored-By: Claude Sonnet 4.6 --- .../checklists/requirements.md | 34 -- .../contracts/ui-contracts.md | 185 ------- .../001-async-import-runs/data-model.md | 148 ------ .../features/001-async-import-runs/plan.md | 463 ------------------ .../001-async-import-runs/research.md | 123 ----- .../features/001-async-import-runs/spec.md | 148 ------ .../features/001-async-import-runs/tasks.md | 275 ----------- .../src/fixtures/FixtureHarness.tsx | 23 - .../fixtures/googleDocsReview/fixture.json | 289 ----------- 9 files changed, 1688 deletions(-) delete mode 100644 apps/drive-integration/.specify/features/001-async-import-runs/checklists/requirements.md delete mode 100644 apps/drive-integration/.specify/features/001-async-import-runs/contracts/ui-contracts.md delete mode 100644 apps/drive-integration/.specify/features/001-async-import-runs/data-model.md delete mode 100644 apps/drive-integration/.specify/features/001-async-import-runs/plan.md delete mode 100644 apps/drive-integration/.specify/features/001-async-import-runs/research.md delete mode 100644 apps/drive-integration/.specify/features/001-async-import-runs/spec.md delete mode 100644 apps/drive-integration/.specify/features/001-async-import-runs/tasks.md delete mode 100644 apps/drive-integration/src/fixtures/FixtureHarness.tsx delete mode 100644 apps/drive-integration/src/fixtures/googleDocsReview/fixture.json diff --git a/apps/drive-integration/.specify/features/001-async-import-runs/checklists/requirements.md b/apps/drive-integration/.specify/features/001-async-import-runs/checklists/requirements.md deleted file mode 100644 index a1ba052bae..0000000000 --- a/apps/drive-integration/.specify/features/001-async-import-runs/checklists/requirements.md +++ /dev/null @@ -1,34 +0,0 @@ -# Specification Quality Checklist: Async Import Runs - -**Purpose**: Validate specification completeness and quality before proceeding to planning -**Created**: 2026-07-16 -**Feature**: [spec.md](../spec.md) - -## Content Quality - -- [x] No implementation details (languages, frameworks, APIs) -- [x] Focused on user value and business needs -- [x] Written for non-technical stakeholders -- [x] All mandatory sections completed - -## Requirement Completeness - -- [x] No [NEEDS CLARIFICATION] markers remain -- [x] Requirements are testable and unambiguous -- [x] Success criteria are measurable -- [x] Success criteria are technology-agnostic (no implementation details) -- [x] All acceptance scenarios are defined -- [x] Edge cases are identified -- [x] Scope is clearly bounded -- [x] Dependencies and assumptions identified - -## Feature Readiness - -- [x] All functional requirements have clear acceptance criteria -- [x] User scenarios cover primary flows -- [x] Feature meets measurable outcomes defined in Success Criteria -- [x] No implementation details leak into specification - -## Notes - -All items pass. Spec is ready for `/speckit.plan`. diff --git a/apps/drive-integration/.specify/features/001-async-import-runs/contracts/ui-contracts.md b/apps/drive-integration/.specify/features/001-async-import-runs/contracts/ui-contracts.md deleted file mode 100644 index c30efadf07..0000000000 --- a/apps/drive-integration/.specify/features/001-async-import-runs/contracts/ui-contracts.md +++ /dev/null @@ -1,185 +0,0 @@ -# UI Contracts: Async Import Runs - -**Feature**: 001-async-import-runs -**Date**: 2026-07-16 - -These contracts define the props interfaces and callback signatures for all new and modified components. They are the stable boundary between implementation units — changes here require updating all callers. - ---- - -## New Components - -### `RunsPage` - -```ts -interface RunsPageProps { - sdk: PageAppSDK; - onNewImport: () => void; // navigates to import wizard - onReviewRun: (runId: string) => void; // navigates to review screen -} -``` - -**Renders**: -- Empty state when no runs exist (with "New Import" CTA) -- List of `RunRow` components sorted by `startedAt` descending -- "New Import" button always visible in header -- Auto-polls every 10s while any run has `displayStatus === 'running'` - ---- - -### `RunRow` - -```ts -interface RunRowProps { - run: RunWithStatus; - onReview: (runId: string) => void; // emitted for 'needs-review' status - onDismiss: (runId: string) => void; // emitted for 'failed' or 'expired' status -} -``` - -**Renders per status**: - -| Status | Badge | Actions | -|---|---|---| -| `loading` | Spinner | — | -| `running` | Blue "Running" + spinner | — | -| `needs-review` | Yellow "Needs Review" | "Review" button | -| `completed` | Green "Completed" | Links to created entries (one per `createdEntryId`) | -| `failed` | Red "Failed" | Error summary text + "Dismiss" button | -| `expired` | Grey "Expired" | "Dismiss" button | - ---- - -## Modified Components - -### `ModalOrchestrator` — changed props - -**Removed**: -```ts -onMappingReviewReady: (payload: MappingReviewSuspendPayload, runId: string) => void; -``` - -**Added**: -```ts -onRunStarted: (runId: string) => void; // fires after run is saved to localStorage -documentTitle?: string; // passed down from Google Picker selection -``` - -**`onRunStarted` contract**: By the time `onRunStarted` fires, the run record MUST already be written to localStorage (via `useRunStorage.addRun`). The caller (Page.tsx) can safely navigate to the Runs page immediately. - ---- - -### `ReviewPage` — changed props - -**Removed**: -```ts -// nothing removed from the public interface -``` - -**Added**: -```ts -onRunCompleted: (entryIds: string[]) => void; // fires before onExitReview; caller writes createdEntryIds to localStorage -``` - -**`onRunCompleted` contract**: Called synchronously after `createEntriesFromPreviewPayload` resolves successfully, before the SummaryModal is shown. The caller writes `createdEntryIds` to localStorage so the Runs page shows the completed state even if the user never clicks "Exit". - -**Unchanged props**: `sdk`, `payload`, `runId`, `onCancelReview`, `onExitReview` - ---- - -## New Hooks - -### `useRunStorage` - -```ts -function useRunStorage(spaceId: string, environmentId: string): UseRunStorage - -interface UseRunStorage { - runs: RunRecord[]; - addRun(record: RunRecord): void; - removeRun(runId: string): void; - markCompleted(runId: string, entryIds: string[]): void; - storageError: string | null; -} -``` - -**Guarantees**: -- `runs` is always sorted by `startedAt` descending -- `addRun` is idempotent on `runId` (duplicate IDs are ignored) -- `addRun` prunes oldest record if length would exceed 50 -- All writes are atomic (single `localStorage.setItem` call per operation) -- `storageError` is set to a user-readable message on any `localStorage` exception - ---- - -### `useRunsPolling` - -```ts -function useRunsPolling( - runs: RunRecord[], - sdk: PageAppSDK -): Map -``` - -**Behavior**: -- On mount and when `runs` changes: fetches status for all runs in parallel via `getWorkflowRun()` -- Sets interval (10s) while any run has status `'running'` -- Clears interval when all runs are settled (completed / failed / expired / needs-review) -- Returns current `Map`; returns `'loading'` for any runId not yet fetched - ---- - -## New Service Function - -### `workflowService.ts` - -```ts -export async function resumeAndPollWorkflow( - sdk: PageAppSDK, - runId: string, - resumePayload: ResumePayload -): Promise -``` - -Direct extraction from `useWorkflowAgent.resumeWorkflow`. Same semantics: calls `resumeWorkflowRun`, then polls until settled. Throws `WorkflowRunError` on failure. - ---- - -## Modified Hook - -### `useWorkflowAgent` - -**Signature change**: -```ts -// Before -startWorkflow(contentTypeIds, documentSelection): Promise - -// After -startWorkflow(contentTypeIds, documentSelection): Promise // returns runId only -``` - -**Removed from return value**: `resumeWorkflow` (moved to `workflowService.ts`) -**Unchanged**: `isAnalyzing` (kept for loading state in ModalOrchestrator) - ---- - -## AppView State (Page.tsx) - -```ts -type AppView = - | { view: 'runs' } - | { view: 'import' } - | { view: 'review'; runId: string }; - -// Default on mount: -const [appView, setAppView] = useState({ view: 'runs' }); -``` - -**Page.tsx render switch**: -```ts -switch (appView.view) { - case 'runs': return ; - case 'import': return ; // contains ModalOrchestrator - case 'review': return ; -} -``` diff --git a/apps/drive-integration/.specify/features/001-async-import-runs/data-model.md b/apps/drive-integration/.specify/features/001-async-import-runs/data-model.md deleted file mode 100644 index e7c677218f..0000000000 --- a/apps/drive-integration/.specify/features/001-async-import-runs/data-model.md +++ /dev/null @@ -1,148 +0,0 @@ -# Data Model: Async Import Runs - -**Feature**: 001-async-import-runs -**Date**: 2026-07-16 - ---- - -## Entities - -### RunRecord (localStorage) - -Represents a single import attempt. Stored in localStorage; status is always fetched live. - -```ts -interface RunRecord { - runId: string; // Contentful agentRun sys.id — the stable key - documentTitle: string; // Google Doc title; fallback to 'Untitled Document' - documentId: string; // Google Doc ID (for deep-links) - contentTypeIds: string[]; // Content type IDs selected in the wizard - startedAt: string; // ISO 8601 — e.g. "2026-07-16T10:23:00.000Z" - createdEntryIds?: string[]; // Written once, on successful entry creation -} -``` - -**Stored as**: JSON array at key `gdrive-import-runs::{spaceId}::{environmentId}` -**Ordering**: Array kept sorted by `startedAt` descending (newest first) -**Capacity**: Max 50 records; oldest are pruned when limit is exceeded -**Mutable fields**: Only `createdEntryIds` — written by `useRunStorage.markCompleted(runId, entryIds)` - ---- - -### RunStatus (display-only, derived) - -Not persisted. Derived on the Runs page by merging localStorage records with live backend data. - -```ts -type DisplayStatus = - | 'running' // backend: IN_PROGRESS or DRAFT - | 'needs-review' // backend: PENDING_REVIEW - | 'completed' // backend: COMPLETED (or createdEntryIds present) - | 'failed' // backend: FAILED - | 'expired' // backend: 404 / run not found - | 'loading'; // initial fetch in progress -``` - ---- - -### RunWithStatus (view model) - -The merged view model consumed by the Runs page UI. - -```ts -interface RunWithStatus extends RunRecord { - displayStatus: DisplayStatus; - errorMessage?: string; // populated when displayStatus === 'failed' -} -``` - ---- - -## State Transitions - -``` -[wizard completes] - │ - ▼ - 'running' ←─── backend: IN_PROGRESS / DRAFT - │ - ├──────────────────────────────────────┐ - ▼ ▼ - 'needs-review' 'failed' - (backend: PENDING_REVIEW) (backend: FAILED) - │ │ - │ user clicks Review │ user clicks Dismiss - ▼ ▼ - [ReviewPage] [removed from list] - │ - │ user creates entries - ▼ - 'completed' - (createdEntryIds written to localStorage) - -[any state] → 'expired' if backend returns 404 -[any state] → 'loading' on initial page load or manual refresh -``` - ---- - -## Storage Layer: `useRunStorage` Hook - -Central hook for all localStorage read/write operations. All other components interact with run records through this hook only. - -**Interface**: -```ts -interface UseRunStorage { - runs: RunRecord[]; // sorted by startedAt desc - addRun(record: RunRecord): void; // prepends; prunes if >50 - removeRun(runId: string): void; // used for dismiss on failed/expired - markCompleted(runId: string, entryIds: string[]): void; // writes createdEntryIds - storageError: string | null; // non-null if localStorage unavailable -} -``` - -**Storage key**: `gdrive-import-runs::${spaceId}::${environmentId}` (passed to hook as init params) - ---- - -## AppView State Machine - -Replaces the `mappingReviewState !== null` binary toggle in `Page.tsx`. - -```ts -type AppView = - | { view: 'runs' } - | { view: 'import' } - | { view: 'review'; runId: string }; -``` - -**Transitions**: -- App opens → `{ view: 'runs' }` (default home) -- User clicks "New Import" → `{ view: 'import' }` -- Wizard fires `onRunStarted(runId)` → `{ view: 'runs' }` (wizard closes, back to runs) -- User clicks "Review" on a PENDING_REVIEW run → `{ view: 'review', runId }` -- User exits review → `{ view: 'runs' }` - ---- - -## New Files - -| Path | Purpose | -|---|---| -| `src/hooks/useRunStorage.ts` | localStorage read/write for RunRecord array | -| `src/hooks/useRunsPolling.ts` | Polls backend status for all RunRecords; returns `Map` | -| `src/locations/Page/components/runs/RunsPage.tsx` | New home screen — list of all runs | -| `src/locations/Page/components/runs/RunRow.tsx` | Single run row with status badge + actions | -| `src/services/workflowService.ts` | `resumeAndPollWorkflow()` standalone function (extracted from useWorkflowAgent) | -| `src/types/runs.ts` | `RunRecord`, `DisplayStatus`, `RunWithStatus`, `AppView` types | - ---- - -## Modified Files - -| Path | What changes | -|---|---| -| `src/locations/Page/Page.tsx` | Replace `mappingReviewState` toggle with `AppView` state machine; add `useRunStorage`; wire `RunsPage` and `ReviewPage` to AppView transitions | -| `src/locations/Page/components/mainpage/ModalOrchestrator.tsx` | Replace blocking `startWorkflowWithScope` with fire-and-forget; add `onRunStarted` prop; remove `onMappingReviewReady`; store run record via `useRunStorage` | -| `src/hooks/useWorkflowAgent.ts` | `startWorkflow` returns `runId: string` only (no poll); remove `pollAgentRun` call from `startWorkflow`; `resumeWorkflow` removed (moved to `workflowService.ts`) | -| `src/locations/Page/components/review/ReviewPage.tsx` | Replace `useWorkflowAgent` stub instantiation with direct `resumeAndPollWorkflow` call; add `onRunCompleted(entryIds: string[])` prop; call it before `onExitReview` | diff --git a/apps/drive-integration/.specify/features/001-async-import-runs/plan.md b/apps/drive-integration/.specify/features/001-async-import-runs/plan.md deleted file mode 100644 index 63dcfe9110..0000000000 --- a/apps/drive-integration/.specify/features/001-async-import-runs/plan.md +++ /dev/null @@ -1,463 +0,0 @@ -# Implementation Plan: Async Import Runs - -**Feature**: 001-async-import-runs -**Date**: 2026-07-16 -**Spec**: [spec.md](./spec.md) | **Research**: [research.md](./research.md) | **Data Model**: [data-model.md](./data-model.md) | **Contracts**: [contracts/ui-contracts.md](./contracts/ui-contracts.md) - ---- - -## Overview - -The rearchitecture has 5 independent slices, each deliverable and testable on its own. Slices 1–3 are foundation (no UI yet). Slices 4–5 are the visible surfaces. The order minimizes integration surprise: storage and polling exist before any UI tries to use them; the wizard exit change is last because it depends on all the infrastructure being in place. - -**Unchanged zones**: `functions/`, `useGoogleDriveOAuth`, `entryService.ts`, `referenceResolution.ts`, `richtext.ts`, `MappingView.tsx` and all sub-components, all modal step components inside `ModalOrchestrator`, `agents-api.ts`. - ---- - -## Slice 1 — Types + Storage Foundation - -**Goal**: Define all new types and the `useRunStorage` hook. No UI. TDD. - -### Tasks - -**TASK-101** — Add `src/types/runs.ts` - -Create the file with: -```ts -export interface RunRecord { - runId: string; - documentTitle: string; - documentId: string; - contentTypeIds: string[]; - startedAt: string; - createdEntryIds?: string[]; -} - -export type DisplayStatus = - | 'loading' - | 'running' - | 'needs-review' - | 'completed' - | 'failed' - | 'expired'; - -export interface RunWithStatus extends RunRecord { - displayStatus: DisplayStatus; - errorMessage?: string; -} - -export type AppView = - | { view: 'runs' } - | { view: 'import' } - | { view: 'review'; runId: string }; -``` - -No test needed (pure types). - ---- - -**TASK-102** — Add `src/hooks/useRunStorage.ts` - -Implement `useRunStorage(spaceId: string, environmentId: string)`. - -Storage key: `` `gdrive-import-runs::${spaceId}::${environmentId}` `` - -State: `RunRecord[]` in React state, initialized from localStorage on mount. - -`addRun(record)`: -- Ignore if `runId` already exists (idempotency) -- Prepend to array -- Prune to 50 records (drop oldest = last element) -- Write updated array to localStorage -- If `localStorage.setItem` throws, set `storageError` - -`removeRun(runId)`: -- Filter out the record; write to localStorage - -`markCompleted(runId, entryIds)`: -- Find the record, spread `{ createdEntryIds: entryIds }`, write to localStorage - -`storageError: string | null`: -- Set to human-readable message on any localStorage exception - -**Tests** — `test/hooks/useRunStorage.test.ts`: -- `addRun` persists to localStorage and updates `runs` state -- `addRun` is idempotent on duplicate `runId` -- `addRun` prunes to 50 when at capacity -- `removeRun` removes the correct record -- `markCompleted` writes `createdEntryIds` without overwriting other fields -- Initializes from existing localStorage data on mount -- Sets `storageError` when `localStorage.setItem` throws (mock localStorage) -- Key is scoped by spaceId + environmentId (two instances with different params don't share data) - ---- - -**TASK-103** — Add `src/services/workflowService.ts` - -Extract `resumeAndPollWorkflow` from `useWorkflowAgent`: - -```ts -export async function resumeAndPollWorkflow( - sdk: PageAppSDK, - runId: string, - resumePayload: ResumePayload -): Promise -``` - -Internal: calls `resumeWorkflowRun(sdk, spaceId, environmentId, runId, resumePayload)` then `pollAgentRun(sdk, spaceId, environmentId, runId)`. The `pollAgentRun` function is moved/copied here from `useWorkflowAgent`. - -Move `pollAgentRun`, `getWorkflowRunResult`, `getRunStatus`, `WorkflowRunError` (if not already in types) into `workflowService.ts` or a shared `src/utils/workflowUtils.ts`. `useWorkflowAgent` imports them from there. - -**Tests** — `test/services/workflowService.test.ts`: -- Calls `resumeWorkflowRun` with correct args -- Calls `pollAgentRun` after resume -- Returns `WorkflowRunResult` on success -- Throws `WorkflowRunError` on failure - ---- - -## Slice 2 — Polling Hook - -**Goal**: `useRunsPolling` hook. Depends on Slice 1 types and `agents-api.ts`. - -### Tasks - -**TASK-201** — Add `src/hooks/useRunsPolling.ts` - -```ts -function useRunsPolling(runs: RunRecord[], sdk: PageAppSDK): Map -``` - -Implementation: -1. `statusMap` state: `Map` initialized with all runIds as `'loading'` -2. `fetchAllStatuses()`: calls `getWorkflowRun()` for each run in parallel (`Promise.all`). Maps backend `RunStatus` → `DisplayStatus`: - - `IN_PROGRESS` | `DRAFT` → `'running'` - - `PENDING_REVIEW` → `'needs-review'` - - `COMPLETED` → `'completed'` - - `FAILED` → `'failed'` - - `null` (404) → `'expired'` -3. On mount: call `fetchAllStatuses()` -4. `useEffect` with interval: if any current status is `'running'`, set 10s interval calling `fetchAllStatuses()`. Clear interval when no runs are `'running'`. -5. Re-run effect when `runs` array length changes (new run added). - -Also return `errorMap: Map` for failed runs' error messages (from `runData.metadata.workflowFailure`). - -**Tests** — `test/hooks/useRunsPolling.test.ts`: -- Maps `IN_PROGRESS` → `'running'`, `PENDING_REVIEW` → `'needs-review'`, etc. -- Sets all unresolved runIds to `'loading'` initially -- Maps null response (404) → `'expired'` -- Sets up polling interval when any run is `'running'` -- Clears interval when all runs settle -- Fetches in parallel (all `getWorkflowRun` calls made before any await resolves) - ---- - -## Slice 3 — Refactor `useWorkflowAgent` + `ModalOrchestrator` exit - -**Goal**: Make the wizard exit async. Depends on Slices 1 + 2. - -### Tasks - -**TASK-301** — Refactor `src/hooks/useWorkflowAgent.ts` - -Changes: -- `startWorkflow` returns `Promise` (just the `runId`) — remove `pollAgentRun` call from it -- Remove `resumeWorkflow` from the hook (it moves to `workflowService.ts`) -- `pollAgentRun` is still called internally by... actually it no longer needs to be in this hook. Simplify: `startWorkflow` calls `startAgentRun` and returns the `runId`. That's it. The hook now only exposes `{ isAnalyzing, startWorkflow }`. -- Keep `isAnalyzing` for the loading spinner in `ModalOrchestrator`. - -**Tests** — update `test/hooks/useWorkflowAgent.test.ts` (if it exists) or add one: -- `startWorkflow` calls `startAgentRun` and returns its result as `runId` -- `startWorkflow` does NOT call `pollAgentRun` -- `isAnalyzing` is `true` during `startWorkflow` and `false` after - ---- - -**TASK-302** — Refactor `src/locations/Page/components/mainpage/ModalOrchestrator.tsx` - -Changes: -1. Add `onRunStarted: (runId: string) => void` prop -2. Remove `onMappingReviewReady` prop -3. Add `documentTitle?: string` prop (passed from Google Picker result — `SelectDocumentModal` already has the doc title from the picker response; thread it up) -4. Add `useRunStorage` usage: inject `spaceId`/`environmentId` from `sdk.ids` and call `addRun` after `startWorkflow` resolves -5. `startWorkflowWithScope` refactored: - ```ts - const startWorkflowWithScope = async (contentTypeIds, documentSelection) => { - setFlowStep(FlowStep.LOADING); - try { - const runId = await startWorkflow(contentTypeIds, documentSelection); - addRun({ - runId, - documentTitle: documentTitle ?? 'Untitled Document', - documentId, - contentTypeIds, - startedAt: new Date().toISOString(), - }); - setFlowStep(null); - onRunStarted(runId); - } catch (err) { - handleWorkflowError(err); - } - }; - ``` -6. Remove `handleWorkflowResult` (the PENDING_REVIEW branching is gone — polling determines that now) -7. Remove dead state: `activeRunId` (was never read) -8. Keep `FlowStep.LOADING` spinner during the `startAgentRun` call (still ~1-2 seconds to register the run) - -**Tests** — update `test/locations/Page/components/mainpage/ModalOrchestrator.spec.tsx`: -- On wizard completion, calls `addRun` with correct RunRecord fields -- On wizard completion, calls `onRunStarted(runId)` -- Does NOT call `onMappingReviewReady` (removed) -- Shows loading spinner during `startWorkflow` -- On `startWorkflow` error, shows error modal (existing error path) -- If `storageError` is set (localStorage unavailable), shows error to user before proceeding - ---- - -## Slice 4 — `RunsPage` + `RunRow` UI - -**Goal**: The Runs page home screen. Depends on Slices 1 + 2. - -### Tasks - -**TASK-401** — Add `src/locations/Page/components/runs/RunRow.tsx` - -Single run row using F36 components. Displays: -- Document title (bold) -- Content type IDs (comma-separated, or truncated if >3) -- `startedAt` formatted as relative time ("2 hours ago") or absolute date if >24h -- Status badge using F36 `Badge` component with appropriate `variant`: - - `loading` → spinner (no badge) - - `running` → `Badge variant="primary"` + `Spinner` (small) - - `needs-review` → `Badge variant="warning"` "Needs Review" - - `completed` → `Badge variant="positive"` "Completed" - - `failed` → `Badge variant="negative"` "Failed" - - `expired` → `Badge variant="secondary"` "Expired" -- Actions per status (see contracts/ui-contracts.md) -- For `completed`: render one `TextLink` per `createdEntryId` pointing to the entry in Contentful web app (`https://app.contentful.com/spaces/{spaceId}/entries/{entryId}`) -- For `failed`: render `errorMessage` in a small `Note variant="negative"` collapsed by default with a "Show details" toggle - -**Tests** — `test/locations/Page/components/runs/RunRow.spec.tsx`: -- Renders document title -- Renders correct badge per status -- "Review" button present for `needs-review`, calls `onReview` -- "Dismiss" button present for `failed`/`expired`, calls `onDismiss` -- Entry links rendered for `completed` with correct URLs -- Error message rendered for `failed` - ---- - -**TASK-402** — Add `src/locations/Page/components/runs/RunsPage.tsx` - -```ts -interface RunsPageProps { - sdk: PageAppSDK; - onNewImport: () => void; - onReviewRun: (runId: string) => void; -} -``` - -Implementation: -1. Call `useRunStorage(sdk.ids.space, sdk.ids.environment)` to get `runs` -2. Call `useRunsPolling(runs, sdk)` to get `statusMap` -3. Merge into `RunWithStatus[]` for rendering -4. Call `removeRun` on dismiss -5. Show `storageError` as a `Note variant="negative"` if set - -**Empty state**: F36 `EmptyState` or equivalent — "No imports yet" with a "Start your first import" button calling `onNewImport`. - -**Header**: "Import Runs" heading + "New Import" `Button variant="primary"` (always visible). - -**Tests** — `test/locations/Page/components/runs/RunsPage.spec.tsx`: -- Renders empty state when `runs` is empty -- Renders a `RunRow` per run -- Clicking "New Import" calls `onNewImport` -- Clicking "Review" on a needs-review run calls `onReviewRun(runId)` -- Clicking "Dismiss" on a failed run calls `removeRun` and the row disappears -- Storage error note shown when `storageError` is set -- Polling is active when a `running` run exists (mock `useRunsPolling`) - ---- - -## Slice 5 — Wire Everything in `Page.tsx` + `ReviewPage` callback - -**Goal**: Plug all the pieces together. This is the integration slice. Depends on all prior slices. - -### Tasks - -**TASK-501** — Refactor `src/locations/Page/Page.tsx` - -Replace entire state model: - -```ts -// Remove: -const [mappingReviewState, setMappingReviewState] = useState<...>(null); -const { resumeWorkflow } = useWorkflowAgent({ sdk, documentId: '', oauthToken: '' }); // stub - -// Add: -const [appView, setAppView] = useState({ view: 'runs' }); -const { runs, addRun, removeRun, markCompleted, storageError } = - useRunStorage(sdk.ids.space, sdk.ids.environment); -``` - -Callbacks: -```ts -const handleRunStarted = (runId: string) => setAppView({ view: 'runs' }); -const handleReviewRun = (runId: string) => setAppView({ view: 'review', runId }); -const handleExitReview = () => setAppView({ view: 'runs' }); -const handleRunCompleted = (runId: string, entryIds: string[]) => markCompleted(runId, entryIds); -const handleCancelReview = async (runId?: string) => { - if (runId) await resumeAndPollWorkflow(sdk, runId, { cancelled: true }); - setAppView({ view: 'runs' }); -}; -``` - -Render switch: -```tsx -if (aiAccessDeniedMessage) return {aiAccessDeniedMessage}; - -switch (appView.view) { - case 'runs': - return ( - setAppView({ view: 'import' })} - onReviewRun={handleReviewRun} - /> - ); - case 'import': - return ( - - // ModalOrchestrator inside MainPageView receives: - // onRunStarted={handleRunStarted} - ); - case 'review': - return ( - handleCancelReview(appView.runId)} - onExitReview={handleExitReview} - onRunCompleted={(entryIds) => handleRunCompleted(appView.runId, entryIds)} - /> - ); -} -``` - -Note on `ReviewPage` payload fetch: When navigating to `review` view, `Page.tsx` needs the `MappingReviewSuspendPayload`. Add a loading state in `ReviewPage` itself: on mount with `runId` but no `payload` prop, fetch via `getWorkflowRun()` and render a spinner until it resolves. This keeps `Page.tsx` clean — it passes `runId` only, not the payload. - -Alternatively (simpler): `Page.tsx` manages a `pendingReviewPayload` state, fetches it when `appView.view === 'review'`, and renders a loading screen until it has it. Either approach is valid — prefer whatever makes `ReviewPage` easier to test (probably keeping the fetch in `Page.tsx` since `ReviewPage` already has complex state). - -**Tests** — update `test/locations/Page/Page.spec.tsx`: -- Renders `RunsPage` by default on mount -- `onNewImport` callback transitions to import view -- `onRunStarted` callback transitions back to runs view -- `onReviewRun` callback transitions to review view -- `onExitReview` callback transitions back to runs view -- `onRunCompleted` calls `markCompleted` with correct args -- `aiAccessDeniedMessage` blocks all views (existing test) - ---- - -**TASK-502** — Update `src/locations/Page/components/review/ReviewPage.tsx` - -Changes: -1. Add `onRunCompleted: (entryIds: string[]) => void` prop -2. Replace `useWorkflowAgent` stub with direct `resumeAndPollWorkflow` import -3. Call `onRunCompleted(createdEntries.map(e => e.sys.id))` inside `handleCreateEntries` after `createEntriesFromPreviewPayload` resolves and before `setIsSummaryModalOpen(true)` -4. Add optional payload fetch: if a `runId` is present but `payload` is loaded lazily, handle that state (see TASK-501 discussion) -5. Remove the stub `useWorkflowAgent({ sdk, documentId: '', oauthToken: '' })` instantiation - -**Tests** — update `test/locations/Page/components/review/ReviewPage.spec.tsx`: -- `onRunCompleted` called with correct entry IDs after successful creation -- `resumeAndPollWorkflow` called (not the old hook) on "Create selected entries" - ---- - -## Slice 6 — Cleanup - -**TASK-601** — Remove dead code from `useWorkflowAgent.ts` -- Remove `resumeWorkflow` (moved to `workflowService.ts`) -- Remove `isAnalyzing` if no longer consumed -- Remove `pollAgentRun` from this file (it lives in `workflowService.ts` now, or `workflowUtils.ts`) - -**TASK-602** — Verify all existing tests still pass (`npm test`) - -**TASK-603** — Manual smoke test per the CLAUDE.md sprite workflow: -- Start an import → verify redirect to Runs page -- Runs page shows "Running" status -- Status updates to "Needs Review" when agent settles -- Click Review → Review screen loads -- Create entries → status updates to "Completed" with entry links -- Start two concurrent imports → both appear independently - ---- - -## File Inventory - -### New Files -``` -src/types/runs.ts -src/hooks/useRunStorage.ts -src/hooks/useRunsPolling.ts -src/services/workflowService.ts -src/locations/Page/components/runs/RunsPage.tsx -src/locations/Page/components/runs/RunRow.tsx -test/hooks/useRunStorage.test.ts -test/hooks/useRunsPolling.test.ts -test/services/workflowService.test.ts -test/locations/Page/components/runs/RunsPage.spec.tsx -test/locations/Page/components/runs/RunRow.spec.tsx -``` - -### Modified Files -``` -src/types/workflow.ts (no change — all types stay) -src/hooks/useWorkflowAgent.ts (startWorkflow return type; remove resumeWorkflow) -src/services/agents-api.ts (no change) -src/locations/Page/Page.tsx (AppView state machine; new callbacks) -src/locations/Page/components/mainpage/ModalOrchestrator.tsx (async exit; new props) -src/locations/Page/components/review/ReviewPage.tsx (onRunCompleted; remove hook stub) -test/locations/Page/Page.spec.tsx (update for new state model) -test/locations/Page/components/mainpage/ModalOrchestrator.spec.tsx (update for new props) -test/locations/Page/components/review/ReviewPage.spec.tsx (update for onRunCompleted) -``` - -### Untouched Files -``` -functions/ (all OAuth handlers) -src/hooks/useGoogleDriveOAuth.ts -src/services/entryService.ts -src/services/referenceResolution.ts -src/services/richtext.ts -src/locations/Page/components/review/mapping/ (all) -src/locations/Page/components/mainpage/SelectDocumentModal.tsx -src/locations/Page/components/mainpage/ContentTypePickerModal.tsx -src/locations/Page/components/mainpage/SelectTabsModal.tsx -src/locations/Page/components/mainpage/IncludeImagesModal.tsx -src/locations/Page/components/mainpage/LoadingModal.tsx -src/locations/Page/components/mainpage/ErrorModal.tsx -src/locations/Page/components/mainpage/ConfirmCancelModal.tsx -src/locations/Page/components/mainpage/OAuthConnector.tsx -src/locations/Page/components/mainpage/MainPageView.tsx -src/locations/ConfigScreen/ -src/App.tsx -src/index.tsx -``` - ---- - -## Risk Register - -| Risk | Likelihood | Impact | Mitigation | -|---|---|---|---| -| `sdk.cma.agentRun.get()` doesn't return `suspendPayload` on re-fetch | Medium | High | Test in dev with a real PENDING_REVIEW run before building ReviewPage load path | -| localStorage quota exceeded on large suspend payloads | Low | Medium | Spec says payloads are NOT stored in localStorage — only RunRecord metadata (~400B each) | -| `PENDING_REVIEW` runs never poll (polling stops on settle) | None | High | Research Decision 4 explicitly addresses this: `PENDING_REVIEW` is stable, no polling needed | -| Two tabs writing `addRun` simultaneously | Low | Low | Last write wins — one run may be lost if both tabs add at exactly the same millisecond; acceptable per spec | -| Google Picker doesn't return `documentTitle` | Medium | Low | Fall back to `'Untitled Document'`; the title is cosmetic | diff --git a/apps/drive-integration/.specify/features/001-async-import-runs/research.md b/apps/drive-integration/.specify/features/001-async-import-runs/research.md deleted file mode 100644 index 87e62e3522..0000000000 --- a/apps/drive-integration/.specify/features/001-async-import-runs/research.md +++ /dev/null @@ -1,123 +0,0 @@ -# Research: Async Import Runs - -**Feature**: 001-async-import-runs -**Date**: 2026-07-16 - ---- - -## Decision 1: In-App Router Strategy - -**Decision**: Lightweight view-state machine using a `AppView` discriminated union in a single `useAppView` hook, managed in `App.tsx` / `Page.tsx`. No third-party router library. - -**Rationale**: The app is a Contentful single-page location — there is no URL bar the user can navigate, and React Router would add routing logic that can never be exercised. A discriminated union (`{ view: 'runs' } | { view: 'import' } | { view: 'review', runId: string }`) is exhaustively type-safe and trivially tested. It replaces the current binary `mappingReviewState !== null` toggle in `Page.tsx` with a three-way switch. - -**Alternatives considered**: -- React Router (memory history): technically feasible but brings ~10kb of unnecessary dependency for a problem solvable in 30 lines. -- Contentful `sdk.navigator`: designed for navigating between entries/assets, not for in-app view management. - ---- - -## Decision 2: localStorage Schema and Key Strategy - -**Decision**: One localStorage key per app installation scoped by `spaceId + environmentId`: `gdrive-import-runs::{spaceId}::{environmentId}`. Value is a JSON array of `RunRecord` objects, ordered by `startedAt` descending. Max 50 records (oldest are pruned when limit is hit). - -**Rationale**: -- Scoping by space+environment prevents run records from one space polluting another when the same browser is used across spaces. -- A simple array is easy to read/write atomically — no complex indexing needed at the expected scale (max 20 runs per SC-005). -- 50-record cap ensures localStorage quota is never a realistic concern (each record is ~400 bytes of JSON metadata, no payloads stored). - -**Alternatives considered**: -- One key per run ID: Requires listing all keys to load the runs page — not possible with the standard `localStorage` API without iterating all keys. -- IndexedDB: Provides async reads and larger quota, but is heavy machinery for a small metadata store. -- Contentful app installation parameters: 32KB limit and shared state across users on the same space — inappropriate for per-user run history. - -**`RunRecord` schema** (stored in localStorage): -```ts -interface RunRecord { - runId: string; // Contentful agentRun sys.id - documentTitle: string; // Google Doc title (may be 'Untitled' if unavailable) - documentId: string; // Google Doc ID - contentTypeIds: string[]; // IDs selected in the wizard - startedAt: string; // ISO 8601 timestamp - createdEntryIds?: string[]; // populated after successful entry creation -} -``` -Note: `status` is intentionally NOT stored. It is always fetched live from the backend (FR-007). `createdEntryIds` is the only mutable field — written on completion. - ---- - -## Decision 3: Suspend Payload Fetch Strategy - -**Decision**: The `MappingReviewSuspendPayload` is NOT stored in localStorage. When the user clicks "Review" on a `PENDING_REVIEW` run, a fresh `getWorkflowRun()` call fetches the full run data (including `metadata.suspendPayload`) from the backend. A loading state is shown during this fetch. - -**Rationale**: -- `MappingReviewSuspendPayload` contains the full normalized document, entry block graph, and reference graph. Real-world documents produce payloads in the range of 100KB–1MB+ of JSON. Storing this in localStorage risks hitting the 5MB quota, especially for users with multiple `PENDING_REVIEW` runs. -- The payload is already durably stored on Contentful's backend for the lifetime of the agent run. Re-fetching it on demand is reliable and fast (single API call, ~200ms). -- This simplifies the localStorage record structure and eliminates quota management complexity. - -**Alternatives considered**: -- Store suspend payload in localStorage: Risk of quota failure for large documents; creates stale data if the backend payload is ever updated. -- Store payload in sessionStorage: Not durable across browser restarts — defeats the purpose. - ---- - -## Decision 4: Polling Architecture on the Runs Page - -**Decision**: A single `useRunsPolling` hook in `RunsPage` manages all polling. It accepts the list of `RunRecord`s, fetches status for all runs in parallel on mount (and every 10s if any run is `IN_PROGRESS`/`PENDING_REVIEW` — i.e., not yet settled). It maintains a `Map` as its return value. The Runs page merges this with the localStorage records to derive display state. - -**Rationale**: -- Parallel fetch per run (via `Promise.all`) means the page loads quickly even with 10+ runs. -- Stopping polling when all runs are settled prevents unnecessary API calls. -- The status map is kept separate from the `RunRecord` array so that the localStorage layer stays read-mostly (only writes on completion). - -**Polling stop conditions**: Stop polling a run when its fetched status is `COMPLETED`, `FAILED`, or `null` (404 = expired). Continue polling while status is `IN_PROGRESS`, `DRAFT`, or `PENDING_REVIEW`. - -Wait — `PENDING_REVIEW` does not need continued polling once it is detected; it is a stable state the user must act on. Polling SHOULD stop for `PENDING_REVIEW` runs (they won't change without user input). Only `IN_PROGRESS` and `DRAFT` need continued polling. - ---- - -## Decision 5: Wizard Exit Behavior - -**Decision**: In `ModalOrchestrator`, `startWorkflowWithScope` is refactored to: -1. Call `startAgentRun()` (fire-and-forget — get only the `runId`) -2. Save a `RunRecord` to localStorage via `useRunStorage` -3. Call a new `onRunStarted(runId)` prop callback (replaces `onMappingReviewReady`) -4. Page.tsx responds by navigating to the Runs view - -The `pollAgentRun` call is removed from `useWorkflowAgent.startWorkflow`. The hook's `startWorkflow` method only starts the run; it no longer blocks. - -**What changes in `useWorkflowAgent`**: -- `startWorkflow` becomes a thin wrapper: generates the payload, calls `startAgentRun`, returns `runId`. No polling. -- `pollAgentRun` moves to `useRunsPolling` hook (used by RunsPage). -- `resumeWorkflow` is unchanged — it still calls `resumeWorkflowRun` then `pollAgentRun`. This blocking behavior is intentional for the post-review entry creation step. - ---- - -## Decision 6: `resumeWorkflow` Cleanup - -**Decision**: `resumeWorkflow` is extracted from `useWorkflowAgent` into a standalone service function `resumeAndPollWorkflow(sdk, runId, resumePayload): Promise`. `ReviewPage` calls this directly from a local async handler. `useWorkflowAgent` is simplified to just `startWorkflow`. - -**Rationale**: Both Page.tsx and ReviewPage.tsx currently instantiate `useWorkflowAgent` with stub `documentId`/`oauthToken` params just to get `resumeWorkflow`. The hook was never designed as a resume mechanism — it was incidental coupling. A plain async function is simpler, more testable, and eliminates the stub-param anti-pattern. - ---- - -## Decision 7: Run Status Staleness for 404s - -**Decision**: If `getWorkflowRun()` returns `null` (404), the run's display status is `'EXPIRED'`. This is a local-only status (not part of the `RunStatus` enum from the backend). The run row shows "Expired" in neutral/grey styling with a "Dismiss" action. No retry logic. - -**Rationale**: A 404 on an agentRun is permanent — Contentful does not restore deleted runs. Retrying would confuse the user. Displaying "Expired" with a dismiss action is the cleanest UX per FR-015. - ---- - -## Existing Code Reuse / No-Change Zones - -The following files are **unchanged** by this rearchitecture: -- All `functions/oauth/` — OAuth app functions are untouched -- `useGoogleDriveOAuth` hook — OAuth flow is unaffected -- `entryService.ts` — entry creation logic is unchanged -- `referenceResolution.ts` — unchanged -- `richtext.ts` — unchanged -- `MappingView.tsx` and all child components — the mapping review UX is unchanged -- `ReviewPage.tsx` props interface changes minimally (adds `onRunCompleted` callback) -- All content type picker, tab picker, image picker modals — wizard step UX is unchanged -- `agents-api.ts` — no changes to API call functions; only callers change diff --git a/apps/drive-integration/.specify/features/001-async-import-runs/spec.md b/apps/drive-integration/.specify/features/001-async-import-runs/spec.md deleted file mode 100644 index f1e48e4e0c..0000000000 --- a/apps/drive-integration/.specify/features/001-async-import-runs/spec.md +++ /dev/null @@ -1,148 +0,0 @@ -# Feature Specification: Async Import Runs - -**Feature Branch**: `001-async-import-runs` -**Created**: 2026-07-16 -**Status**: Draft - -## User Scenarios & Testing *(mandatory)* - -### User Story 1 — Start Import and See It Tracked (Priority: P1) - -A user selects a Google Doc, picks content types, and kicks off an import. Instead of waiting on a spinner for up to 20 minutes, the wizard immediately confirms the import has started and redirects the user to the Runs page. The new run appears at the top of the list with a "Running" status. The user can close the app or navigate elsewhere and return later. - -**Why this priority**: This is the core value unlock of the feature. Every other story depends on a run being created and tracked. Without this, nothing else ships. - -**Independent Test**: Can be fully tested by starting a new import through the wizard and verifying a run record appears on the Runs page with "Running" status — no review or entry creation needed. - -**Acceptance Scenarios**: - -1. **Given** the user has connected Google Drive and completes the wizard (doc selected, content types chosen), **When** they click the final "Start Import" action, **Then** the wizard closes, the user is redirected to the Runs page, and a new run entry appears at the top with status "Running", the document title, selected content types, and the start time. -2. **Given** a run is in "Running" status on the Runs page, **When** the user refreshes the page or returns after navigating away, **Then** the run is still visible and still shows the current live status fetched from the backend. -3. **Given** a run is in "Running" status, **When** the AI analysis completes, **Then** the run status updates to "Needs Review" on the Runs page (within one polling cycle). - ---- - -### User Story 2 — Runs Page Home View (Priority: P1) - -A user opens the Drive Integration app and sees the Runs page as the default home screen. They can see all their past and current imports at a glance — status, document, content types, and when each started. A "New Import" button lets them kick off another import at any time. - -**Why this priority**: The Runs page is the new home of the app. Without it the rearchitecture has no anchor. - -**Independent Test**: Can be fully tested with a seeded set of runs in localStorage (various statuses) — verify each run renders correctly with all required fields, and that the "New Import" button opens the wizard. - -**Acceptance Scenarios**: - -1. **Given** the user has no prior runs, **When** they open the app, **Then** the Runs page shows an empty state with a clear prompt to start their first import. -2. **Given** the user has multiple runs in various statuses, **When** they open the app, **Then** all runs are listed, sorted with most recent first, each showing: document title, content types, started timestamp, and status badge. -3. **Given** the user is on the Runs page, **When** they click "New Import", **Then** the wizard opens (same flow as before, but exits async). -4. **Given** there are one or more "Running" status runs, **When** the Runs page is open, **Then** statuses auto-refresh every 10 seconds without requiring a manual page reload. - ---- - -### User Story 3 — Review a Completed AI Analysis (Priority: P2) - -A user returns to the Runs page and sees a run with "Needs Review" status. They click the "Review" button, which opens the full mapping review screen pre-loaded with the AI's proposed field mappings. They approve or edit the mappings and create the entries — exactly as today. - -**Why this priority**: This is the path to actually creating entries. P2 because P1 stories must exist first, but this closes the loop on the import workflow. - -**Independent Test**: Can be fully tested by seeding a run with `PENDING_REVIEW` status and a `suspendPayload` in localStorage, clicking "Review", and completing the entry creation flow. - -**Acceptance Scenarios**: - -1. **Given** a run has status "Needs Review", **When** the user clicks the "Review" button, **Then** the Review screen opens with the mapping payload for that run. -2. **Given** the user is on the Review screen for a run, **When** they approve mappings and click "Create selected entries", **Then** entries are created in Contentful synchronously (same as today) and the run status updates to "Completed". -3. **Given** a run transitions to "Completed" after entry creation, **When** the user returns to the Runs page, **Then** the run shows a "Completed" badge and a link to the created entries in Contentful. - ---- - -### User Story 4 — Handle Failed Runs (Priority: P3) - -A user sees a run with "Failed" status on the Runs page. The run entry shows an error summary, and the user can dismiss/remove the run from their list. - -**Why this priority**: Error visibility is important but doesn't block the happy path. - -**Independent Test**: Can be fully tested by seeding a run with `FAILED` status and an error message in localStorage. - -**Acceptance Scenarios**: - -1. **Given** a run transitions to "Failed" status, **When** the user views the Runs page, **Then** the run shows a "Failed" badge and a human-readable error summary. -2. **Given** a run is in "Failed" status, **When** the user clicks "Dismiss" (or equivalent), **Then** the run is removed from the list. - ---- - -### User Story 5 — Multiple Concurrent Imports (Priority: P3) - -A user starts a second import while a first one is still running. Both runs appear on the Runs page simultaneously with independent statuses. - -**Why this priority**: Concurrency is a requirement but doesn't require special flows — it emerges naturally if the data model supports multiple runs. - -**Independent Test**: Can be fully tested by starting two wizard flows back-to-back and verifying both runs appear independently on the Runs page. - -**Acceptance Scenarios**: - -1. **Given** one run is already in "Running" status, **When** the user clicks "New Import" and completes the wizard, **Then** a second run appears on the Runs page with its own independent "Running" status, and the first run is unaffected. -2. **Given** two runs are running simultaneously, **When** one reaches "Needs Review", **Then** only that run's status changes; the other remains "Running". - ---- - -### Edge Cases - -- What happens if the user clears their browser data (localStorage wiped)? Runs list becomes empty with no way to recover in-progress runs. -- What happens if the backend run ID no longer exists when the Runs page tries to fetch its status? Show the run as "Unknown / Expired" rather than crashing. -- What happens if the user opens the app in two browser tabs simultaneously? Both tabs read from the same localStorage, but polling in both tabs is acceptable — no write conflict since runs are only added (not mutated) in localStorage. -- What happens if an import is started on one device and the user opens the app on another? The run will not appear (localStorage is device-specific). This is a known limitation. -- What happens if the Review screen is opened for a run whose suspend payload is very large? The payload is stored in localStorage alongside the run metadata; if localStorage quota is exceeded, the app must gracefully handle the write failure. - ---- - -## Requirements *(mandatory)* - -### Functional Requirements - -- **FR-001**: The app MUST display the Runs page as the default home screen when opened. -- **FR-002**: The Runs page MUST list all tracked import runs, sorted by start time descending (most recent first). -- **FR-003**: Each run entry MUST display: document title, content type names, start timestamp, and status badge. -- **FR-004**: The Runs page MUST provide a "New Import" action that opens the import wizard. -- **FR-005**: When the wizard's final step is completed, the app MUST immediately create a run record in persistent local storage and redirect to the Runs page, without waiting for the AI analysis to complete. -- **FR-006**: Run records in local storage MUST contain at minimum: run ID, document title, document ID, selected content type IDs, and start timestamp. -- **FR-007**: The Runs page MUST fetch live status from the backend for each stored run ID on load. -- **FR-008**: When any run is in "Running" status, the Runs page MUST automatically refresh run statuses every 10 seconds. -- **FR-009**: A run in "Needs Review" status MUST show a "Review" action that opens the mapping review screen for that run. -- **FR-010**: The mapping review screen MUST be loadable from the Runs page (not only from the wizard flow). -- **FR-011**: After the user approves mappings and entries are created, the run status MUST update to "Completed" and the run entry MUST display links to the created Contentful entries. -- **FR-012**: A run in "Failed" status MUST display a human-readable error summary. -- **FR-013**: The user MUST be able to dismiss/remove a "Failed" run from the list. -- **FR-014**: Multiple runs MUST be trackable simultaneously with independent statuses. -- **FR-015**: If a run ID stored locally is no longer resolvable from the backend, the run MUST display an "Expired" or "Unknown" status rather than causing an error. -- **FR-016**: If local storage write fails (e.g., quota exceeded), the app MUST inform the user that run tracking is unavailable and the import cannot proceed, rather than silently losing the run record. - -### Key Entities - -- **Run Record**: Represents a single import attempt. Key attributes: unique run ID (from backend), document title, document ID, selected content type IDs (array), start timestamp, status (Running / Needs Review / Completed / Failed / Expired). Stored in local browser storage. Status is always fetched live from the backend — not persisted locally. -- **Suspend Payload**: The AI-generated field mapping proposal for a run in "Needs Review" state. Fetched from the backend run data when the Review screen is opened. Not stored in local storage (to avoid quota issues). -- **Created Entries Summary**: The list of Contentful entry IDs created after a successful review. Stored alongside the run record in local storage upon completion so the Runs page can show links without a backend call. - ---- - -## Success Criteria *(mandatory)* - -### Measurable Outcomes - -- **SC-001**: After completing the import wizard, the user is on the Runs page within 2 seconds — no waiting on AI analysis. -- **SC-002**: A user who navigates away from the app and returns finds all their previous runs still visible with up-to-date statuses. -- **SC-003**: Run status on the Runs page reflects actual backend state within 10 seconds of a status change. -- **SC-004**: A user can start, track, review, and complete an import without ever being blocked by a loading screen lasting more than 5 seconds (excluding the review screen's entry-creation step, which is bounded by Contentful API response times). -- **SC-005**: A user can have at least 20 run records tracked simultaneously without degraded performance on the Runs page. -- **SC-006**: 100% of runs started through the wizard appear on the Runs page immediately after wizard completion, with no data loss on navigation or refresh. - ---- - -## Assumptions - -- The Contentful `sdk.cma.agentRun.get()` API is sufficient to retrieve run status and suspend payload for any run ID. No new backend functions are required. -- The Contentful `sdk.cma.agentRun.resumeRun()` API continues to work as today for the review → entry creation step. -- `localStorage` is available and has sufficient quota for the expected volume of run records (metadata only; suspend payloads are not stored locally). -- Run records are user- and device-specific; cross-device sync is explicitly out of scope. -- The app continues to use the Contentful App Framework single-page location model; a lightweight in-app router (view state machine) will replace the current binary main/review toggle — no third-party routing library is required. -- The OAuth connect/disconnect flow is unaffected by this rearchitecture. -- The import wizard steps (file picker, content type picker, tabs, images) are unchanged; only the exit behavior changes. diff --git a/apps/drive-integration/.specify/features/001-async-import-runs/tasks.md b/apps/drive-integration/.specify/features/001-async-import-runs/tasks.md deleted file mode 100644 index 771942398e..0000000000 --- a/apps/drive-integration/.specify/features/001-async-import-runs/tasks.md +++ /dev/null @@ -1,275 +0,0 @@ -# Tasks: Async Import Runs - -**Input**: Design documents from `.specify/features/001-async-import-runs/` -**Prerequisites**: plan.md ✅ spec.md ✅ data-model.md ✅ contracts/ui-contracts.md ✅ research.md ✅ - -**Approach**: Red/Green TDD per project CLAUDE.md — write failing tests first, then implement. - -**Organization**: Tasks grouped by user story. Foundation phase (Slices 1–2) blocks all user stories. - -## Format: `[ID] [P?] [Story] Description` - -- **[P]**: Can run in parallel (different files, no dependencies on incomplete tasks) -- **[Story]**: Which user story this task belongs to (US1–US5) - ---- - -## Phase 1: Setup (Scaffolding) - -**Purpose**: Create all new files and directories so parallel work can begin. - -- [ ] T001 Create `src/types/runs.ts` (empty exports as scaffold) -- [ ] T002 Create `src/hooks/useRunStorage.ts` (empty export as scaffold) -- [ ] T003 [P] Create `src/hooks/useRunsPolling.ts` (empty export as scaffold) -- [ ] T004 [P] Create `src/services/workflowService.ts` (empty export as scaffold) -- [ ] T005 [P] Create `src/locations/Page/components/runs/` directory with empty `RunsPage.tsx` and `RunRow.tsx` -- [ ] T006 [P] Create `test/hooks/` directory -- [ ] T007 [P] Create `test/locations/Page/components/runs/` directory - -**Checkpoint**: Directory structure exists — parallel foundation work can begin - ---- - -## Phase 2: Foundational (Blocking Prerequisites) - -**Purpose**: Core data layer, storage, polling, and service extraction that ALL user stories depend on. No user story can be worked until this phase is complete. - -**⚠️ CRITICAL**: Phases 3–7 are blocked until this phase is complete. - -### Types - -- [ ] T008 [P] Define `RunRecord`, `DisplayStatus`, `RunWithStatus`, `AppView` types in `src/types/runs.ts` per data-model.md - -### `useRunStorage` — TDD - -- [ ] T009 [P] Write failing tests for `useRunStorage` in `test/hooks/useRunStorage.test.ts`: addRun persists + updates state, addRun idempotent on duplicate runId, addRun prunes to 50 records, removeRun removes correct record, markCompleted writes createdEntryIds, initializes from existing localStorage on mount, sets storageError on localStorage exception, key scoped by spaceId+environmentId -- [ ] T010 Implement `useRunStorage(spaceId, environmentId)` in `src/hooks/useRunStorage.ts` — localStorage key `gdrive-import-runs::${spaceId}::${environmentId}`, RunRecord[] state, addRun/removeRun/markCompleted/storageError per contracts/ui-contracts.md (depends on T008, T009) - -### `workflowService.ts` — TDD - -- [ ] T011 [P] Write failing tests for `resumeAndPollWorkflow` in `test/services/workflowService.test.ts`: calls resumeWorkflowRun with correct args, calls pollAgentRun after resume, returns WorkflowRunResult on success, throws WorkflowRunError on failure -- [ ] T012 Extract `resumeAndPollWorkflow(sdk, runId, resumePayload)` into `src/services/workflowService.ts` — move pollAgentRun, getWorkflowRunResult, getRunStatus logic from `src/hooks/useWorkflowAgent.ts` into this file; re-export from useWorkflowAgent for backward compat during transition (depends on T011) - -### `useRunsPolling` — TDD - -- [ ] T013 [P] Write failing tests for `useRunsPolling` in `test/hooks/useRunsPolling.test.ts`: maps IN_PROGRESS→'running', PENDING_REVIEW→'needs-review', COMPLETED→'completed', FAILED→'failed', null(404)→'expired'; initializes all runIds as 'loading'; sets up 10s polling interval when any run is 'running'; clears interval when all runs settled; fetches all runs in parallel -- [ ] T014 Implement `useRunsPolling(runs, sdk)` in `src/hooks/useRunsPolling.ts` — parallel Promise.all fetch via getWorkflowRun(), Map state, 10s interval while any 'running', stops when all settled; also returns errorMap: Map for failed run error messages (depends on T008, T013) - -### `useWorkflowAgent` refactor — TDD - -- [ ] T015 [P] Write failing tests for refactored `useWorkflowAgent` in `test/hooks/useWorkflowAgent.test.ts` (or update existing): startWorkflow calls startAgentRun and returns runId string only; startWorkflow does NOT call pollAgentRun; isAnalyzing is true during startWorkflow and false after -- [ ] T016 Refactor `src/hooks/useWorkflowAgent.ts`: change startWorkflow to return `Promise` (runId only, no poll); remove resumeWorkflow from hook (it now lives in workflowService.ts); keep isAnalyzing (depends on T012, T015) - -**Checkpoint**: Foundation complete — storage, polling, service extraction, and workflow hook refactor all tested and passing. User story phases can now begin. - ---- - -## Phase 3: User Story 1 — Start Import and See It Tracked (Priority: P1) 🎯 MVP - -**Goal**: Wizard fires off the agent run, saves to localStorage, redirects to Runs page immediately. No more 20-minute blocking spinner. - -**Independent Test**: Complete the wizard (mock agentRun start), verify a RunRecord appears in localStorage with correct fields, and verify the `onRunStarted` callback fires with the correct runId — no polling or UI rendering required. - -### ModalOrchestrator refactor — TDD - -- [ ] T017 [P] [US1] Write failing tests for refactored ModalOrchestrator in `test/locations/Page/components/mainpage/ModalOrchestrator.spec.tsx`: on wizard completion calls addRun with correct RunRecord fields (runId, documentTitle, documentId, contentTypeIds, startedAt); calls onRunStarted(runId); does NOT call onMappingReviewReady (removed); shows loading spinner during startWorkflow; shows error modal on startWorkflow failure; shows error note when storageError is set before proceeding -- [ ] T018 [US1] Refactor `src/locations/Page/components/mainpage/ModalOrchestrator.tsx`: add `onRunStarted: (runId: string) => void` prop; remove `onMappingReviewReady` prop; add `documentTitle?: string` prop (thread from Google Picker result); integrate `useRunStorage` (inject spaceId/environmentId from sdk.ids); refactor `startWorkflowWithScope` to fire-and-forget (call startWorkflow → addRun → onRunStarted); remove handleWorkflowResult and PENDING_REVIEW branching; remove dead activeRunId state (depends on T010, T016, T017) - -**Checkpoint**: Wizard completes async. RunRecord written to localStorage. onRunStarted fires. User Story 1 independently verifiable. - ---- - -## Phase 4: User Story 2 — Runs Page Home View (Priority: P1) - -**Goal**: Runs page is the new home screen showing all imports with live status, a "New Import" button, and 10s auto-refresh while any run is in-flight. - -**Independent Test**: Seed localStorage with 3 RunRecords (statuses: running, needs-review, completed), render RunsPage, verify all 3 rows appear with correct badges and actions, verify "New Import" button calls onNewImport, verify polling interval is active. - -### RunRow component — TDD - -- [ ] T019 [P] [US2] Write failing tests for `RunRow` in `test/locations/Page/components/runs/RunRow.spec.tsx`: renders document title; renders correct F36 Badge per DisplayStatus (loading=spinner, running=primary+spinner, needs-review=warning, completed=positive, failed=negative, expired=secondary); "Review" button present for needs-review, calls onReview(runId); "Dismiss" button present for failed/expired, calls onDismiss(runId); entry links rendered for completed with correct Contentful web app URLs; error message rendered for failed -- [ ] T020 [P] [US2] Implement `src/locations/Page/components/runs/RunRow.tsx`: RunWithStatus + onReview + onDismiss props per contracts/ui-contracts.md; F36 Badge, Button, TextLink, Note components; entry links as `https://app.contentful.com/spaces/${spaceId}/entries/${entryId}`; format startedAt as relative time (<24h) or absolute date (depends on T008, T019) - -### RunsPage component — TDD - -- [ ] T021 [P] [US2] Write failing tests for `RunsPage` in `test/locations/Page/components/runs/RunsPage.spec.tsx`: renders empty state when no runs with "Start your first import" CTA; renders RunRow per run record; "New Import" button always visible calls onNewImport; clicking Review on needs-review run calls onReviewRun(runId); clicking Dismiss on failed run calls removeRun and row disappears; storageError Note shown when storageError set; polling hook active when running run exists -- [ ] T022 [US2] Implement `src/locations/Page/components/runs/RunsPage.tsx`: call useRunStorage + useRunsPolling; merge into RunWithStatus[] for rendering; handle removeRun on dismiss; empty state with F36 EmptyState; "Import Runs" heading + "New Import" Button always visible; storageError Note (depends on T010, T014, T020, T021) - -**Checkpoint**: Runs page fully functional — shows all runs, live status, New Import button, dismiss. User Stories 1 + 2 independently verifiable. - ---- - -## Phase 5: User Story 3 — Review a Completed AI Analysis (Priority: P2) - -**Goal**: "Review" button on a needs-review run opens the mapping review screen. Entry creation updates the run to Completed with entry links. - -**Independent Test**: Seed a RunRecord with needs-review status; click Review; verify ReviewPage loads with correct payload (fetched via getWorkflowRun mock); approve mappings; verify onRunCompleted fires with entry IDs and run transitions to completed in localStorage. - -### ReviewPage update — TDD - -- [ ] T023 [P] [US3] Write failing tests for updated `ReviewPage` in `test/locations/Page/components/review/ReviewPage.spec.tsx`: onRunCompleted called with correct entry IDs after successful creation; resumeAndPollWorkflow called (not useWorkflowAgent hook) on "Create selected entries"; onRunCompleted fires before SummaryModal opens -- [ ] T024 [US3] Update `src/locations/Page/components/review/ReviewPage.tsx`: add `onRunCompleted: (entryIds: string[]) => void` prop; replace `useWorkflowAgent` stub instantiation with direct `resumeAndPollWorkflow` import from workflowService.ts; call `onRunCompleted(entries.map(e => e.sys.id))` inside handleCreateEntries after createEntriesFromPreviewPayload resolves (depends on T012, T023) - -### Page.tsx wiring — TDD - -- [ ] T025 [P] [US3] Write failing tests for updated `Page.tsx` in `test/locations/Page/Page.spec.tsx`: renders RunsPage by default on mount; onNewImport transitions to import view; onRunStarted transitions back to runs view; onReviewRun transitions to review view; onExitReview transitions back to runs view; onRunCompleted calls markCompleted with correct args; aiAccessDeniedMessage blocks all views; payload loading spinner shown while fetching suspendPayload for review view -- [ ] T026 [US3] Refactor `src/locations/Page/Page.tsx`: replace `mappingReviewState` toggle with `AppView` state machine (`{ view: 'runs' } | { view: 'import' } | { view: 'review', runId: string }`); remove `useWorkflowAgent` stub instantiation; add `useRunStorage`; add `pendingReviewPayload` state with fetch-on-review-nav via getWorkflowRun; implement handleRunStarted, handleReviewRun, handleExitReview, handleRunCompleted, handleCancelReview callbacks; render switch over AppView (depends on T010, T018, T022, T024, T025) - -**Checkpoint**: Full end-to-end async import flow works — start → runs page → review → create entries → completed. User Stories 1, 2, 3 all independently verifiable. - ---- - -## Phase 6: User Story 4 — Handle Failed Runs (Priority: P3) - -**Goal**: Failed runs show a human-readable error summary and can be dismissed from the list. - -**Independent Test**: Seed a RunRecord with FAILED backend status and a workflowFailure message; render RunsPage; verify "Failed" badge and error summary visible; click Dismiss; verify run removed from list and localStorage. - -> **Note**: RunRow already handles `failed` display status (built in Phase 4, T019–T020). This phase adds the error message extraction from `useRunsPolling`'s `errorMap` and wires it into `RunWithStatus`. - -- [ ] T027 [P] [US4] Write failing tests for error propagation: useRunsPolling returns error message from runData.metadata.workflowFailure for FAILED runs; RunRow renders workflowFailure message in collapsed Note for failed status; Dismiss removes the run and updates localStorage -- [ ] T028 [US4] Wire error messages in `src/hooks/useRunsPolling.ts`: extract `runData.metadata.workflowFailure` into `errorMap: Map`; pass human-readable failure reason string (map WorkflowFailureReason enum to user-facing strings) (depends on T014, T027) -- [ ] T029 [US4] Wire `errorMap` into RunsPage → RunWithStatus in `src/locations/Page/components/runs/RunsPage.tsx`: merge errorMap into RunWithStatus.errorMessage; confirm RunRow renders it (depends on T022, T028) - -**Checkpoint**: Failed runs show meaningful error messages. User Stories 1–4 independently verifiable. - ---- - -## Phase 7: User Story 5 — Multiple Concurrent Imports (Priority: P3) - -**Goal**: Multiple runs can be in-flight simultaneously with independent statuses. This emerges from the data model — no special orchestration needed. - -**Independent Test**: Call addRun twice with different runIds; render RunsPage; verify both rows appear with independent statuses; mock one transitioning to needs-review and verify the other is unaffected. - -> **Note**: Concurrency support is inherent in the array-based localStorage model (Slice 1) and the parallel polling in useRunsPolling (Slice 2). This phase is primarily integration verification and edge case hardening. - -- [ ] T030 [P] [US5] Write failing tests for concurrent run scenarios: two runs with different statuses render independently; one run transitioning status does not affect the other; addRun idempotency under rapid successive calls; polling fetches all runs in parallel (Promise.all fires all before any await) -- [ ] T031 [US5] Verify concurrent behavior in `src/hooks/useRunsPolling.ts` and `src/hooks/useRunStorage.ts` — fix any identified concurrency issues from T030; ensure statusMap updates are atomic (replace full map, not patch individual keys) (depends on T010, T014, T030) - -**Checkpoint**: All 5 user stories independently verifiable and working together. - ---- - -## Phase 8: Polish & Cross-Cutting Concerns - -**Purpose**: Dead code removal, smoke test, and final validation. - -- [ ] T032 [P] Remove dead code from `src/hooks/useWorkflowAgent.ts`: remove isAnalyzing if no longer consumed by any component; remove any remaining pollAgentRun references now fully in workflowService.ts -- [ ] T033 [P] Remove dead code from `src/locations/Page/components/mainpage/ModalOrchestrator.tsx`: confirm activeRunId state and onResetToMain prop are still needed or remove; clean up unused imports -- [ ] T034 Run full test suite (`npm test`) — all tests must pass with no regressions -- [ ] T035 Smoke test per CLAUDE.md sprite workflow: start import → verify redirect to Runs page with 'Running' status; wait for 'Needs Review'; click Review → verify payload loads; create entries → verify 'Completed' with entry links; start two concurrent imports → verify both appear independently; clear localStorage → verify empty state -- [ ] T036 [P] Verify TypeScript compiles with no errors (`npm run build` or `tsc --noEmit`) - ---- - -## Dependencies & Execution Order - -### Phase Dependencies - -``` -Phase 1 (Setup) → no dependencies -Phase 2 (Foundation) → depends on Phase 1 — BLOCKS all user story phases -Phase 3 (US1) → depends on Phase 2 (useRunStorage, useWorkflowAgent refactor) -Phase 4 (US2) → depends on Phase 2 (useRunStorage, useRunsPolling) -Phase 5 (US3) → depends on Phase 3 (ModalOrchestrator), Phase 4 (RunsPage) -Phase 6 (US4) → depends on Phase 4 (RunRow already built), Phase 2 (useRunsPolling errorMap) -Phase 7 (US5) → depends on Phase 2 (storage + polling foundation) -Phase 8 (Polish) → depends on all user story phases -``` - -### User Story Dependencies - -| Story | Depends On | Can Start After | -|---|---|---| -| US1 (P1) | Phase 2 complete | T016 merged | -| US2 (P1) | Phase 2 complete | T014 merged | -| US3 (P2) | US1 + US2 both complete | T022 + T018 merged | -| US4 (P3) | Phase 2 + US2 RunRow built | T022 merged | -| US5 (P3) | Phase 2 complete | T014 merged | - -### Within Each Phase - -- Tests (T-odd scaffold): write FIRST, ensure they FAIL before implementing -- T008 (types) must land before any hook/component that imports from `src/types/runs.ts` -- T012 (workflowService) must land before T016 (useWorkflowAgent refactor) and T024 (ReviewPage) -- T010 (useRunStorage) must land before T018 (ModalOrchestrator) and T022 (RunsPage) -- T014 (useRunsPolling) must land before T022 (RunsPage) - -### Parallel Opportunities - -**Phase 2 can parallelize**: -- T009 + T011 + T013 + T015 (all test-writing tasks) run fully in parallel -- T010 + T012 run in parallel (different files, T008 must land first) -- T014 runs in parallel with T016 (different files) - -**Phase 3 + Phase 4 can run in parallel** (different files, both only need Phase 2): -- Developer A: T017 → T018 (ModalOrchestrator) -- Developer B: T019 → T020 → T021 → T022 (RunRow + RunsPage) - ---- - -## Parallel Example: Foundation (Phase 2) - -``` -After T008 (types) lands: - -Parallel track A (storage): - T009 → T010 (useRunStorage test → impl) - -Parallel track B (service): - T011 → T012 (workflowService test → impl) - -Parallel track C (polling): - T013 → T014 (useRunsPolling test → impl) - -Parallel track D (workflow hook): - T015 → T016 (useWorkflowAgent test → refactor) -``` - -## Parallel Example: User Stories 1 + 2 (Phases 3 + 4) - -``` -After Phase 2 complete: - -Parallel track A (US1): - T017 → T018 (ModalOrchestrator tests → refactor) - -Parallel track B (US2): - T019 → T020 (RunRow tests → impl) - T021 → T022 (RunsPage tests → impl, depends on T020) -``` - ---- - -## Implementation Strategy - -### MVP (User Stories 1 + 2 only) - -1. Complete Phase 1: Setup -2. Complete Phase 2: Foundation — T008 → [T009, T011, T013, T015] → [T010, T012, T014, T016] -3. Complete Phase 3: US1 — T017 → T018 -4. Complete Phase 4: US2 — T019 → T020 → T021 → T022 -5. **STOP and VALIDATE**: Wizard exits async, Runs page shows live status -6. US3 wires it all together (Phase 5) - -### Incremental Delivery - -1. Phase 1+2 (Foundation) → core data layer ready -2. Phase 3 (US1) → async wizard exit -3. Phase 4 (US2) → Runs page home -4. Phase 5 (US3) → review flow reconnected -5. Phase 6 (US4) → failed run UX -6. Phase 7 (US5) → concurrency validated -7. Phase 8 (Polish) → cleanup + smoke test - ---- - -## Notes - -- [P] = different files, no dependency conflicts — safe to parallelize -- Story labels map to spec.md: US1=Story1, US2=Story2, US3=Story3, US4=Story4, US5=Story5 -- TDD: every implementation task has a paired test task that must be written and FAILING first -- `src/types/runs.ts` (T008) is a shared dependency — land it before any parallel foundation work -- Do not store `MappingReviewSuspendPayload` in localStorage — re-fetch from backend on Review nav (research.md Decision 3) -- Files explicitly NOT touched: `functions/`, `entryService.ts`, `referenceResolution.ts`, `richtext.ts`, `MappingView.tsx`, all modal step components, `agents-api.ts` -- Total tasks: **36** across 8 phases diff --git a/apps/drive-integration/src/fixtures/FixtureHarness.tsx b/apps/drive-integration/src/fixtures/FixtureHarness.tsx deleted file mode 100644 index 79d30c0062..0000000000 --- a/apps/drive-integration/src/fixtures/FixtureHarness.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { Layout } from '@contentful/f36-components'; -import type { PageAppSDK } from '@contentful/app-sdk'; -import { ReviewPage } from '../locations/Page/components/review/ReviewPage'; -import type { MappingReviewSuspendPayload } from '../types/workflow'; -import fixtureData from './googleDocsReview/fixture.json'; - -const stubSdk = { - locales: { default: 'en-US' }, -} as unknown as PageAppSDK; - -export const FixtureHarness = () => { - return ( - - {}} - onExitReview={() => {}} - /> - - ); -}; diff --git a/apps/drive-integration/src/fixtures/googleDocsReview/fixture.json b/apps/drive-integration/src/fixtures/googleDocsReview/fixture.json deleted file mode 100644 index 42f4a1cca9..0000000000 --- a/apps/drive-integration/src/fixtures/googleDocsReview/fixture.json +++ /dev/null @@ -1,289 +0,0 @@ -{ - "suspendStepId": "mapping-review", - "documentId": "fixture-doc-001", - "documentTitle": "Product Launch Blog Post", - "referenceGraph": { "edges": [], "creationOrder": [] }, - "validationFindings": [ - { - "code": "REQUIRED_FIELD_EMPTY", - "message": "The required field \"Title\" has no mapped content. This entry cannot be created until it is assigned.", - "severity": "block", - "entryIndex": 0, - "fieldId": "title" - }, - { - "code": "FIELD_TYPE_MISMATCH", - "message": "Field \"Body\" is a Rich Text field but the mapped content contains only plain text. Some formatting may be lost on publish.", - "severity": "warn", - "entryIndex": 0, - "fieldId": "body" - }, - { - "code": "CONTENT_TRUNCATED", - "message": "Mapped content for \"Summary\" exceeds the field's character limit (500). It will be truncated on entry creation.", - "severity": "warn", - "entryIndex": 0, - "fieldId": "summary" - }, - { - "code": "DUPLICATE_ENTRY", - "message": "An entry with the same content type and title already exists in this space. Creating this entry may result in a duplicate.", - "severity": "warn", - "entryIndex": 1 - } - ], - "normalizedDocument": { - "documentId": "fixture-doc-001", - "title": "Product Launch Blog Post", - "contentBlocks": [ - { - "id": "block-1", - "position": 0, - "type": "heading", - "headingLevel": 1, - "textRuns": [{ "text": "Introducing Our New Product" }], - "flattenedTextRuns": [{ "text": "Introducing Our New Product", "start": 0, "end": 27 }], - "designValueIds": [], - "imageIds": [] - }, - { - "id": "block-2", - "position": 1, - "type": "paragraph", - "textRuns": [ - { "text": "We are thrilled to announce the launch of " }, - { "text": "Contentful Studio", "styles": { "bold": true } }, - { "text": ", the next generation content platform. This release brings powerful new tools for content teams everywhere." } - ], - "flattenedTextRuns": [ - { "text": "We are thrilled to announce the launch of ", "start": 0, "end": 41 }, - { "text": "Contentful Studio", "start": 41, "end": 58, "styles": { "bold": true } }, - { "text": ", the next generation content platform. This release brings powerful new tools for content teams everywhere.", "start": 58, "end": 167 } - ], - "designValueIds": [], - "imageIds": [] - }, - { - "id": "block-3", - "position": 2, - "type": "paragraph", - "textRuns": [{ "text": "Available starting March 2025 for all Enterprise customers." }], - "flattenedTextRuns": [{ "text": "Available starting March 2025 for all Enterprise customers.", "start": 0, "end": 58 }], - "designValueIds": [], - "imageIds": [] - }, - { - "id": "block-4", - "position": 3, - "type": "heading", - "headingLevel": 2, - "textRuns": [{ "text": "Key Features" }], - "flattenedTextRuns": [{ "text": "Key Features", "start": 0, "end": 12 }], - "designValueIds": [], - "imageIds": [] - }, - { - "id": "block-5", - "position": 4, - "type": "listItem", - "bullet": { "nestingLevel": 0, "ordered": false }, - "textRuns": [{ "text": "Visual content editing with live preview" }], - "flattenedTextRuns": [{ "text": "Visual content editing with live preview", "start": 0, "end": 40 }], - "designValueIds": [], - "imageIds": [] - }, - { - "id": "block-6", - "position": 5, - "type": "listItem", - "bullet": { "nestingLevel": 0, "ordered": false }, - "textRuns": [{ "text": "AI-powered content suggestions" }], - "flattenedTextRuns": [{ "text": "AI-powered content suggestions", "start": 0, "end": 30 }], - "designValueIds": [], - "imageIds": [] - }, - { - "id": "block-7", - "position": 6, - "type": "listItem", - "bullet": { "nestingLevel": 0, "ordered": false }, - "textRuns": [{ "text": "One-click multi-channel publishing" }], - "flattenedTextRuns": [{ "text": "One-click multi-channel publishing", "start": 0, "end": 34 }], - "designValueIds": [], - "imageIds": [] - } - ], - "tables": [ - { - "id": "table-1", - "position": 7, - "headers": ["Plan", "Price", "Users"], - "designValueIds": [], - "imageIds": [], - "rows": [ - { - "id": "row-1", - "cells": [ - { - "id": "cell-1-1", - "parts": [{ "id": "part-1-1-1", "type": "text", "textRuns": [{ "text": "Starter" }], "flattenedTextRuns": [{ "text": "Starter", "start": 0, "end": 7 }] }] - }, - { - "id": "cell-1-2", - "parts": [{ "id": "part-1-2-1", "type": "text", "textRuns": [{ "text": "$99/mo" }], "flattenedTextRuns": [{ "text": "$99/mo", "start": 0, "end": 6 }] }] - }, - { - "id": "cell-1-3", - "parts": [{ "id": "part-1-3-1", "type": "text", "textRuns": [{ "text": "Up to 5" }], "flattenedTextRuns": [{ "text": "Up to 5", "start": 0, "end": 7 }] }] - } - ] - } - ] - } - ], - "images": [] - }, - "contentTypes": [ - { - "sys": { "id": "blogPost" }, - "name": "Blog Post", - "displayField": "title", - "fields": [ - { "id": "title", "name": "Title", "type": "Symbol", "required": true }, - { "id": "body", "name": "Body", "type": "RichText" }, - { "id": "summary", "name": "Summary", "type": "Text" } - ] - }, - { - "sys": { "id": "pricingTable" }, - "name": "Pricing Table", - "displayField": "planName", - "fields": [ - { "id": "planName", "name": "Plan Name", "type": "Symbol", "required": true }, - { "id": "price", "name": "Price", "type": "Symbol" }, - { "id": "userLimit", "name": "User Limit", "type": "Symbol" } - ] - } - ], - "entryBlockGraph": { - "excludedSourceRefs": [], - "entries": [ - { - "contentTypeId": "blogPost", - "tempId": "entry-blog-1", - "fieldMappings": [ - { - "fieldId": "body", - "fieldType": "RichText", - "confidence": 0.88, - "sourceRefs": [ - { - "type": "blockText", - "blockId": "block-2", - "start": 0, - "end": 167, - "flattenedRuns": [ - { "text": "We are thrilled to announce the launch of ", "start": 0, "end": 41 }, - { "text": "Contentful Studio", "start": 41, "end": 58, "styles": { "bold": true } }, - { "text": ", the next generation content platform. This release brings powerful new tools for content teams everywhere.", "start": 58, "end": 167 } - ] - }, - { - "type": "blockText", - "blockId": "block-5", - "start": 0, - "end": 40, - "flattenedRuns": [{ "text": "Visual content editing with live preview", "start": 0, "end": 40 }] - }, - { - "type": "blockText", - "blockId": "block-6", - "start": 0, - "end": 30, - "flattenedRuns": [{ "text": "AI-powered content suggestions", "start": 0, "end": 30 }] - }, - { - "type": "blockText", - "blockId": "block-7", - "start": 0, - "end": 34, - "flattenedRuns": [{ "text": "One-click multi-channel publishing", "start": 0, "end": 34 }] - } - ] - }, - { - "fieldId": "summary", - "fieldType": "Text", - "confidence": 0.80, - "sourceRefs": [ - { - "type": "blockText", - "blockId": "block-3", - "start": 0, - "end": 58, - "flattenedRuns": [{ "text": "Available starting March 2025 for all Enterprise customers.", "start": 0, "end": 58 }] - } - ] - } - ] - }, - { - "contentTypeId": "pricingTable", - "tempId": "entry-pricing-1", - "fieldMappings": [ - { - "fieldId": "planName", - "fieldType": "Symbol", - "confidence": 0.90, - "sourceRefs": [ - { - "type": "tableText", - "tableId": "table-1", - "rowId": "row-1", - "cellId": "cell-1-1", - "partId": "part-1-1-1", - "start": 0, - "end": 7, - "flattenedRuns": [{ "text": "Starter", "start": 0, "end": 7 }] - } - ] - }, - { - "fieldId": "price", - "fieldType": "Symbol", - "confidence": 0.90, - "sourceRefs": [ - { - "type": "tableText", - "tableId": "table-1", - "rowId": "row-1", - "cellId": "cell-1-2", - "partId": "part-1-2-1", - "start": 0, - "end": 6, - "flattenedRuns": [{ "text": "$99/mo", "start": 0, "end": 6 }] - } - ] - }, - { - "fieldId": "userLimit", - "fieldType": "Symbol", - "confidence": 0.90, - "sourceRefs": [ - { - "type": "tableText", - "tableId": "table-1", - "rowId": "row-1", - "cellId": "cell-1-3", - "partId": "part-1-3-1", - "start": 0, - "end": 7, - "flattenedRuns": [{ "text": "Up to 5", "start": 0, "end": 7 }] - } - ] - } - ] - } - ] - } -} From db5e9396c99ae41d52313127e0c8cdd607897e00 Mon Sep 17 00:00:00 2001 From: David Shibley Date: Fri, 17 Jul 2026 09:27:08 -0600 Subject: [PATCH 06/20] fix(drive-integration): remove dangling FixtureHarness import and VITE_ENABLE_MOCK_REVIEW_PAYLOAD branch Co-Authored-By: Claude Sonnet 4.6 --- apps/drive-integration/src/index.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/apps/drive-integration/src/index.tsx b/apps/drive-integration/src/index.tsx index a2badedae1..86b129ce91 100644 --- a/apps/drive-integration/src/index.tsx +++ b/apps/drive-integration/src/index.tsx @@ -4,8 +4,6 @@ import { withLDProvider } from 'launchdarkly-react-client-sdk'; import { createRoot } from 'react-dom/client'; import App from './App'; import LocalhostWarning from './locations/LocalhostWarning'; -import { FixtureHarness } from './fixtures/FixtureHarness'; - const AppWithLD = withLDProvider({ clientSideID: import.meta.env.VITE_LD_CLIENT_ID ?? '', options: { bootstrap: 'localStorage' }, @@ -35,9 +33,7 @@ if (window.location.search.includes('code=') && window.location.search.includes( handleOAuthCallback(); } -if (import.meta.env.VITE_ENABLE_MOCK_REVIEW_PAYLOAD === 'true') { - root.render(); -} else if (process.env.NODE_ENV === 'development' && window.self === window.top) { +if (process.env.NODE_ENV === 'development' && window.self === window.top) { // You can remove this if block before deploying your app root.render(); } else { From 82d7a995d8c2e3f33307325b2cd5aa678f690b1d Mon Sep 17 00:00:00 2001 From: David Shibley Date: Mon, 20 Jul 2026 11:31:05 -0600 Subject: [PATCH 07/20] feat(drive-integration): runs page UI polish and retry feature - Replace OAuthConnector card with compact Connected button - Fix document titles: fall back to doc.title from Google Picker, recover from backend suspendPayload for existing runs stored as "Untitled Document" - Fix forever-loading status: catch per-run API errors so failures degrade to expired instead of blocking the status map - Add retry feature: stores documentSelection on RunRecord and replaces Dismiss with Retry, which restarts the job in-place in the same table row - Add multi-checkbox filter popover (replaces View all select) and style sort-by button with SortAscending/SortDescending icons - Move Status filters below label, left-aligned to match designs - Remove table outer border and row box-shadows; keep per-row dividers - Error message on failed runs moved to tooltip on the Failed badge - Active runs (loading/running/needs-review) always sort above completed rows - Bouncing three-dot indicator in action column for in-progress rows - Match button sizes between Connected and Select file; cap copy max-width 600px Co-Authored-By: Claude Sonnet 4.6 --- .../src/hooks/useGoogleDocPicker.tsx | 2 +- .../src/hooks/useRunStorage.ts | 14 +- .../src/hooks/useRunsPolling.ts | 12 +- .../src/locations/Page/Page.tsx | 54 +++- .../components/mainpage/ModalOrchestrator.tsx | 4 +- .../components/mainpage/OAuthConnector.tsx | 79 ++--- .../modals/step_1/SelectDocumentModal.tsx | 6 +- .../locations/Page/components/runs/RunRow.tsx | 233 +++++++++------ .../Page/components/runs/RunsPage.tsx | 279 ++++++++++++++++-- apps/drive-integration/src/types/runs.ts | 3 +- 10 files changed, 494 insertions(+), 192 deletions(-) diff --git a/apps/drive-integration/src/hooks/useGoogleDocPicker.tsx b/apps/drive-integration/src/hooks/useGoogleDocPicker.tsx index b3b74938ff..1523678f25 100644 --- a/apps/drive-integration/src/hooks/useGoogleDocPicker.tsx +++ b/apps/drive-integration/src/hooks/useGoogleDocPicker.tsx @@ -71,7 +71,7 @@ export function useGoogleDocsPicker( const docs: PickerCallbackData[] = pickedItems.map((doc: any) => ({ id: doc.id, - name: doc.name, + name: doc.name ?? doc.title ?? 'Untitled Document', mimeType: doc.mimeType, url: doc.url, })); diff --git a/apps/drive-integration/src/hooks/useRunStorage.ts b/apps/drive-integration/src/hooks/useRunStorage.ts index 4947829c0f..c2379745e5 100644 --- a/apps/drive-integration/src/hooks/useRunStorage.ts +++ b/apps/drive-integration/src/hooks/useRunStorage.ts @@ -25,6 +25,7 @@ export interface UseRunStorage { runs: RunRecord[]; addRun(record: RunRecord): void; removeRun(runId: string): void; + retryRun(oldRunId: string, newRecord: RunRecord): void; markCompleted(runId: string, entryIds: string[]): void; storageError: string | null; } @@ -84,6 +85,17 @@ export function useRunStorage(spaceId: string, environmentId: string): UseRunSto [persist] ); + const retryRun = useCallback( + (oldRunId: string, newRecord: RunRecord) => { + setRuns((current) => { + const next = current.map((r) => (r.runId === oldRunId ? newRecord : r)); + persist(next); + return next; + }); + }, + [persist] + ); + const markCompleted = useCallback( (runId: string, entryIds: string[]) => { setRuns((current) => { @@ -97,5 +109,5 @@ export function useRunStorage(spaceId: string, environmentId: string): UseRunSto [persist] ); - return { runs, addRun, removeRun, markCompleted, storageError }; + return { runs, addRun, removeRun, retryRun, markCompleted, storageError }; } diff --git a/apps/drive-integration/src/hooks/useRunsPolling.ts b/apps/drive-integration/src/hooks/useRunsPolling.ts index 6642b83697..49ba8f29cd 100644 --- a/apps/drive-integration/src/hooks/useRunsPolling.ts +++ b/apps/drive-integration/src/hooks/useRunsPolling.ts @@ -31,6 +31,7 @@ function extractErrorMessage(runData: AgentRunData | null): string | undefined { export interface UseRunsPollingResult { statusMap: Map; errorMap: Map; + titleMap: Map; } export function useRunsPolling(runs: RunRecord[], sdk: PageAppSDK): UseRunsPollingResult { @@ -38,6 +39,7 @@ export function useRunsPolling(runs: RunRecord[], sdk: PageAppSDK): UseRunsPolli () => new Map(runs.map((r) => [r.runId, 'loading' as DisplayStatus])) ); const [errorMap, setErrorMap] = useState>(new Map()); + const [titleMap, setTitleMap] = useState>(new Map()); const intervalRef = useRef | null>(null); const fetchAllStatuses = useCallback(async () => { @@ -47,11 +49,14 @@ export function useRunsPolling(runs: RunRecord[], sdk: PageAppSDK): UseRunsPolli const environmentId = sdk.ids.environmentAlias ?? sdk.ids.environment; const results = await Promise.all( - runs.map((r) => getWorkflowRun(sdk, spaceId, environmentId, r.runId)) + runs.map((r) => + getWorkflowRun(sdk, spaceId, environmentId, r.runId).catch(() => null) + ) ); const nextStatus = new Map(); const nextErrors = new Map(); + const nextTitles = new Map(); for (let i = 0; i < runs.length; i++) { const runId = runs[i].runId; @@ -59,10 +64,13 @@ export function useRunsPolling(runs: RunRecord[], sdk: PageAppSDK): UseRunsPolli nextStatus.set(runId, toDisplayStatus(data)); const errMsg = extractErrorMessage(data); if (errMsg) nextErrors.set(runId, errMsg); + const title = data?.metadata?.suspendPayload?.documentTitle; + if (title) nextTitles.set(runId, title); } setStatusMap(nextStatus); setErrorMap(nextErrors); + setTitleMap(nextTitles); return nextStatus; }, [runs, sdk]); @@ -105,5 +113,5 @@ export function useRunsPolling(runs: RunRecord[], sdk: PageAppSDK): UseRunsPolli }; }, [fetchAllStatuses]); - return { statusMap, errorMap }; + return { statusMap, errorMap, titleMap }; } diff --git a/apps/drive-integration/src/locations/Page/Page.tsx b/apps/drive-integration/src/locations/Page/Page.tsx index 8b27f70dd1..3579fb9657 100644 --- a/apps/drive-integration/src/locations/Page/Page.tsx +++ b/apps/drive-integration/src/locations/Page/Page.tsx @@ -6,7 +6,6 @@ import { ModalOrchestrator, ModalOrchestratorHandle, } from './components/mainpage/ModalOrchestrator'; -import { MainPageView } from './components/mainpage/MainPageView'; import { ReviewPage } from './components/review/ReviewPage'; import { RunsPage } from './components/runs/RunsPage'; import type { AppView, MappingReviewSuspendPayload } from '@types'; @@ -14,7 +13,8 @@ import { useGoogleDriveOAuth } from '@hooks/useGoogleDriveOAuth'; import { isAiAccessDeniedError } from '../../utils/aiAccess'; import { resumeAndPollWorkflow } from '../../services/workflowService'; import { useRunStorage } from '../../hooks/useRunStorage'; -import { getWorkflowRun } from '../../services/agents-api'; +import { getWorkflowRun, startAgentRun } from '../../services/agents-api'; +import { WORKFLOW_AGENT_ID } from '../../utils/constants/agent'; const Page = () => { const sdk = useSDK(); @@ -29,12 +29,12 @@ const Page = () => { const spaceId = sdk.ids.space; const environmentId = sdk.ids.environmentAlias ?? sdk.ids.environment; - const { runs, addRun, removeRun, markCompleted, storageError } = useRunStorage( + const { runs, addRun, removeRun, retryRun, markCompleted, storageError } = useRunStorage( spaceId, environmentId ); - const { oauthToken, isOAuthConnected, isOAuthLoading, isOAuthBusy, startOAuth, disconnectOAuth } = + const { oauthToken, isOAuthConnected, isOAuthBusy, startOAuth, disconnectOAuth } = useGoogleDriveOAuth(sdk); // When navigating to the review view, fetch the suspend payload from the backend @@ -109,6 +109,40 @@ const Page = () => { setAppView({ view: 'runs' }); }; + const handleRetryRun = async (runId: string) => { + const record = runs.find((r) => r.runId === runId); + if (!record) return; + + const threadId = [crypto.randomUUID(), WORKFLOW_AGENT_ID].join('-'); + const newRunId = await startAgentRun(sdk, spaceId, environmentId, { + messages: [ + { + role: 'user', + parts: [ + { + type: 'text', + text: `Analyze the following google docs document ${record.documentId} and extract the Contentful entries and assets for the following content types: ${record.contentTypeIds.join(', ')}`, + }, + ], + }, + ], + metadata: { + documentId: record.documentId, + contentTypeIds: record.contentTypeIds, + oauthToken, + documentSelection: record.documentSelection, + }, + threadId, + }); + + retryRun(runId, { + ...record, + runId: newRunId, + startedAt: new Date().toISOString(), + createdEntryIds: undefined, + }); + }; + const handleConnectGoogleDrive = async () => { handleAiAccessRestored(); try { @@ -154,21 +188,13 @@ const Page = () => { runs={runs} removeRun={removeRun} storageError={storageError} - onNewImport={() => setAppView({ view: 'import' })} + onStartImport={handleSelectFile} onReviewRun={handleReviewRun} - /> - ); - - case 'import': - return ( - ); diff --git a/apps/drive-integration/src/locations/Page/components/mainpage/ModalOrchestrator.tsx b/apps/drive-integration/src/locations/Page/components/mainpage/ModalOrchestrator.tsx index 73d4967443..a27ace91fd 100644 --- a/apps/drive-integration/src/locations/Page/components/mainpage/ModalOrchestrator.tsx +++ b/apps/drive-integration/src/locations/Page/components/mainpage/ModalOrchestrator.tsx @@ -255,9 +255,10 @@ export const ModalOrchestrator = forwardRef { + const handleUploadModalCloseRequest = (docId?: string, docTitle?: string) => { if (docId) { setDocumentId(docId); + setDocumentTitle(docTitle ?? 'Untitled Document'); setIsUploadModalOpen(false); setFlowStep(FlowStep.CONTENT_TYPE_PICKER); return; @@ -316,6 +317,7 @@ export const ModalOrchestrator = forwardRef { + if (isOAuthConnected) { + setIsHoveringConnected(true); + } + }} + onMouseLeave={() => { + setIsHoveringConnected(false); }}> - - - Drive Integration - - - Drive Integration + {isOAuthConnected && isHoveringConnected && ( + + Status: connected - - { - if (isOAuthConnected) { - setIsHoveringConnected(true); - } - }} - onMouseLeave={() => { - setIsHoveringConnected(false); - }}> - {isOAuthConnected && isHoveringConnected && ( - - Status: connected - - )} - - + )} + ); }; diff --git a/apps/drive-integration/src/locations/Page/components/modals/step_1/SelectDocumentModal.tsx b/apps/drive-integration/src/locations/Page/components/modals/step_1/SelectDocumentModal.tsx index 28e2a5cf1f..2a6ff7c0fb 100644 --- a/apps/drive-integration/src/locations/Page/components/modals/step_1/SelectDocumentModal.tsx +++ b/apps/drive-integration/src/locations/Page/components/modals/step_1/SelectDocumentModal.tsx @@ -4,7 +4,7 @@ import { useGoogleDocsPicker } from '@hooks/useGoogleDocPicker'; interface SelectDocumentModalProps { oauthToken: string; isOpen: boolean; - onClose: (documentId?: string) => void; + onClose: (documentId?: string, documentTitle?: string) => void; } export default function SelectDocumentModal({ @@ -21,9 +21,9 @@ export default function SelectDocumentModal({ }, [onClose]); // Stable callbacks that use refs - const handlePicked = useCallback((files: { id: string }[]) => { + const handlePicked = useCallback((files: { id: string; name: string }[]) => { if (files.length > 0) { - onCloseRef.current(files[0].id); + onCloseRef.current(files[0].id, files[0].name); } else { onCloseRef.current(); } diff --git a/apps/drive-integration/src/locations/Page/components/runs/RunRow.tsx b/apps/drive-integration/src/locations/Page/components/runs/RunRow.tsx index 793ca1585e..4bbabffe68 100644 --- a/apps/drive-integration/src/locations/Page/components/runs/RunRow.tsx +++ b/apps/drive-integration/src/locations/Page/components/runs/RunRow.tsx @@ -1,117 +1,180 @@ -import { Badge, Box, Button, Flex, Spinner, Text, TextLink } from '@contentful/f36-components'; +import { useState } from 'react'; +import { Badge, Button, Flex, TableCell, TableRow, Text, TextLink, Tooltip } from '@contentful/f36-components'; import type { RunWithStatus } from '../../../../types/runs'; interface RunRowProps { run: RunWithStatus; spaceId: string; onReview: (runId: string) => void; - onDismiss: (runId: string) => void; + onRetry: (runId: string) => Promise; } function formatDate(iso: string): string { const date = new Date(iso); - const now = Date.now(); - const diffMs = now - date.getTime(); - const diffMinutes = Math.floor(diffMs / 60_000); - const diffHours = Math.floor(diffMs / 3_600_000); - const diffDays = Math.floor(diffMs / 86_400_000); - - if (diffMinutes < 1) return 'just now'; - if (diffMinutes < 60) return `${diffMinutes}m ago`; - if (diffHours < 24) return `${diffHours}h ago`; - if (diffDays < 7) return `${diffDays}d ago`; - return date.toLocaleDateString(); + return date.toLocaleDateString('en-US', { day: 'numeric', month: 'short', year: 'numeric' }); } -function StatusBadge({ status }: { status: RunWithStatus['displayStatus'] }) { + +function StatusBadge({ status, errorMessage }: { status: RunWithStatus['displayStatus']; errorMessage?: string }) { switch (status) { case 'loading': - return ; + return ( + + Loading + + ); case 'running': return ( - - Running - - + + In progress + ); case 'needs-review': - return Needs Review; + return ( + + Ready for review + + ); case 'completed': - return Completed; - case 'failed': - return Failed; + return ( + + Complete + + ); + case 'failed': { + const badge = ( + + Failed + + ); + return errorMessage ? ( + + {badge} + + ) : badge; + } case 'expired': - return Expired; + return ( + + Expired + + ); } } -export function RunRow({ run, spaceId, onReview, onDismiss }: RunRowProps) { - const contentTypesLabel = - run.contentTypeIds.length <= 3 - ? run.contentTypeIds.join(', ') - : `${run.contentTypeIds.slice(0, 3).join(', ')} +${run.contentTypeIds.length - 3} more`; +export function RunRow({ run, spaceId, onReview, onRetry }: RunRowProps) { + const [isRetrying, setIsRetrying] = useState(false); - return ( - - - {/* Left: metadata */} - - - {run.documentTitle} - - - {contentTypesLabel} - - - {formatDate(run.startedAt)} - + const handleRetry = async () => { + setIsRetrying(true); + try { + await onRetry(run.runId); + } finally { + setIsRetrying(false); + } + }; - {/* Entry links for completed runs */} - {run.displayStatus === 'completed' && - run.createdEntryIds && - run.createdEntryIds.length > 0 && ( - - {run.createdEntryIds.map((entryId) => ( - - {entryId} - - ))} - - )} + return ( + + {/* Name */} + + {run.documentTitle} - {/* Error message for failed runs */} - {run.displayStatus === 'failed' && run.errorMessage && ( - - {run.errorMessage} - + {/* Entry links for completed runs */} + {run.displayStatus === 'completed' && + run.createdEntryIds && + run.createdEntryIds.length > 0 && ( + + {run.createdEntryIds.map((entryId, index) => ( + + {run.createdEntryIds!.length === 1 ? 'View entry' : `Entry ${index + 1}`} + + ))} + )} - - {/* Right: status + actions */} - - + - {run.displayStatus === 'needs-review' && ( - - )} + {/* Created date */} + + + {formatDate(run.startedAt)} + + - {(run.displayStatus === 'failed' || run.displayStatus === 'expired') && ( - - )} - - - + {/* Status badge */} + + + + + {/* Action */} + + {run.displayStatus === 'running' && ( + + {[0, 1, 2].map((i) => ( +
+ ))} + + + )} + {run.displayStatus === 'needs-review' && ( + + )} + {run.displayStatus === 'completed' && run.createdEntryIds?.length === 1 && ( + + )} + {(run.displayStatus === 'failed' || run.displayStatus === 'expired') && ( + + )} + + ); } diff --git a/apps/drive-integration/src/locations/Page/components/runs/RunsPage.tsx b/apps/drive-integration/src/locations/Page/components/runs/RunsPage.tsx index 1956dfd412..56d3431713 100644 --- a/apps/drive-integration/src/locations/Page/components/runs/RunsPage.tsx +++ b/apps/drive-integration/src/locations/Page/components/runs/RunsPage.tsx @@ -1,16 +1,51 @@ -import { Box, Button, Flex, Heading, Note, Text } from '@contentful/f36-components'; +import { + Box, + Button, + Checkbox, + Flex, + Heading, + Note, + Paragraph, + Popover, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Text, +} from '@contentful/f36-components'; +import { CaretDownIcon, SortAscendingIcon, SortDescendingIcon } from '@contentful/f36-icons'; import { PageAppSDK } from '@contentful/app-sdk'; +import { useState } from 'react'; import { useRunsPolling } from '../../../../hooks/useRunsPolling'; -import type { RunRecord, RunWithStatus } from '../../../../types/runs'; +import type { DisplayStatus, RunRecord, RunWithStatus } from '../../../../types/runs'; import { RunRow } from './RunRow'; +import { OAuthConnector } from '../mainpage/OAuthConnector'; + +type SortOrder = 'newest' | 'oldest'; + +const STATUS_OPTIONS: { value: DisplayStatus; label: string }[] = [ + { value: 'running', label: 'In progress' }, + { value: 'needs-review', label: 'Ready for review' }, + { value: 'completed', label: 'Complete' }, + { value: 'failed', label: 'Failed' }, + { value: 'expired', label: 'Expired' }, +]; + +const ALL_STATUSES = new Set(STATUS_OPTIONS.map((o) => o.value)); interface RunsPageProps { sdk: PageAppSDK; runs: RunRecord[]; removeRun: (runId: string) => void; storageError: string | null; - onNewImport: () => void; + onStartImport: () => void; onReviewRun: (runId: string) => void; + onRetryRun: (runId: string) => Promise; + isOAuthConnected: boolean; + isOAuthBusy: boolean; + onConnectGoogleDrive: () => Promise; + onDisconnectGoogleDrive: () => Promise; } export function RunsPage({ @@ -18,64 +53,246 @@ export function RunsPage({ runs, removeRun, storageError, - onNewImport, + onStartImport, onReviewRun, + onRetryRun, + isOAuthConnected, + isOAuthBusy, + onConnectGoogleDrive, + onDisconnectGoogleDrive, }: RunsPageProps) { const spaceId = sdk.ids.space; + const [visibleStatuses, setVisibleStatuses] = useState>(new Set(ALL_STATUSES)); + const [filterOpen, setFilterOpen] = useState(false); + const [sortOpen, setSortOpen] = useState(false); + const [sortOrder, setSortOrder] = useState('newest'); + + const toggleStatus = (status: DisplayStatus) => { + setVisibleStatuses((prev) => { + const next = new Set(prev); + if (next.has(status)) { + next.delete(status); + } else { + next.add(status); + } + return next; + }); + }; - const { statusMap, errorMap } = useRunsPolling(runs, sdk); + const isFiltered = visibleStatuses.size < ALL_STATUSES.size; + + const { statusMap, errorMap, titleMap } = useRunsPolling(runs, sdk); const runsWithStatus: RunWithStatus[] = runs.map((r) => ({ ...r, + documentTitle: titleMap.get(r.runId) ?? r.documentTitle, displayStatus: statusMap.get(r.runId) ?? 'loading', errorMessage: errorMap.get(r.runId), })); + const activeStatuses = new Set(['loading', 'running', 'needs-review']); + const statusGroup = (s: RunWithStatus) => (activeStatuses.has(s.displayStatus) ? 0 : 1); + + const filtered = runsWithStatus + .filter((r) => visibleStatuses.has(r.displayStatus)) + .sort((a, b) => { + const groupDiff = statusGroup(a) - statusGroup(b); + if (groupDiff !== 0) return groupDiff; + const diff = new Date(a.startedAt).getTime() - new Date(b.startedAt).getTime(); + return sortOrder === 'newest' ? -diff : diff; + }); + return ( - - {/* Header */} + + {/* Page header */} + + + Drive Integration + + Create entries using existing content types from a Google Drive file. + + + + + + {/* Intro / select file card */} - Import Runs - {/* Storage error */} {storageError && ( - + {storageError} )} - {/* Run list or empty state */} - {runsWithStatus.length === 0 ? ( + {runs.length > 0 && ( + <> + {/* Status label + filters */} + + Status + + setFilterOpen(false)}> + + + + + + {STATUS_OPTIONS.map(({ value, label }) => ( + toggleStatus(value)}> + {label} + + ))} + + + + setSortOpen(false)}> + + + + + + {(['newest', 'oldest'] as SortOrder[]).map((value) => ( + + ))} + + + + + + + + + {/* Table */} + + + + Name + Created + Status + + + + + {filtered.length === 0 ? ( + + + No imports match the selected filter. + + + ) : ( + filtered.map((run) => ( + + )) + )} + +
+ + )} + + {runs.length === 0 && ( - No imports yet - + style={{ minHeight: '200px' }}> + No imports yet. Select a file above to get started. - ) : ( - runsWithStatus.map((run) => ( - - )) )}
); diff --git a/apps/drive-integration/src/types/runs.ts b/apps/drive-integration/src/types/runs.ts index ab92911c9a..4dad1155c1 100644 --- a/apps/drive-integration/src/types/runs.ts +++ b/apps/drive-integration/src/types/runs.ts @@ -3,6 +3,7 @@ export interface RunRecord { documentTitle: string; documentId: string; contentTypeIds: string[]; + documentSelection: { includeImages: boolean; selectedTabIds: string[] }; startedAt: string; createdEntryIds?: string[]; } @@ -20,4 +21,4 @@ export interface RunWithStatus extends RunRecord { errorMessage?: string; } -export type AppView = { view: 'runs' } | { view: 'import' } | { view: 'review'; runId: string }; +export type AppView = { view: 'runs' } | { view: 'review'; runId: string }; From de91278b8c549128067889d50d2807924de2839e Mon Sep 17 00:00:00 2001 From: David Shibley Date: Mon, 20 Jul 2026 11:42:02 -0600 Subject: [PATCH 08/20] fix(drive-integration): address PR review feedback from JuliRossi - Convert DisplayStatus and AppViewKind to enums; replace all string literals - Store documentSelection on RunRecord so retry replays exact original settings - Replace dismiss with retry action; kicks off new agent run, updates row in-place - Remove progress bars (redundant with status badges) - Add bouncing three-dot indicator for in-progress rows - Replace forever-loading/Untitled fallbacks with polling recovery from suspendPayload - Use sdk.hostnames.webapp instead of hardcoded app.contentful.com - Style filter/sort popovers to match design (multi-checkbox filter, sort icons) - OAuthConnector: compact button replaces card layout - Error message moved to Tooltip on failed badge (not permanent display) - Check storage error before starting workflow (not after) - Guard per-run Promise.all with .catch(() => null) to prevent cascade failures - Remove unused isAnalyzing state and WorkflowFailureReason re-export Co-Authored-By: Claude Sonnet 4.6 --- apps/drive-integration/package-lock.json | 48 +++++++++++++++++++ .../src/hooks/useRunStorage.ts | 13 +---- .../src/hooks/useRunsPolling.ts | 47 +++++++----------- .../src/hooks/useWorkflowAgent.ts | 18 ++----- .../src/locations/Page/Page.tsx | 37 +++++++------- .../components/mainpage/ModalOrchestrator.tsx | 21 ++++---- .../locations/Page/components/runs/RunRow.tsx | 30 ++++++------ .../Page/components/runs/RunsPage.tsx | 19 ++++---- apps/drive-integration/src/types/runs.ts | 22 +++++---- 9 files changed, 139 insertions(+), 116 deletions(-) diff --git a/apps/drive-integration/package-lock.json b/apps/drive-integration/package-lock.json index fa2b6429c9..7f7ed74183 100644 --- a/apps/drive-integration/package-lock.json +++ b/apps/drive-integration/package-lock.json @@ -21,6 +21,7 @@ "ai": "^5.0.81", "contentful-management": "^11.76.0", "googleapis": "^166.0.0", + "launchdarkly-react-client-sdk": "^3.0.8", "mammoth": "^1.11.0", "react": "^18.3.1", "react-dom": "^18.3.1" @@ -8849,6 +8850,47 @@ "json-buffer": "3.0.1" } }, + "node_modules/launchdarkly-js-client-sdk": { + "version": "3.9.3", + "resolved": "https://registry.npmjs.org/launchdarkly-js-client-sdk/-/launchdarkly-js-client-sdk-3.9.3.tgz", + "integrity": "sha512-1q+TqfBEBQcaz77EHwPf5F0L1hc22iwoa/S+69O5DSyp7+Txg35SDrvIQJWDcpigYxg0WN9VswAiq3uFci1ZhA==", + "license": "Apache-2.0", + "dependencies": { + "escape-string-regexp": "^4.0.0", + "launchdarkly-js-sdk-common": "5.8.1" + } + }, + "node_modules/launchdarkly-js-sdk-common": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/launchdarkly-js-sdk-common/-/launchdarkly-js-sdk-common-5.8.1.tgz", + "integrity": "sha512-q6sYOatAhkKJYIT+8eeJyFBy4HZxOjHLxg8028Q6j7/ZdaySbo451ntPnlyKBrvXw9YfyB+62pL9ZHME3U6trg==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "fast-deep-equal": "^2.0.1" + } + }, + "node_modules/launchdarkly-js-sdk-common/node_modules/fast-deep-equal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", + "integrity": "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==", + "license": "MIT" + }, + "node_modules/launchdarkly-react-client-sdk": { + "version": "3.9.2", + "resolved": "https://registry.npmjs.org/launchdarkly-react-client-sdk/-/launchdarkly-react-client-sdk-3.9.2.tgz", + "integrity": "sha512-jSQw8ZqvmhIsLUxkNNiUYD+kFgVQiJj1C5dEN4pq9567GjDHkoHk6AmhXvRkjg8yjyWI4pJBKbIIgRdcmRsxfA==", + "license": "Apache-2.0", + "dependencies": { + "hoist-non-react-statics": "^3.3.2", + "launchdarkly-js-client-sdk": "^3.9.3", + "lodash.camelcase": "^4.3.0" + }, + "peerDependencies": { + "react": "^16.6.3 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.4 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -8898,6 +8940,12 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", diff --git a/apps/drive-integration/src/hooks/useRunStorage.ts b/apps/drive-integration/src/hooks/useRunStorage.ts index c2379745e5..5428ac60f0 100644 --- a/apps/drive-integration/src/hooks/useRunStorage.ts +++ b/apps/drive-integration/src/hooks/useRunStorage.ts @@ -58,20 +58,11 @@ export function useRunStorage(spaceId: string, environmentId: string): UseRunSto if (current.some((r) => r.runId === record.runId)) return current; const next = [record, ...current]; if (next.length > MAX_RUNS) next.splice(MAX_RUNS); - try { - writeToStorage(key, next); - setStorageError(null); - } catch (err) { - const msg = - err instanceof Error - ? err.message - : 'Unable to save import history. Storage may be full.'; - setStorageError(msg); - } + persist(next); return next; }); }, - [key] + [persist] ); const removeRun = useCallback( diff --git a/apps/drive-integration/src/hooks/useRunsPolling.ts b/apps/drive-integration/src/hooks/useRunsPolling.ts index 49ba8f29cd..717c59f57b 100644 --- a/apps/drive-integration/src/hooks/useRunsPolling.ts +++ b/apps/drive-integration/src/hooks/useRunsPolling.ts @@ -2,30 +2,27 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { PageAppSDK } from '@contentful/app-sdk'; import { RunStatus } from '@types'; import { getWorkflowRun, AgentRunData } from '../services/agents-api'; -import type { DisplayStatus, RunRecord } from '../types/runs'; +import { DisplayStatus } from '../types/runs'; +import type { RunRecord } from '../types/runs'; const POLL_INTERVAL_MS = 10_000; +const RUN_STATUS_TO_DISPLAY: Partial> = { + [RunStatus.IN_PROGRESS]: DisplayStatus.RUNNING, + [RunStatus.DRAFT]: DisplayStatus.RUNNING, + [RunStatus.PENDING_REVIEW]: DisplayStatus.NEEDS_REVIEW, + [RunStatus.COMPLETED]: DisplayStatus.COMPLETED, + [RunStatus.FAILED]: DisplayStatus.FAILED, +}; + function toDisplayStatus(runData: AgentRunData | null): DisplayStatus { - if (!runData) return 'expired'; + if (!runData) return DisplayStatus.EXPIRED; const status = runData.sys?.status ?? runData.metadata?.status; - switch (status) { - case RunStatus.IN_PROGRESS: - case RunStatus.DRAFT: - return 'running'; - case RunStatus.PENDING_REVIEW: - return 'needs-review'; - case RunStatus.COMPLETED: - return 'completed'; - case RunStatus.FAILED: - return 'failed'; - default: - return 'expired'; - } + return (status && RUN_STATUS_TO_DISPLAY[status]) ?? DisplayStatus.EXPIRED; } function extractErrorMessage(runData: AgentRunData | null): string | undefined { - return runData?.metadata?.workflowFailure?.message ?? undefined; + return runData?.metadata?.workflowFailure?.message; } export interface UseRunsPollingResult { @@ -35,9 +32,7 @@ export interface UseRunsPollingResult { } export function useRunsPolling(runs: RunRecord[], sdk: PageAppSDK): UseRunsPollingResult { - const [statusMap, setStatusMap] = useState>( - () => new Map(runs.map((r) => [r.runId, 'loading' as DisplayStatus])) - ); + const [statusMap, setStatusMap] = useState>(new Map()); const [errorMap, setErrorMap] = useState>(new Map()); const [titleMap, setTitleMap] = useState>(new Map()); const intervalRef = useRef | null>(null); @@ -74,20 +69,10 @@ export function useRunsPolling(runs: RunRecord[], sdk: PageAppSDK): UseRunsPolli return nextStatus; }, [runs, sdk]); - // Initial fetch + reactive refetch when run list changes useEffect(() => { - // Reset new runs to 'loading' - setStatusMap((prev) => { - const next = new Map(prev); - for (const r of runs) { - if (!next.has(r.runId)) next.set(r.runId, 'loading'); - } - return next; - }); - void fetchAllStatuses().then((nextStatus) => { if (!nextStatus) return; - const hasRunning = [...nextStatus.values()].some((s) => s === 'running'); + const hasRunning = [...nextStatus.values()].some((s) => s === DisplayStatus.RUNNING); if (intervalRef.current) clearInterval(intervalRef.current); @@ -95,7 +80,7 @@ export function useRunsPolling(runs: RunRecord[], sdk: PageAppSDK): UseRunsPolli intervalRef.current = setInterval(() => { void fetchAllStatuses().then((updated) => { if (!updated) return; - const stillRunning = [...updated.values()].some((s) => s === 'running'); + const stillRunning = [...updated.values()].some((s) => s === DisplayStatus.RUNNING); if (!stillRunning && intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; diff --git a/apps/drive-integration/src/hooks/useWorkflowAgent.ts b/apps/drive-integration/src/hooks/useWorkflowAgent.ts index d895709a95..bd1fec5c17 100644 --- a/apps/drive-integration/src/hooks/useWorkflowAgent.ts +++ b/apps/drive-integration/src/hooks/useWorkflowAgent.ts @@ -1,7 +1,6 @@ -import { useState, useCallback } from 'react'; +import { useCallback } from 'react'; import { PageAppSDK } from '@contentful/app-sdk'; import { WORKFLOW_AGENT_ID } from '../utils/constants/agent'; -import { WorkflowFailureReason } from '@types'; import { AgentGeneratePayload, DocumentSelection, startAgentRun } from '../services/agents-api'; interface UseWorkflowParams { @@ -11,7 +10,6 @@ interface UseWorkflowParams { } interface WorkflowHook { - isAnalyzing: boolean; startWorkflow: ( contentTypeIds: string[], documentSelection: DocumentSelection @@ -23,12 +21,8 @@ export const useWorkflowAgent = ({ documentId, oauthToken, }: UseWorkflowParams): WorkflowHook => { - const [isAnalyzing, setIsAnalyzing] = useState(false); - const startWorkflow = useCallback( async (contentTypeIds: string[], documentSelection: DocumentSelection): Promise => { - setIsAnalyzing(true); - const spaceId = sdk.ids.space; const environmentId = sdk.ids.environmentAlias ?? sdk.ids.environment; const threadId = [crypto.randomUUID(), WORKFLOW_AGENT_ID].join('-'); @@ -59,17 +53,11 @@ export const useWorkflowAgent = ({ try { return await startAgentRun(sdk, spaceId, environmentId, payload); } catch (err) { - const error = err instanceof Error ? err : new Error('Workflow failed'); - throw error; - } finally { - setIsAnalyzing(false); + throw err instanceof Error ? err : new Error('Workflow failed'); } }, [sdk, documentId, oauthToken] ); - return { isAnalyzing, startWorkflow }; + return { startWorkflow }; }; - -// Re-export WorkflowFailureReason for backward compatibility with callers that imported it here -export { WorkflowFailureReason }; diff --git a/apps/drive-integration/src/locations/Page/Page.tsx b/apps/drive-integration/src/locations/Page/Page.tsx index 3579fb9657..2f2a1b182b 100644 --- a/apps/drive-integration/src/locations/Page/Page.tsx +++ b/apps/drive-integration/src/locations/Page/Page.tsx @@ -9,6 +9,8 @@ import { import { ReviewPage } from './components/review/ReviewPage'; import { RunsPage } from './components/runs/RunsPage'; import type { AppView, MappingReviewSuspendPayload } from '@types'; +import { AppViewKind } from '../../types/runs'; +import type { EntryBlockGraph } from '../../types/entryBlockGraph'; import { useGoogleDriveOAuth } from '@hooks/useGoogleDriveOAuth'; import { isAiAccessDeniedError } from '../../utils/aiAccess'; import { resumeAndPollWorkflow } from '../../services/workflowService'; @@ -21,7 +23,7 @@ const Page = () => { const modalOrchestratorRef = useRef(null); const [aiAccessDeniedMessage, setAiAccessDeniedMessage] = useState(null); - const [appView, setAppView] = useState({ view: 'runs' }); + const [appView, setAppView] = useState({ view: AppViewKind.RUNS }); const [pendingReviewPayload, setPendingReviewPayload] = useState(null); const [isLoadingReviewPayload, setIsLoadingReviewPayload] = useState(false); @@ -39,8 +41,9 @@ const Page = () => { // When navigating to the review view, fetch the suspend payload from the backend useEffect(() => { - if (appView.view !== 'review') { + if (appView.view !== AppViewKind.REVIEW) { setPendingReviewPayload(null); + setIsLoadingReviewPayload(false); return; } @@ -64,7 +67,7 @@ const Page = () => { isCancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps -- only re-fetch when the runId changes - }, [appView.view === 'review' ? appView.runId : null]); + }, [appView.view === AppViewKind.REVIEW ? appView.runId : null]); const handleSelectFile = () => { modalOrchestratorRef.current?.startFlow(); @@ -81,32 +84,30 @@ const Page = () => { }; const handleRunStarted = () => { - setAppView({ view: 'runs' }); + setAppView({ view: AppViewKind.RUNS }); }; const handleReviewRun = (runId: string) => { - setAppView({ view: 'review', runId }); + setAppView({ view: AppViewKind.REVIEW, runId }); }; const handleExitReview = () => { modalOrchestratorRef.current?.resetFlow(); - setAppView({ view: 'runs' }); + handleRunStarted(); }; const handleRunCompleted = (runId: string, entryIds: string[]) => { markCompleted(runId, entryIds); }; - const handleCancelReview = async (runId?: string) => { - if (runId) { - try { - await resumeAndPollWorkflow(sdk, runId, { cancelled: true }); - } catch (error) { - console.error(error); - } + const handleCancelReview = async (runId: string, entryBlockGraph: EntryBlockGraph) => { + try { + await resumeAndPollWorkflow(sdk, runId, { cancelled: true, entryBlockGraph }); + } catch (error) { + console.error(error); } modalOrchestratorRef.current?.resetFlow(); - setAppView({ view: 'runs' }); + handleRunStarted(); }; const handleRetryRun = async (runId: string) => { @@ -181,7 +182,7 @@ const Page = () => { const renderView = () => { switch (appView.view) { - case 'runs': + case AppViewKind.RUNS: return ( { /> ); - case 'review': { + case AppViewKind.REVIEW: { if (isLoadingReviewPayload || !pendingReviewPayload) { return ( @@ -212,7 +213,7 @@ const Page = () => { sdk={sdk} payload={pendingReviewPayload} runId={appView.runId} - onCancelReview={() => handleCancelReview(appView.runId)} + onCancelReview={(graph) => handleCancelReview(appView.runId, graph)} onExitReview={handleExitReview} onRunCompleted={(entryIds) => handleRunCompleted(appView.runId, entryIds)} /> @@ -236,7 +237,7 @@ const Page = () => { onReconnectGoogleDrive={startOAuth} onAiAccessDenied={handleAiAccessDenied} onRunStarted={handleRunStarted} - onResetToMain={() => setAppView({ view: 'runs' })} + onResetToMain={handleRunStarted} addRun={addRun} storageError={storageError} /> diff --git a/apps/drive-integration/src/locations/Page/components/mainpage/ModalOrchestrator.tsx b/apps/drive-integration/src/locations/Page/components/mainpage/ModalOrchestrator.tsx index a27ace91fd..ce6c468c43 100644 --- a/apps/drive-integration/src/locations/Page/components/mainpage/ModalOrchestrator.tsx +++ b/apps/drive-integration/src/locations/Page/components/mainpage/ModalOrchestrator.tsx @@ -297,21 +297,20 @@ export const ModalOrchestrator = forwardRef { + if (storageError) { + showWorkflowError( + new WorkflowRunError( + 'Unable to track this import: browser storage is unavailable or full.', + WorkflowFailureReason.GENERIC + ) + ); + return; + } + setFlowStep(FlowStep.LOADING); try { const runId = await startWorkflow(contentTypeIds, documentSelection); - if (storageError) { - // Storage unavailable — surface error before proceeding - showWorkflowError( - new WorkflowRunError( - 'Unable to track this import: browser storage is unavailable or full.', - WorkflowFailureReason.GENERIC - ) - ); - return; - } - addRun({ runId, documentTitle, diff --git a/apps/drive-integration/src/locations/Page/components/runs/RunRow.tsx b/apps/drive-integration/src/locations/Page/components/runs/RunRow.tsx index 4bbabffe68..766ee55867 100644 --- a/apps/drive-integration/src/locations/Page/components/runs/RunRow.tsx +++ b/apps/drive-integration/src/locations/Page/components/runs/RunRow.tsx @@ -1,10 +1,12 @@ import { useState } from 'react'; import { Badge, Button, Flex, TableCell, TableRow, Text, TextLink, Tooltip } from '@contentful/f36-components'; +import { DisplayStatus } from '../../../../types/runs'; import type { RunWithStatus } from '../../../../types/runs'; interface RunRowProps { run: RunWithStatus; spaceId: string; + webappHost: string; onReview: (runId: string) => void; onRetry: (runId: string) => Promise; } @@ -17,7 +19,7 @@ function formatDate(iso: string): string { function StatusBadge({ status, errorMessage }: { status: RunWithStatus['displayStatus']; errorMessage?: string }) { switch (status) { - case 'loading': + case DisplayStatus.LOADING: return ( ); - case 'running': + case DisplayStatus.RUNNING: return ( ); - case 'needs-review': + case DisplayStatus.NEEDS_REVIEW: return ( ); - case 'completed': + case DisplayStatus.COMPLETED: return ( ); - case 'failed': { + case DisplayStatus.FAILED: { const badge = ( ) : badge; } - case 'expired': + case DisplayStatus.EXPIRED: return ( { @@ -93,14 +95,14 @@ export function RunRow({ run, spaceId, onReview, onRetry }: RunRowProps) { {run.documentTitle} {/* Entry links for completed runs */} - {run.displayStatus === 'completed' && + {run.displayStatus === DisplayStatus.COMPLETED && run.createdEntryIds && run.createdEntryIds.length > 0 && ( {run.createdEntryIds.map((entryId, index) => ( @@ -126,7 +128,7 @@ export function RunRow({ run, spaceId, onReview, onRetry }: RunRowProps) { {/* Action */} - {run.displayStatus === 'running' && ( + {run.displayStatus === DisplayStatus.RUNNING && ( {[0, 1, 2].map((i) => (
)} - {run.displayStatus === 'needs-review' && ( + {run.displayStatus === DisplayStatus.NEEDS_REVIEW && ( )} - {run.displayStatus === 'completed' && run.createdEntryIds?.length === 1 && ( + {run.displayStatus === DisplayStatus.COMPLETED && run.createdEntryIds?.length === 1 && ( )} - {(run.displayStatus === 'failed' || run.displayStatus === 'expired') && ( + {(run.displayStatus === DisplayStatus.FAILED || run.displayStatus === DisplayStatus.EXPIRED) && ( )} - {(run.displayStatus === DisplayStatus.FAILED || run.displayStatus === DisplayStatus.EXPIRED) && ( + {(run.displayStatus === DisplayStatus.FAILED || + run.displayStatus === DisplayStatus.EXPIRED) && ( @@ -216,7 +228,11 @@ export function RunsPage({ color: '#536171', fontSize: '14px', }}> - {sortOrder === 'newest' ? : } + {sortOrder === 'newest' ? ( + + ) : ( + + )} Sort by @@ -226,7 +242,10 @@ export function RunsPage({ {(['newest', 'oldest'] as SortOrder[]).map((value) => ( + + + )} + + ); +} diff --git a/apps/drive-integration/src/locations/Page/components/runs/RunRow.tsx b/apps/drive-integration/src/locations/Page/components/runs/RunRow.tsx index 2b12c53fc6..6af20636f3 100644 --- a/apps/drive-integration/src/locations/Page/components/runs/RunRow.tsx +++ b/apps/drive-integration/src/locations/Page/components/runs/RunRow.tsx @@ -6,14 +6,16 @@ import { TableCell, TableRow, Text, - TextLink, Tooltip, } from '@contentful/f36-components'; +import { PageAppSDK } from '@contentful/app-sdk'; import { DisplayStatus } from '../../../../types/runs'; import type { RunWithStatus } from '../../../../types/runs'; +import { EntriesCreatedModal } from './EntriesCreatedModal'; interface RunRowProps { run: RunWithStatus; + sdk: PageAppSDK; spaceId: string; webappHost: string; onReview: (runId: string) => void; @@ -99,8 +101,9 @@ function StatusBadge({ } } -export function RunRow({ run, spaceId, webappHost, onReview, onRetry }: RunRowProps) { +export function RunRow({ run, sdk, spaceId, webappHost, onReview, onRetry }: RunRowProps) { const [isRetrying, setIsRetrying] = useState(false); + const [isModalOpen, setIsModalOpen] = useState(false); const handleRetry = async () => { setIsRetrying(true); @@ -112,98 +115,89 @@ export function RunRow({ run, spaceId, webappHost, onReview, onRetry }: RunRowPr }; return ( - - {/* Name */} - - {run.documentTitle} + <> + + {/* Name */} + + {run.documentTitle} + - {/* Entry links for completed runs */} - {run.displayStatus === DisplayStatus.COMPLETED && - run.createdEntryIds && - run.createdEntryIds.length > 0 && ( - - {run.createdEntryIds.map((entryId, index) => ( - - {run.createdEntryIds!.length === 1 ? 'View entry' : `Entry ${index + 1}`} - + {/* Created date */} + + + {formatDate(run.startedAt)} + + + + {/* Status badge */} + + + + + {/* Action */} + + {run.displayStatus === DisplayStatus.RUNNING && ( + + {[0, 1, 2].map((i) => ( +
))} + )} - - - {/* Created date */} - - - {formatDate(run.startedAt)} - - + {run.displayStatus === DisplayStatus.NEEDS_REVIEW && ( + + )} + {run.displayStatus === DisplayStatus.COMPLETED && + run.createdEntryIds && + run.createdEntryIds.length > 0 && ( + + )} + {(run.displayStatus === DisplayStatus.FAILED || + run.displayStatus === DisplayStatus.EXPIRED) && ( + + )} + + - {/* Status badge */} - - 0 && ( + setIsModalOpen(false)} + sdk={sdk} + spaceId={spaceId} + webappHost={webappHost} + entryIds={run.createdEntryIds} /> - - - {/* Action */} - - {run.displayStatus === DisplayStatus.RUNNING && ( - - {[0, 1, 2].map((i) => ( -
- ))} - - - )} - {run.displayStatus === DisplayStatus.NEEDS_REVIEW && ( - - )} - {run.displayStatus === DisplayStatus.COMPLETED && run.createdEntryIds?.length === 1 && ( - - )} - {(run.displayStatus === DisplayStatus.FAILED || - run.displayStatus === DisplayStatus.EXPIRED) && ( - - )} - - + )} + ); } diff --git a/apps/drive-integration/src/locations/Page/components/runs/RunsPage.tsx b/apps/drive-integration/src/locations/Page/components/runs/RunsPage.tsx index a7172c033c..7978528ad8 100644 --- a/apps/drive-integration/src/locations/Page/components/runs/RunsPage.tsx +++ b/apps/drive-integration/src/locations/Page/components/runs/RunsPage.tsx @@ -285,6 +285,7 @@ export function RunsPage({ Date: Mon, 20 Jul 2026 13:45:58 -0600 Subject: [PATCH 13/20] refactor(drive-integration): use f36 EntryCard in entries created modal, remove unused props Co-Authored-By: Claude Sonnet 4.6 --- .../components/runs/EntriesCreatedModal.tsx | 163 +++++++----------- .../locations/Page/components/runs/RunRow.tsx | 6 +- .../Page/components/runs/RunsPage.tsx | 4 - 3 files changed, 62 insertions(+), 111 deletions(-) diff --git a/apps/drive-integration/src/locations/Page/components/runs/EntriesCreatedModal.tsx b/apps/drive-integration/src/locations/Page/components/runs/EntriesCreatedModal.tsx index bdc81329b1..f0aa33113c 100644 --- a/apps/drive-integration/src/locations/Page/components/runs/EntriesCreatedModal.tsx +++ b/apps/drive-integration/src/locations/Page/components/runs/EntriesCreatedModal.tsx @@ -1,106 +1,63 @@ import { useEffect, useState } from 'react'; -import { - Badge, - Button, - Flex, - Modal, - Spinner, - Table, - TableBody, - TableCell, - TableRow, - Text, - TextLink, -} from '@contentful/f36-components'; +import { Button, EntryCard, Flex, Modal, Spinner } from '@contentful/f36-components'; import { PageAppSDK } from '@contentful/app-sdk'; - -interface EntryRow { - id: string; - title: string; - contentTypeName: string; - status: 'Draft' | 'Published' | 'Changed'; -} +import type { EntryProps } from 'contentful-management'; +import { fetchContentTypesInfoByIds } from '../../../../services/contentTypeService'; +import type { ContentTypeDisplayInfoMap } from '../../../../utils/overviewEntryList'; +import { getEntryDisplayTitle } from '../../../../utils/getEntryDisplayTitle'; interface EntriesCreatedModalProps { isOpen: boolean; onClose: () => void; sdk: PageAppSDK; - spaceId: string; - webappHost: string; entryIds: string[]; } -async function fetchEntryRows( - sdk: PageAppSDK, - spaceId: string, - entryIds: string[] -): Promise { - const environmentId = sdk.ids.environmentAlias ?? sdk.ids.environment; - - const entries = await Promise.all( - entryIds.map((id) => - sdk.cma.entry.get({ entryId: id, spaceId, environmentId }).catch(() => null) - ) - ); - - const contentTypeIds = [ - ...new Set(entries.filter(Boolean).map((e) => e!.sys.contentType.sys.id)), - ]; - const contentTypes = await Promise.all( - contentTypeIds.map((ctId) => - sdk.cma.contentType.get({ contentTypeId: ctId, spaceId, environmentId }).catch(() => null) - ) - ); - const ctMap = new Map(contentTypes.filter(Boolean).map((ct) => [ct!.sys.id, ct!])); - - return entries - .map((entry, i) => { - if (!entry) return null; - const ct = ctMap.get(entry.sys.contentType.sys.id); - const displayField = ct?.displayField; - const locale = sdk.locales.default; - const title = - (displayField && String(entry.fields[displayField]?.[locale] ?? '')) || 'Untitled'; - const contentTypeName = ct?.name ?? entry.sys.contentType.sys.id; - - const isPublished = !!entry.sys.publishedAt; - const isDraft = !isPublished; - const isChanged = isPublished && entry.sys.version > (entry.sys.publishedVersion ?? 0) + 1; - const status: EntryRow['status'] = isChanged ? 'Changed' : isDraft ? 'Draft' : 'Published'; - - return { id: entryIds[i], title, contentTypeName, status }; - }) - .filter((r): r is EntryRow => r !== null); +function resolveContentTypeLabel(contentTypeId: string, map?: ContentTypeDisplayInfoMap): string { + const name = map?.get(contentTypeId)?.name?.trim(); + return name && name.length > 0 ? name : 'Content type'; } -const STATUS_STYLES: Record = { - Draft: { background: '#FEF3C7', color: '#92400E', border: 'none' }, - Published: { background: '#D1FAE5', color: '#065F46', border: 'none' }, - Changed: { background: '#DBEAFE', color: '#1E40AF', border: 'none' }, -}; +function entryStatus(entry: EntryProps): 'draft' | 'published' | 'changed' { + if (!entry.sys.publishedAt) return 'draft'; + if (entry.sys.version > (entry.sys.publishedVersion ?? 0) + 1) return 'changed'; + return 'published'; +} -export function EntriesCreatedModal({ - isOpen, - onClose, - sdk, - spaceId, - webappHost, - entryIds, -}: EntriesCreatedModalProps) { - const [rows, setRows] = useState([]); +export function EntriesCreatedModal({ isOpen, onClose, sdk, entryIds }: EntriesCreatedModalProps) { + const [entries, setEntries] = useState([]); + const [ctMap, setCtMap] = useState(new Map()); const [isLoading, setIsLoading] = useState(false); useEffect(() => { if (!isOpen || entryIds.length === 0) return; + setIsLoading(true); - fetchEntryRows(sdk, spaceId, entryIds) - .then(setRows) - .catch(() => setRows([])) + + const spaceId = sdk.ids.space; + const environmentId = sdk.ids.environmentAlias ?? sdk.ids.environment; + + Promise.all( + entryIds.map((id) => + sdk.cma.entry.get({ entryId: id, spaceId, environmentId }).catch(() => null) + ) + ) + .then((results) => { + const fetched = results.filter((e): e is EntryProps => e !== null); + setEntries(fetched); + const ctIds = fetched.map((e) => e.sys.contentType.sys.id); + return fetchContentTypesInfoByIds(sdk, ctIds).then(setCtMap); + }) + .catch(() => { + setEntries([]); + }) .finally(() => setIsLoading(false)); }, [isOpen, entryIds.join(',')]); // eslint-disable-line react-hooks/exhaustive-deps + const defaultLocale = sdk.locales.default; + return ( - + {() => ( <> @@ -110,27 +67,29 @@ export function EntriesCreatedModal({ ) : ( - - - {rows.map((row) => ( - - - - {row.title} - - - - - {row.status} - - - - ))} - -
+ + {entries.map((entry) => { + const contentTypeId = entry.sys.contentType.sys.id; + const title = getEntryDisplayTitle( + entry, + defaultLocale, + ctMap.get(contentTypeId) + ); + const contentTypeLabel = resolveContentTypeLabel(contentTypeId, ctMap); + return ( + { + void sdk.navigator.openEntry(entry.sys.id, { slideIn: true }); + }} + /> + ); + })} + )} diff --git a/apps/drive-integration/src/locations/Page/components/runs/RunRow.tsx b/apps/drive-integration/src/locations/Page/components/runs/RunRow.tsx index 6af20636f3..9dacee9441 100644 --- a/apps/drive-integration/src/locations/Page/components/runs/RunRow.tsx +++ b/apps/drive-integration/src/locations/Page/components/runs/RunRow.tsx @@ -16,8 +16,6 @@ import { EntriesCreatedModal } from './EntriesCreatedModal'; interface RunRowProps { run: RunWithStatus; sdk: PageAppSDK; - spaceId: string; - webappHost: string; onReview: (runId: string) => void; onRetry: (runId: string) => Promise; } @@ -101,7 +99,7 @@ function StatusBadge({ } } -export function RunRow({ run, sdk, spaceId, webappHost, onReview, onRetry }: RunRowProps) { +export function RunRow({ run, sdk, onReview, onRetry }: RunRowProps) { const [isRetrying, setIsRetrying] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false); @@ -193,8 +191,6 @@ export function RunRow({ run, sdk, spaceId, webappHost, onReview, onRetry }: Run isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} sdk={sdk} - spaceId={spaceId} - webappHost={webappHost} entryIds={run.createdEntryIds} /> )} diff --git a/apps/drive-integration/src/locations/Page/components/runs/RunsPage.tsx b/apps/drive-integration/src/locations/Page/components/runs/RunsPage.tsx index 7978528ad8..3e2ab56ae6 100644 --- a/apps/drive-integration/src/locations/Page/components/runs/RunsPage.tsx +++ b/apps/drive-integration/src/locations/Page/components/runs/RunsPage.tsx @@ -62,8 +62,6 @@ export function RunsPage({ onConnectGoogleDrive, onDisconnectGoogleDrive, }: RunsPageProps) { - const spaceId = sdk.ids.space; - const webappHost = sdk.hostnames.webapp ?? 'app.contentful.com'; const [visibleStatuses, setVisibleStatuses] = useState>(new Set(ALL_STATUSES)); const [filterOpen, setFilterOpen] = useState(false); const [sortOpen, setSortOpen] = useState(false); @@ -286,8 +284,6 @@ export function RunsPage({ key={run.runId} run={run} sdk={sdk} - spaceId={spaceId} - webappHost={webappHost} onReview={onReviewRun} onRetry={onRetryRun} /> From 32e8e76c3e57af447dd5c22f57c33e077f3209c8 Mon Sep 17 00:00:00 2001 From: David Shibley Date: Mon, 20 Jul 2026 13:59:17 -0600 Subject: [PATCH 14/20] fix(drive-integration): remove unused removeRun destructure to fix lint error Co-Authored-By: Claude Sonnet 4.6 --- .../src/locations/Page/components/runs/RunsPage.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/drive-integration/src/locations/Page/components/runs/RunsPage.tsx b/apps/drive-integration/src/locations/Page/components/runs/RunsPage.tsx index 3e2ab56ae6..a3e16ee935 100644 --- a/apps/drive-integration/src/locations/Page/components/runs/RunsPage.tsx +++ b/apps/drive-integration/src/locations/Page/components/runs/RunsPage.tsx @@ -52,7 +52,6 @@ interface RunsPageProps { export function RunsPage({ sdk, runs, - removeRun, storageError, onStartImport, onReviewRun, From ab88836525f200a029ffe665338ba3a527cee447 Mon Sep 17 00:00:00 2001 From: David Shibley Date: Mon, 20 Jul 2026 14:26:24 -0600 Subject: [PATCH 15/20] fix(drive-integration): update stale tests and add missing hook state - useRunsPolling: pre-populate statusMap with LOADING for all runIds on init - useWorkflowAgent: add isAnalyzing state (set during startWorkflow, cleared on resolve/reject) - RunRow.spec: update to current prop API (sdk + onRetry), current badge labels, entry count badge, View button - RunsPage.spec: update to current prop API, add titleMap to mock, use current badge labels - Page.spec: update RunsPage mock prop names (onStartImport), fix onCancelReview assertion to objectContaining, remove stale import-view navigation tests Co-Authored-By: Claude Sonnet 4.6 --- .../src/hooks/useRunsPolling.ts | 6 +- .../src/hooks/useWorkflowAgent.ts | 10 +- .../test/locations/Page/Page.spec.tsx | 32 ++-- .../Page/components/runs/RunRow.spec.tsx | 138 +++++++++--------- .../Page/components/runs/RunsPage.spec.tsx | 75 +++++----- 5 files changed, 131 insertions(+), 130 deletions(-) diff --git a/apps/drive-integration/src/hooks/useRunsPolling.ts b/apps/drive-integration/src/hooks/useRunsPolling.ts index 3ee537a967..feeddac280 100644 --- a/apps/drive-integration/src/hooks/useRunsPolling.ts +++ b/apps/drive-integration/src/hooks/useRunsPolling.ts @@ -32,7 +32,11 @@ export interface UseRunsPollingResult { } export function useRunsPolling(runs: RunRecord[], sdk: PageAppSDK): UseRunsPollingResult { - const [statusMap, setStatusMap] = useState>(new Map()); + const [statusMap, setStatusMap] = useState>(() => { + const initial = new Map(); + for (const r of runs) initial.set(r.runId, DisplayStatus.LOADING); + return initial; + }); const [errorMap, setErrorMap] = useState>(new Map()); const [titleMap, setTitleMap] = useState>(new Map()); const intervalRef = useRef | null>(null); diff --git a/apps/drive-integration/src/hooks/useWorkflowAgent.ts b/apps/drive-integration/src/hooks/useWorkflowAgent.ts index bd1fec5c17..37b4885b1d 100644 --- a/apps/drive-integration/src/hooks/useWorkflowAgent.ts +++ b/apps/drive-integration/src/hooks/useWorkflowAgent.ts @@ -1,4 +1,4 @@ -import { useCallback } from 'react'; +import { useCallback, useState } from 'react'; import { PageAppSDK } from '@contentful/app-sdk'; import { WORKFLOW_AGENT_ID } from '../utils/constants/agent'; import { AgentGeneratePayload, DocumentSelection, startAgentRun } from '../services/agents-api'; @@ -10,6 +10,7 @@ interface UseWorkflowParams { } interface WorkflowHook { + isAnalyzing: boolean; startWorkflow: ( contentTypeIds: string[], documentSelection: DocumentSelection @@ -21,6 +22,8 @@ export const useWorkflowAgent = ({ documentId, oauthToken, }: UseWorkflowParams): WorkflowHook => { + const [isAnalyzing, setIsAnalyzing] = useState(false); + const startWorkflow = useCallback( async (contentTypeIds: string[], documentSelection: DocumentSelection): Promise => { const spaceId = sdk.ids.space; @@ -50,14 +53,17 @@ export const useWorkflowAgent = ({ threadId, }; + setIsAnalyzing(true); try { return await startAgentRun(sdk, spaceId, environmentId, payload); } catch (err) { throw err instanceof Error ? err : new Error('Workflow failed'); + } finally { + setIsAnalyzing(false); } }, [sdk, documentId, oauthToken] ); - return { startWorkflow }; + return { isAnalyzing, startWorkflow }; }; diff --git a/apps/drive-integration/test/locations/Page/Page.spec.tsx b/apps/drive-integration/test/locations/Page/Page.spec.tsx index a7a86e42bb..1a5ffb9852 100644 --- a/apps/drive-integration/test/locations/Page/Page.spec.tsx +++ b/apps/drive-integration/test/locations/Page/Page.spec.tsx @@ -147,16 +147,16 @@ vi.mock('../../../src/locations/Page/components/mainpage/ModalOrchestrator', () vi.mock('../../../src/locations/Page/components/runs/RunsPage', () => ({ RunsPage: ({ - onNewImport, + onStartImport, onReviewRun, }: { - onNewImport: () => void; + onStartImport: () => void; onReviewRun: (runId: string) => void; }) => (
Runs Page
- + + ); + } + if (isLoadingReviewPayload || !pendingReviewPayload) { return ( @@ -241,7 +287,6 @@ const Page = () => { onReconnectGoogleDrive={startOAuth} onAiAccessDenied={handleAiAccessDenied} onRunStarted={handleRunStarted} - onResetToMain={handleRunStarted} addRun={addRun} storageError={storageError} /> diff --git a/apps/drive-integration/src/locations/Page/components/mainpage/ModalOrchestrator.tsx b/apps/drive-integration/src/locations/Page/components/mainpage/ModalOrchestrator.tsx index dfee3af81f..c9794beb0e 100644 --- a/apps/drive-integration/src/locations/Page/components/mainpage/ModalOrchestrator.tsx +++ b/apps/drive-integration/src/locations/Page/components/mainpage/ModalOrchestrator.tsx @@ -5,7 +5,6 @@ import { ContentTypeProps } from 'contentful-management'; import { ConfirmCancelModal } from '../modals/ConfirmCancelModal'; import { ErrorModal, type ErrorModalConfig } from '../modals/ErrorModal'; import SelectDocumentModal from '../modals/step_1/SelectDocumentModal'; -import { LoadingModal } from '../modals/LoadingModal'; import { ERROR_MESSAGES } from '@constants/messages'; import { SelectTabsModal } from '../modals/step_3/SelectTabsModal'; import { DocumentTabProps, WorkflowFailureReason, WorkflowRunError } from '@types'; @@ -29,7 +28,6 @@ enum FlowStep { CONTENT_TYPE_PICKER = 'contentTypePicker', SELECT_TABS = 'selectTabs', INCLUDE_IMAGES = 'includeImages', - LOADING = 'loading', } interface ModalOrchestratorProps { @@ -40,7 +38,6 @@ interface ModalOrchestratorProps { onReconnectGoogleDrive?: () => Promise; onAiAccessDenied?: (message: string) => void; onRunStarted: (runId: string) => void; - onResetToMain: () => void; addRun: (record: RunRecord) => void; storageError: string | null; } @@ -60,7 +57,6 @@ export const ModalOrchestrator = forwardRef undefined, onRunStarted, - onResetToMain, onAiAccessDenied, addRun, storageError, @@ -122,7 +118,6 @@ export const ModalOrchestrator = forwardRef { - if (flowStep === FlowStep.LOADING) return; showDiscardConfirmation(); }; @@ -130,13 +125,11 @@ export const ModalOrchestrator = forwardRef { setIsConfirmCancelModalOpen(false); resetProgress(); - onResetToMain(); }; const showWorkflowError = (error?: unknown) => { @@ -257,14 +250,13 @@ export const ModalOrchestrator = forwardRef { if (isAiAccessDeniedError(error)) { resetProgress(); - onResetToMain(); onAiAccessDenied?.(error.message); return; } showWorkflowError(error); }, - [onAiAccessDenied, onResetToMain, resetProgress] + [onAiAccessDenied, resetProgress] ); const handleUploadModalCloseRequest = (docId?: string, docTitle?: string) => { @@ -319,7 +311,6 @@ export const ModalOrchestrator = forwardRef ); - case FlowStep.LOADING: - return ( - - ); default: return null; } @@ -494,7 +476,7 @@ export const ModalOrchestrator = forwardRef + shouldCloseOnEscapePress={true}> {renderFlowStep} diff --git a/apps/drive-integration/test/hooks/useRunsPolling.test.ts b/apps/drive-integration/test/hooks/useRunsPolling.test.ts index d3ed766ea5..1cc7205513 100644 --- a/apps/drive-integration/test/hooks/useRunsPolling.test.ts +++ b/apps/drive-integration/test/hooks/useRunsPolling.test.ts @@ -1,5 +1,5 @@ -import { renderHook, waitFor } from '@testing-library/react'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { useRunsPolling } from '../../src/hooks/useRunsPolling'; import { RunStatus } from '@types'; import { createMockSDK } from '../mocks'; @@ -53,10 +53,10 @@ beforeEach(() => { }); describe('useRunsPolling', () => { - it('initialises all runIds as loading', () => { + it('statusMap is empty before first fetch resolves', () => { mockGetWorkflowRun.mockResolvedValue({ sys: { status: RunStatus.COMPLETED } }); const { result } = renderHook(() => useRunsPolling(SINGLE_RUN, mockSdk)); - expect(result.current.statusMap.get('run-1')).toBe('loading'); + expect(result.current.statusMap.size).toBe(0); }); it('maps IN_PROGRESS to running', async () => { @@ -94,10 +94,30 @@ describe('useRunsPolling', () => { await waitFor(() => expect(result.current.statusMap.get('run-1')).toBe('failed')); }); - it('maps null response (404) to expired', async () => { + it('maps null response to expired only after MAX_CONSECUTIVE_NULLS consecutive misses', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); mockGetWorkflowRun.mockResolvedValue(null); - const { result } = renderHook(() => useRunsPolling(SINGLE_RUN, mockSdk)); + + const { result, unmount } = renderHook(() => useRunsPolling(SINGLE_RUN, mockSdk)); + + // Let first fetch resolve (1 miss, not yet expired) + await act(() => vi.runAllTicks()); + await act(() => Promise.resolve()); + expect(result.current.statusMap.get('run-1')).toBeUndefined(); + + // Drive 4 more ticks (total 5 = MAX_CONSECUTIVE_NULLS) + for (let i = 0; i < 4; i++) { + await act(async () => { + vi.advanceTimersByTime(10_000); + await vi.runAllTicks(); + await Promise.resolve(); + }); + } + await waitFor(() => expect(result.current.statusMap.get('run-1')).toBe('expired')); + + unmount(); + vi.useRealTimers(); }); it('populates errorMap for failed runs', async () => { diff --git a/apps/drive-integration/test/hooks/useWorkflowAgent.test.ts b/apps/drive-integration/test/hooks/useWorkflowAgent.test.ts index e4bb06e9f9..74e08b473a 100644 --- a/apps/drive-integration/test/hooks/useWorkflowAgent.test.ts +++ b/apps/drive-integration/test/hooks/useWorkflowAgent.test.ts @@ -59,38 +59,6 @@ describe('useWorkflowAgent', () => { expect(mockPollAgentRun).not.toHaveBeenCalled(); }); - it('isAnalyzing is true during startWorkflow and false after', async () => { - let resolveRun: (v: string) => void; - mockStartAgentRun.mockReturnValue( - new Promise((resolve) => { - resolveRun = resolve; - }) - ); - - const { result } = renderHook(() => - useWorkflowAgent({ sdk: mockSdk, documentId: 'doc-1', oauthToken: 'token' }) - ); - - expect(result.current.isAnalyzing).toBe(false); - - let workflowPromise: Promise; - act(() => { - workflowPromise = result.current.startWorkflow(['ct-1'], { - includeImages: false, - selectedTabIds: [], - }); - }); - - expect(result.current.isAnalyzing).toBe(true); - - await act(async () => { - resolveRun!('run-xyz'); - await workflowPromise!; - }); - - expect(result.current.isAnalyzing).toBe(false); - }); - it('startWorkflow throws if startAgentRun throws', async () => { mockStartAgentRun.mockRejectedValue(new Error('Network error')); @@ -103,7 +71,5 @@ describe('useWorkflowAgent', () => { result.current.startWorkflow(['ct-1'], { includeImages: false, selectedTabIds: [] }) ) ).rejects.toThrow('Network error'); - - expect(result.current.isAnalyzing).toBe(false); }); }); diff --git a/apps/drive-integration/test/locations/Page/Page.spec.tsx b/apps/drive-integration/test/locations/Page/Page.spec.tsx index 1a5ffb9852..4750bfc0c9 100644 --- a/apps/drive-integration/test/locations/Page/Page.spec.tsx +++ b/apps/drive-integration/test/locations/Page/Page.spec.tsx @@ -66,6 +66,7 @@ vi.mock('../../../src/hooks/useRunsPolling', () => ({ useRunsPolling: () => ({ statusMap: new Map(), errorMap: new Map(), + titleMap: new Map(), }), })); @@ -116,7 +117,6 @@ vi.mock('../../../src/locations/Page/components/mainpage/ModalOrchestrator', () props: { onAiAccessDenied: (message: string) => void; onRunStarted: (runId: string) => void; - onResetToMain: () => void; oauthToken: string; }, ref: React.ForwardedRef<{ startFlow: () => void; resetFlow: () => void }> @@ -131,7 +131,7 @@ vi.mock('../../../src/locations/Page/components/mainpage/ModalOrchestrator', () - - - ); - } - - if (isLoadingReviewPayload || !pendingReviewPayload) { - return ( - - - - ); - } - - return ( - handleCancelReview(appView.runId, graph)} - onExitReview={handleExitReview} - onRunCompleted={(entryIds) => handleRunCompleted(appView.runId, entryIds)} - /> - ); - } - } - }; - - return ( - <> - - {renderView()} - - - - - ); + const flags = useGoogleDocsAgentFlags(); + return flags['google-docs-async-runs'] ? : ; }; export default Page; diff --git a/apps/drive-integration/src/locations/Page/PageAsyncRuns.tsx b/apps/drive-integration/src/locations/Page/PageAsyncRuns.tsx new file mode 100644 index 0000000000..5b31035956 --- /dev/null +++ b/apps/drive-integration/src/locations/Page/PageAsyncRuns.tsx @@ -0,0 +1,296 @@ +import { useEffect, useRef, useState } from 'react'; +import { PageAppSDK } from '@contentful/app-sdk'; +import { useSDK } from '@contentful/react-apps-toolkit'; +import { Button, Flex, Layout, Note, Spinner } from '@contentful/f36-components'; +import { + ModalOrchestrator, + ModalOrchestratorHandle, +} from './components/mainpage/ModalOrchestrator'; +import { ReviewPage } from './components/review/ReviewPage'; +import { RunsPage } from './components/runs/RunsPage'; +import type { AppView, MappingReviewSuspendPayload } from '@types'; +import { AppViewKind } from '../../types/runs'; +import type { EntryBlockGraph } from '../../types/entryBlockGraph'; +import { useGoogleDriveOAuth } from '@hooks/useGoogleDriveOAuth'; +import { isAiAccessDeniedError } from '../../utils/aiAccess'; +import { resumeAndPollWorkflow } from '../../services/workflowService'; +import { useRunStorage } from '../../hooks/useRunStorage'; +import { getWorkflowRun, startAgentRun } from '../../services/agents-api'; +import { + WORKFLOW_AGENT_ID, + MAX_PENDING_REVIEW_MISSING_PAYLOAD_RETRIES, + POLL_INTERVAL_MS, +} from '../../utils/constants/agent'; + +export const PageAsyncRuns = () => { + const sdk = useSDK(); + const modalOrchestratorRef = useRef(null); + + const [aiAccessDeniedMessage, setAiAccessDeniedMessage] = useState(null); + const [appView, setAppView] = useState({ view: AppViewKind.RUNS }); + const [pendingReviewPayload, setPendingReviewPayload] = + useState(null); + const [isLoadingReviewPayload, setIsLoadingReviewPayload] = useState(false); + const [reviewPayloadFailed, setReviewPayloadFailed] = useState(false); + + const spaceId = sdk.ids.space; + const environmentId = sdk.ids.environmentAlias ?? sdk.ids.environment; + + const { runs, addRun, removeRun, retryRun, markCompleted, storageError } = useRunStorage( + spaceId, + environmentId + ); + + const { oauthToken, isOAuthConnected, isOAuthBusy, startOAuth, disconnectOAuth } = + useGoogleDriveOAuth(sdk); + + // When navigating to the review view, fetch the suspend payload from the backend. + // Retries up to MAX_PENDING_REVIEW_MISSING_PAYLOAD_RETRIES times because the backend + // writes PENDING_REVIEW status before the suspendPayload metadata flushes (~50s window). + useEffect(() => { + if (appView.view !== AppViewKind.REVIEW) { + setPendingReviewPayload(null); + setIsLoadingReviewPayload(false); + setReviewPayloadFailed(false); + return; + } + + let isCancelled = false; + let attempt = 0; + setIsLoadingReviewPayload(true); + setReviewPayloadFailed(false); + + const tryFetch = () => { + void getWorkflowRun(sdk, spaceId, environmentId, appView.runId) + .then((runData) => { + if (isCancelled) return; + const payload = runData?.metadata?.suspendPayload ?? null; + if (payload) { + setPendingReviewPayload(payload); + setIsLoadingReviewPayload(false); + return; + } + attempt++; + if (attempt < MAX_PENDING_REVIEW_MISSING_PAYLOAD_RETRIES) { + setTimeout(tryFetch, POLL_INTERVAL_MS); + } else { + setIsLoadingReviewPayload(false); + setReviewPayloadFailed(true); + } + }) + .catch(() => { + if (isCancelled) return; + attempt++; + if (attempt < MAX_PENDING_REVIEW_MISSING_PAYLOAD_RETRIES) { + setTimeout(tryFetch, POLL_INTERVAL_MS); + } else { + setIsLoadingReviewPayload(false); + setReviewPayloadFailed(true); + } + }); + }; + + tryFetch(); + + return () => { + isCancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- only re-fetch when the runId changes + }, [appView.view === AppViewKind.REVIEW ? appView.runId : null]); + + const handleSelectFile = () => { + modalOrchestratorRef.current?.startFlow(); + }; + + const handleAiAccessDenied = (message: string) => { + setAiAccessDeniedMessage(message); + }; + + const handleAiAccessRestored = () => { + if (aiAccessDeniedMessage !== null) { + setAiAccessDeniedMessage(null); + } + }; + + const handleRunStarted = () => { + setAppView({ view: AppViewKind.RUNS }); + }; + + const handleReviewRun = (runId: string) => { + setAppView({ view: AppViewKind.REVIEW, runId }); + }; + + const handleExitReview = () => { + modalOrchestratorRef.current?.resetFlow(); + handleRunStarted(); + }; + + const handleRunCompleted = (runId: string, entryIds: string[]) => { + markCompleted(runId, entryIds); + }; + + const handleCancelReview = async (runId: string, entryBlockGraph: EntryBlockGraph) => { + try { + await resumeAndPollWorkflow(sdk, runId, { cancelled: true, entryBlockGraph }); + } catch (error) { + console.error(error); + } + modalOrchestratorRef.current?.resetFlow(); + handleRunStarted(); + }; + + const handleRetryRun = async (runId: string) => { + const record = runs.find((r) => r.runId === runId); + if (!record) return; + + const threadId = [crypto.randomUUID(), WORKFLOW_AGENT_ID].join('-'); + const newRunId = await startAgentRun(sdk, spaceId, environmentId, { + messages: [ + { + role: 'user', + parts: [ + { + type: 'text', + text: `Analyze the following google docs document ${ + record.documentId + } and extract the Contentful entries and assets for the following content types: ${record.contentTypeIds.join( + ', ' + )}`, + }, + ], + }, + ], + metadata: { + documentId: record.documentId, + contentTypeIds: record.contentTypeIds, + oauthToken, + documentSelection: record.documentSelection, + }, + threadId, + }); + + retryRun(runId, { + ...record, + runId: newRunId, + startedAt: new Date().toISOString(), + createdEntryIds: undefined, + }); + }; + + const handleConnectGoogleDrive = async () => { + handleAiAccessRestored(); + try { + await startOAuth(); + } catch (error) { + if (isAiAccessDeniedError(error)) { + handleAiAccessDenied(error.message); + } + } + }; + + const handleDisconnectGoogleDrive = async () => { + try { + await disconnectOAuth(); + } catch (error) { + if (isAiAccessDeniedError(error)) { + handleAiAccessDenied(error.message); + } + } + }; + + if (aiAccessDeniedMessage !== null) { + return ( + + + + {aiAccessDeniedMessage} + + + + ); + } + + const renderView = () => { + switch (appView.view) { + case AppViewKind.RUNS: + return ( + + ); + + case AppViewKind.REVIEW: { + if (reviewPayloadFailed) { + return ( + + + Could not load the review data. The import may still be processing. + + + + ); + } + + if (isLoadingReviewPayload || !pendingReviewPayload) { + return ( + + + + ); + } + + return ( + handleCancelReview(appView.runId, graph)} + onExitReview={handleExitReview} + onRunCompleted={(entryIds) => handleRunCompleted(appView.runId, entryIds)} + /> + ); + } + } + }; + + return ( + <> + + {renderView()} + + + + + ); +}; + diff --git a/apps/drive-integration/src/locations/Page/PageLegacy.tsx b/apps/drive-integration/src/locations/Page/PageLegacy.tsx new file mode 100644 index 0000000000..ab4c6b9dca --- /dev/null +++ b/apps/drive-integration/src/locations/Page/PageLegacy.tsx @@ -0,0 +1,145 @@ +import { useRef, useState } from 'react'; +import { PageAppSDK } from '@contentful/app-sdk'; +import { useSDK } from '@contentful/react-apps-toolkit'; +import { Flex, Heading, Layout, Note } from '@contentful/f36-components'; +import { + ModalOrchestratorLegacy, + ModalOrchestratorLegacyHandle, +} from './components/mainpage/ModalOrchestratorLegacy'; +import { MainPageView } from './components/mainpage/MainPageView'; +import { ReviewPage } from './components/review/ReviewPage'; +import type { MappingReviewSuspendPayload } from '@types'; +import { useWorkflowAgentLegacy } from '@hooks/useWorkflowAgentLegacy'; +import { useGoogleDriveOAuth } from '@hooks/useGoogleDriveOAuth'; +import { isAiAccessDeniedError } from '../../utils/aiAccess'; + +export const PageLegacy = () => { + const sdk = useSDK(); + const modalOrchestratorRef = useRef(null); + const [aiAccessDeniedMessage, setAiAccessDeniedMessage] = useState(null); + const [mappingReviewState, setMappingReviewState] = useState<{ + payload: MappingReviewSuspendPayload; + runId?: string; + } | null>(null); + const { oauthToken, isOAuthConnected, isOAuthLoading, isOAuthBusy, startOAuth, disconnectOAuth } = + useGoogleDriveOAuth(sdk); + const { resumeWorkflow } = useWorkflowAgentLegacy({ + sdk, + documentId: '', + oauthToken: '', + }); + + + const handleSelectFile = () => { + modalOrchestratorRef.current?.startFlow(); + }; + + const handleAiAccessDenied = (message: string) => { + setAiAccessDeniedMessage(message); + setMappingReviewState(null); + }; + + const handleAiAccessRestored = () => { + if (aiAccessDeniedMessage !== null) setAiAccessDeniedMessage(null); + }; + + const handleMappingReviewReady = (payload: MappingReviewSuspendPayload, runId: string) => { + setMappingReviewState({ payload, runId }); + }; + + const handleReturnToMainPage = () => { + setMappingReviewState(null); + }; + + const resetFlowAndReturnToMainPage = () => { + modalOrchestratorRef.current?.resetFlow(); + handleReturnToMainPage(); + }; + + const handleCancelMappingReview = async () => { + if (!mappingReviewState?.runId) { + resetFlowAndReturnToMainPage(); + return; + } + try { + await resumeWorkflow(mappingReviewState.runId, { cancelled: true }); + } catch (error) { + console.error(error); + } finally { + resetFlowAndReturnToMainPage(); + } + }; + + const handleConnectGoogleDrive = async () => { + handleAiAccessRestored(); + try { + await startOAuth(); + } catch (error) { + if (isAiAccessDeniedError(error)) handleAiAccessDenied(error.message); + } + }; + + const handleDisconnectGoogleDrive = async () => { + try { + await disconnectOAuth(); + } catch (error) { + if (isAiAccessDeniedError(error)) handleAiAccessDenied(error.message); + } + }; + + if (aiAccessDeniedMessage !== null) { + return ( + + + + Drive Integration + {aiAccessDeniedMessage} + + + + ); + } + + return ( + <> + + {mappingReviewState ? ( + + ) : ( + <> + + + )} + + + + + ); +}; diff --git a/apps/drive-integration/src/locations/Page/components/mainpage/ModalOrchestratorLegacy.tsx b/apps/drive-integration/src/locations/Page/components/mainpage/ModalOrchestratorLegacy.tsx new file mode 100644 index 0000000000..27887ee7df --- /dev/null +++ b/apps/drive-integration/src/locations/Page/components/mainpage/ModalOrchestratorLegacy.tsx @@ -0,0 +1,514 @@ +import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useState } from 'react'; +import { PageAppSDK } from '@contentful/app-sdk'; +import { Modal } from '@contentful/f36-components'; +import { ContentTypeProps } from 'contentful-management'; +import { ConfirmCancelModal } from '../modals/ConfirmCancelModal'; +import { ErrorModal, type ErrorModalConfig } from '../modals/ErrorModal'; +import SelectDocumentModal from '../modals/step_1/SelectDocumentModal'; +import { LoadingModal } from '../modals/LoadingModal'; +import { ERROR_MESSAGES } from '@constants/messages'; +import { CONTENT_TYPE_SUBMIT_LOADING_DELAY_MS } from '@constants/agent'; +import { SelectTabsModal } from '../modals/step_3/SelectTabsModal'; +import { + DocumentTabProps, + MappingReviewSuspendPayload, + CompletedWorkflowPayload, + ResumePayload, + TabsImagesSuspendPayload, + RunStatus, + WorkflowRunResult, + WorkflowFailureReason, + WorkflowRunError, +} from '@types'; +import { ContentTypePickerModal } from '../modals/step_2/ContentTypePickerModal'; +import { IncludeImagesModal } from '../modals/step_4/IncludeImagesModal'; +import { useWorkflowAgentLegacy } from '@hooks/useWorkflowAgentLegacy'; +import { isAiAccessDeniedError } from '../../../../utils/aiAccess'; + +export interface ModalOrchestratorLegacyHandle { + startFlow: () => void; + resetFlow: () => void; +} + +enum FlowStep { + CONTENT_TYPE_PICKER = 'contentTypePicker', + SELECT_TABS = 'selectTabs', + INCLUDE_IMAGES = 'includeImages', + LOADING = 'loading', +} + +interface ModalOrchestratorProps { + sdk: PageAppSDK; + oauthToken: string; + isOAuthConnected?: boolean; + isOAuthBusy?: boolean; + onReconnectGoogleDrive?: () => Promise; + onAiAccessDenied?: (message: string) => void; + onMappingReviewReady: (payload: MappingReviewSuspendPayload, runId: string) => void; + onResetToMain: () => void; +} + +interface PreviewErrorState { + reason: WorkflowFailureReason; + title: string; + message: string; +} + +export const ModalOrchestratorLegacy = forwardRef( + ( + { + sdk, + oauthToken, + isOAuthConnected = false, + isOAuthBusy = false, + onReconnectGoogleDrive = async () => undefined, + onMappingReviewReady, + onResetToMain, + onAiAccessDenied, + }, + ref + ) => { + const [isUploadModalOpen, setIsUploadModalOpen] = useState(false); + const [isConfirmCancelModalOpen, setIsConfirmCancelModalOpen] = useState(false); + const [previewErrorState, setPreviewErrorState] = useState(null); + const [isReconnectPending, setIsReconnectPending] = useState(false); + const [flowStep, setFlowStep] = useState(null); + const [documentId, setDocumentId] = useState(''); + const [selectedContentTypes, setSelectedContentTypes] = useState([]); + const [availableTabs, setAvailableTabs] = useState([]); + const [selectedTabs, setSelectedTabs] = useState([]); + const [useAllTabs, setUseAllTabs] = useState(null); + const [includeImages, setIncludeImages] = useState(null); + const [requiresImageSelection, setRequiresImageSelection] = useState(false); + const [activeRunId, setActiveRunId] = useState(null); + const { startWorkflow, resumeWorkflow } = useWorkflowAgentLegacy({ + sdk, + documentId, + oauthToken, + }); + + const hasProgressToLose = documentId.trim().length > 0; + + useImperativeHandle(ref, () => ({ + startFlow: () => setIsUploadModalOpen(true), + resetFlow: () => { + setIsConfirmCancelModalOpen(false); + setPreviewErrorState(null); + setIsReconnectPending(false); + resetProgress(); + }, + })); + + const resetDocumentScopeReview = () => { + setAvailableTabs([]); + setSelectedTabs([]); + setUseAllTabs(null); + setIncludeImages(null); + setRequiresImageSelection(false); + }; + + const resetProgress = () => { + setDocumentId(''); + setSelectedContentTypes([]); + resetDocumentScopeReview(); + setActiveRunId(null); + setFlowStep(null); + setIsUploadModalOpen(false); + }; + + const showDiscardConfirmation = () => { + if (!hasProgressToLose) return; + setIsConfirmCancelModalOpen(true); + }; + + const handleFlowModalCloseRequest = () => { + if (flowStep === FlowStep.LOADING) return; + showDiscardConfirmation(); + }; + + const closePreviewErrorAndReset = useCallback(() => { + setPreviewErrorState(null); + setIsReconnectPending(false); + resetProgress(); + onResetToMain(); + }, [onResetToMain]); + + const handleConfirmCancel = async () => { + setIsConfirmCancelModalOpen(false); + + if (activeRunId) { + try { + await resumeWorkflow(activeRunId, { cancelled: true }); + } catch (error) { + console.error(error); + } + } + + resetProgress(); + onResetToMain(); + }; + + const showWorkflowError = (error?: unknown) => { + setFlowStep(null); + + if ( + error instanceof WorkflowRunError && + error.reason === WorkflowFailureReason.GOOGLE_DRIVE_AUTH_EXPIRED + ) { + setPreviewErrorState({ + reason: WorkflowFailureReason.GOOGLE_DRIVE_AUTH_EXPIRED, + title: 'Reconnect Drive to continue', + message: ERROR_MESSAGES.GOOGLE_DRIVE_AUTH_ERROR, + }); + return; + } + + if ( + error instanceof WorkflowRunError && + error.reason === WorkflowFailureReason.GOOGLE_DOCS_NOT_FOUND + ) { + setPreviewErrorState({ + reason: WorkflowFailureReason.GOOGLE_DOCS_NOT_FOUND, + title: 'Document not found', + message: ERROR_MESSAGES.GOOGLE_DOCS_NOT_FOUND, + }); + return; + } + + if ( + error instanceof WorkflowRunError && + error.reason === WorkflowFailureReason.AI_SERVICE_UNAVAILABLE + ) { + setPreviewErrorState({ + reason: WorkflowFailureReason.AI_SERVICE_UNAVAILABLE, + title: 'AI service temporarily unavailable', + message: ERROR_MESSAGES.AI_SERVICE_UNAVAILABLE, + }); + return; + } + + if ( + error instanceof WorkflowRunError && + error.reason === WorkflowFailureReason.APP_NOT_INSTALLED + ) { + setPreviewErrorState({ + reason: WorkflowFailureReason.APP_NOT_INSTALLED, + title: 'App not installed in this environment', + message: ERROR_MESSAGES.APP_NOT_INSTALLED, + }); + return; + } + + if ( + error instanceof WorkflowRunError && + error.reason === WorkflowFailureReason.DOCUMENT_TOO_COMPLEX + ) { + setPreviewErrorState({ + reason: WorkflowFailureReason.DOCUMENT_TOO_COMPLEX, + title: 'Document too large to import', + message: ERROR_MESSAGES.DOCUMENT_TOO_COMPLEX, + }); + return; + } + + if ( + error instanceof WorkflowRunError && + error.reason === WorkflowFailureReason.PROCESSING_TIMEOUT + ) { + setPreviewErrorState({ + reason: WorkflowFailureReason.PROCESSING_TIMEOUT, + title: 'Import timed out', + message: ERROR_MESSAGES.PROCESSING_TIMEOUT, + }); + return; + } + + if ( + error instanceof WorkflowRunError && + error.reason === WorkflowFailureReason.OUT_OF_DOMAIN + ) { + setPreviewErrorState({ + reason: WorkflowFailureReason.OUT_OF_DOMAIN, + title: 'Document not supported', + message: ERROR_MESSAGES.OUT_OF_DOMAIN, + }); + return; + } + + setPreviewErrorState({ + reason: WorkflowFailureReason.GENERIC, + title: 'Unable to generate preview', + message: ERROR_MESSAGES.GENERIC_ERROR, + }); + }; + + useEffect(() => { + if (!isReconnectPending || isOAuthBusy || !isOAuthConnected) { + return; + } + + closePreviewErrorAndReset(); + }, [closePreviewErrorAndReset, isOAuthBusy, isOAuthConnected, isReconnectPending]); + + const handleWorkflowError = (error: unknown) => { + if (isAiAccessDeniedError(error)) { + resetProgress(); + onResetToMain(); + onAiAccessDenied?.(error.message); + return; + } + + showWorkflowError(error); + }; + + const handleUploadModalCloseRequest = (docId?: string) => { + if (docId) { + setDocumentId(docId); + setIsUploadModalOpen(false); + setFlowStep(FlowStep.CONTENT_TYPE_PICKER); + return; + } + + setIsUploadModalOpen(false); + showDiscardConfirmation(); + }; + + const showDocumentScopeReview = (suspendPayload?: TabsImagesSuspendPayload) => { + setAvailableTabs( + (suspendPayload?.tabs ?? []).map((tab) => ({ + tabId: tab.id ?? '', + tabTitle: tab.title ?? '', + })) + ); + setSelectedTabs([]); + setUseAllTabs(null); + setIncludeImages(null); + setRequiresImageSelection(Boolean(suspendPayload?.requiresImageSelection)); + + if (suspendPayload?.requiresTabSelection) { + setFlowStep(FlowStep.SELECT_TABS); + return; + } + + if (suspendPayload?.requiresImageSelection) { + setFlowStep(FlowStep.INCLUDE_IMAGES); + return; + } + + setFlowStep(null); + }; + + const handleWorkflowResult = (workflowRun: WorkflowRunResult) => { + setActiveRunId(workflowRun.runId); + + if (workflowRun.status === RunStatus.PENDING_REVIEW) { + if (workflowRun.suspendPayload.suspendStepId === 'mapping-review') { + setFlowStep(null); + onMappingReviewReady(workflowRun.suspendPayload, workflowRun.runId); + return; + } + + showDocumentScopeReview(workflowRun.suspendPayload as unknown as TabsImagesSuspendPayload); + return; + } + + setFlowStep(null); + }; + + const continueWorkflow = async (resumePayloadOverrides?: Partial) => { + if (!activeRunId) { + throw new Error('Workflow run id is missing for resume.'); + } + + const resumePayload: ResumePayload = { + ...(selectedTabs.length > 0 + ? { selectedTabIds: selectedTabs.map((tab) => tab.tabId) } + : {}), + ...(includeImages !== null ? { includeImages } : {}), + ...resumePayloadOverrides, + }; + + setFlowStep(FlowStep.LOADING); + + const workflowRun = await resumeWorkflow(activeRunId, resumePayload); + handleWorkflowResult(workflowRun); + }; + + const startWorkflowWithDelayedLoading = async (contentTypeIds: string[]) => { + let isStartPending = true; + const loadingModalTimeout = window.setTimeout(() => { + if (isStartPending) { + setFlowStep(FlowStep.LOADING); + } + }, CONTENT_TYPE_SUBMIT_LOADING_DELAY_MS); + + try { + return await startWorkflow(contentTypeIds, { selectedTabIds: [], includeImages: false }); + } finally { + isStartPending = false; + window.clearTimeout(loadingModalTimeout); + } + }; + + const handleContentTypeContinue = async (contentTypeIds: string[]) => { + if (!isOAuthConnected) { + showWorkflowError( + new WorkflowRunError( + ERROR_MESSAGES.GOOGLE_DRIVE_AUTH_ERROR, + WorkflowFailureReason.GOOGLE_DRIVE_AUTH_EXPIRED + ) + ); + return; + } + + try { + handleWorkflowResult(await startWorkflowWithDelayedLoading(contentTypeIds)); + } catch (error) { + handleWorkflowError(error); + } + }; + + const handleSelectTabsContinue = async (selectedTabs: DocumentTabProps[]) => { + setSelectedTabs(selectedTabs); + + if (requiresImageSelection) { + setFlowStep(FlowStep.INCLUDE_IMAGES); + return; + } + + try { + await continueWorkflow({ selectedTabIds: selectedTabs.map((tab) => tab.tabId) }); + } catch (error) { + handleWorkflowError(error); + } + }; + + const handleIncludeImagesContinue = async (includeImages: boolean) => { + setIncludeImages(includeImages); + + try { + await continueWorkflow({ includeImages }); + } catch (error) { + handleWorkflowError(error); + } + }; + + const handleReconnectGoogleDrive = useCallback(async () => { + setIsReconnectPending(true); + + try { + await onReconnectGoogleDrive(); + } catch (error) { + handleWorkflowError(error); + setIsReconnectPending(false); + } + }, [handleWorkflowError, onReconnectGoogleDrive]); + + const errorModalConfig = useMemo(() => { + if (previewErrorState?.reason === WorkflowFailureReason.GOOGLE_DRIVE_AUTH_EXPIRED) { + return { + title: previewErrorState.title, + message: previewErrorState.message, + primaryActionLabel: 'Reconnect Drive', + onPrimaryAction: () => void handleReconnectGoogleDrive(), + secondaryActionLabel: 'Close', + onSecondaryAction: closePreviewErrorAndReset, + isPrimaryActionLoading: isReconnectPending && isOAuthBusy, + }; + } + + return { + title: previewErrorState?.title ?? 'Unable to generate preview', + message: previewErrorState?.message ?? ERROR_MESSAGES.GENERIC_ERROR, + primaryActionLabel: 'Close', + onPrimaryAction: closePreviewErrorAndReset, + isPrimaryActionLoading: false, + }; + }, [ + closePreviewErrorAndReset, + handleReconnectGoogleDrive, + isOAuthBusy, + isReconnectPending, + previewErrorState, + ]); + + const renderFlowStep = () => { + switch (flowStep) { + case FlowStep.CONTENT_TYPE_PICKER: + return ( + + ); + case FlowStep.SELECT_TABS: + return ( + + ); + case FlowStep.INCLUDE_IMAGES: + return ( + + ); + case FlowStep.LOADING: + return ( + + ); + default: + return null; + } + }; + + return ( + <> + + + + {renderFlowStep} + + + setIsConfirmCancelModalOpen(false)} + /> + + + + ); + } +); + +ModalOrchestratorLegacy.displayName = 'ModalOrchestratorLegacy'; diff --git a/apps/drive-integration/test/locations/Page/Page.spec.tsx b/apps/drive-integration/test/locations/Page/Page.spec.tsx index 4750bfc0c9..446348dd94 100644 --- a/apps/drive-integration/test/locations/Page/Page.spec.tsx +++ b/apps/drive-integration/test/locations/Page/Page.spec.tsx @@ -145,6 +145,13 @@ vi.mock('../../../src/locations/Page/components/mainpage/ModalOrchestrator', () ), })); +vi.mock('../../../src/hooks/useGoogleDocsAgentFlags', () => ({ + useGoogleDocsAgentFlags: () => ({ + 'google-docs-async-runs': true, + 'google-docs-agent-improvements': false, + }), +})); + vi.mock('../../../src/locations/Page/components/runs/RunsPage', () => ({ RunsPage: ({ onStartImport,