diff --git a/docs/plans/2026-06-24-001-refactor-dashboard-app-tsx-module-breakup-plan.md b/docs/plans/2026-06-24-001-refactor-dashboard-app-tsx-module-breakup-plan.md new file mode 100644 index 0000000000..d70e23d5dd --- /dev/null +++ b/docs/plans/2026-06-24-001-refactor-dashboard-app-tsx-module-breakup-plan.md @@ -0,0 +1,301 @@ +--- +title: "refactor: Break the dashboard App.tsx into smaller modules" +type: refactor +status: completed +date: 2026-06-24 +--- + +# refactor: Break the dashboard `App.tsx` into smaller modules + +## Summary + +A behavior-preserving decomposition of `packages/dashboard/app/App.tsx` (2,724 lines today; grandfathered at a 2,729-line ratchet baseline against `scripts/check-file-line-count.mjs`; a single ~2,350-line `AppInner` component): extract its inline state/effect/handler clusters into custom hooks under `app/hooks/`, its pure helpers and constants into `app/utils/`, and its two large render blocks into presentational components under `app/components/` — mirroring the codebase's existing conventions — with the explicit goal of graduating `App.tsx` below the 2,000-line file-count cap so it leaves the ratchet. + +## Problem Frame + +`App.tsx` is the Fusion dashboard's root component and its largest file by far. It is grandfathered at 2,729 lines (the ratchet baseline; the file is currently 2,724) against `scripts/check-file-line-count.mjs` (cap 2,000). Almost all of that bulk lives in one `AppInner()` function (lines ~352–2706), which interleaves ~25 `useState` calls, dozens of `useEffect`/`useMemo`/`useCallback` blocks, several SSE subscriptions and polling loops, the approval-banner dedupe state machine, and two large JSX render blocks — a ~650-line `renderMainContent()` view-switch and a ~430-line provider/header/sidebar/modals shell tree. + +This is a maintenance and review hazard: every dashboard change edits the same monolith, behavior is hard to test in isolation, and the file's size is held in place only by a ratchet baseline rather than by design. The codebase already demonstrates the extraction target — `app/hooks/` holds ~95 custom hooks (including the 24 KB `useTasks`), and `AppInner` itself already consumes ~25 of them — so the inline logic is the remaining un-factored surface, and the conventions to factor it are established. + +The work is strictly behavior-preserving: no feature, UX, or contract change. The success metric is concrete and machine-checked — drive `App.tsx` below 2,000 lines so it leaves the ratchet baseline — gated on the existing behavior contract (`App.test.tsx`, the exported pure-function unit tests) staying green. + +--- + +## Requirements + +### Behavior preservation + +- R1. All existing dashboard behavior is preserved: no functional, UX, rendering, or timing change. Verified by `packages/dashboard/app/components/__tests__/App.test.tsx` (the 4,273-line full-render behavior contract), the exported pure-function unit tests, and a browser smoke check against a freshly built bundle. +- R2. The seven pure functions currently exported from `App.tsx` (`shouldShowFirstEverBootLoader`, `requiresNativeShellOnboarding`, `executeCliSessionBannerAction`, `getCliActionDisabledReasonForBanner`, `isSessionNeedingInputForBanner`, `didEnterAwaitingApproval`, `didEnterDone`) remain importable from `App` so their existing unit tests (`app/__tests__/App.boot-gate.test.tsx`, `App.shell-onboarding.test.tsx`, `app-cli-action-wiring.test.tsx`, and `App.test.tsx`) stay green without test edits. + +### Structure and the line-count ratchet + +- R3. `App.tsx` line count drops below 2,000, and the file is removed from the ratchet baseline (`scripts/line-count-baseline.json`) via the reviewed `--update` path so it can never regress. +- R4. Every new file is ≤ 2,000 lines and follows the established conventions: custom hooks return an object with `UseXxxOptions`/`UseXxxResult` interfaces and private helpers above the hook; imports are relative (no `@/` alias); no `any` in non-test code; no `eslint-disable react-hooks/exhaustive-deps`. + +### Load-bearing invariants honored + +- R5. The non-underscore `lazy()` view consts in `App.tsx` are unchanged; `packages/dashboard/app/__tests__/lazy-loaded-views-docs.test.ts` and the AGENTS.md "Lazy-Loaded Heavy Views" 20-view inventory stay green and unchanged. +- R6. The eager `import "./components/ChatView.css"` (lines 111–115) is preserved verbatim at the `App.tsx` top level — it is an intentional anti-lazy-load that prevents a flash of unstyled chat UI, documented only in that code comment. +- R7. React's rules-of-hooks and `AppInner`'s load-bearing hook-call ordering (the explicit "MUST be called before any conditional logic" sequence) are preserved; no hook becomes conditional as a result of extraction. +- R8. `FNXC:` requirement comments are carried into the extracted modules that own the behavior they describe and kept current (dated, greppable). + +### Verification + +- R9. The merge-blocking gate (`pnpm lint`, `packages/dashboard` typecheck, `pnpm build`) is green, `App.test.tsx` is green, and focused `renderHook` unit tests are added under `app/hooks/__tests__/` for the non-trivial extracted hooks (matching the `useUpdateCheck.test.ts` / `useAgents.test.ts` template). + +--- + +## Key Technical Decisions + +- KTD1. **Extraction strategy is hooks-first plus render-block splitting.** The inline state/effect/handler clusters become custom hooks (`app/hooks/`); the two large render blocks (`renderMainContent()` and the conditional-banner cluster) become presentational components (`app/components/`). This mirrors the codebase's dominant convention (95 existing hooks) and yields the biggest maintainability and line-count win. The full provider/shell wrapper (`AppShell`) is deferred — see Scope Boundaries — because hook + MainContent + Banners extraction alone clears the 2,000-line target, and wrapping the entire provider tree carries the most prop-drifting risk for the least marginal benefit. **Line budget (back-of-envelope, vs. the 2,724-line baseline):** U1 ~150, U2 ~170, U3 ~100, U4 ~50, U5 ~200, U6 ~80, U7 ~770 (MainContent ~640 + DashboardBanners ~130) — roughly 1,520 lines removed, landing `App.tsx` near ~1,200, comfortably under 2,000 even with a pessimistic extraction. `AppShell` is therefore a pure safety net, not load-bearing for R3. +- KTD2. **Pure helpers move to `app/utils/` with re-export shims in `App.tsx`** (acknowledged transient debt — see Deferred to Follow-Up Work). Rather than rewrite the import paths of the existing pure-function unit tests, the function bodies move to `app/utils/appLifecycle.ts` and `App.tsx` re-exports the seven tested symbols. The shim is intentionally partial: the complete cutover (re-pointing the test imports and dropping the re-export lines) is deferred to keep this change free of test churn; the shim is not an intentional long-term re-export surface. +- KTD3. **Extracted hooks mirror the `useAgents`/`useTasks` shape.** Object return value, `UseXxxOptions`/`UseXxxResult` interfaces, private helpers above the hook, and `readCache`/`writeCache` (`app/utils/swrCache.ts`) + `subscribeSse` (`app/sse-bus.ts`) exactly as the data hooks already use them. +- KTD4. **Preserve the single `task:updated` subscriber and its cross-concern wiring.** Today one `/api/events` subscription drives the approval-banner state machine, the first-done GitHub-star trigger, *and* a mailbox-count refresh inside the `task:updated`→`awaiting-approval` branch (App.tsx ~826). Extraction keeps `task:updated` + `approval:requested` banner logic in one hook (`useApprovalBanner`), which exposes an `onTaskEnteredAwaitingApproval: () => void` input that `AppInner` wires to `useMailboxUnread.refresh` so that count refresh still fires on exactly that transition. `useMailboxUnread` separately subscribes to `message:*` and `approval:*` count events (idempotent, and `subscribeSse` multiplexes them onto one shared `EventSource`). The banner trigger is never split across two `task:updated` handlers, preserving single-handler ordering. +- KTD5. **Verification posture = merge gate + behavior contract + targeted hook tests + browser smoke.** The merge gate (lint/typecheck/build) is the hard CI bar; `App.test.tsx` is the non-blocking behavior contract that must stay green; new `renderHook` tests cover hooks with real logic (dedupe, filtering, thresholding); a browser smoke against a freshly built bundle catches the jsdom-misses-stale-dist class of regression documented in `docs/solutions/`. No `any`, no exhaustive-deps disables, no changeset (private package + behavior-preserving). + +--- + +## High-Level Technical Design + +The decomposition splits `AppInner` along three seams — pure helpers, cohesive state clusters, and render blocks — each landing in the directory that already owns that concern. + +```mermaid +flowchart TB + subgraph App["App.tsx (AppInner) — orchestrator only"] + ORCH["hook calls in fixed order\n+ composition of handlers\n+ provider/shell tree"] + end + + subgraph Utils["app/utils/ (U1)"] + UL["appLifecycle.ts\npure fns + module constants\n(re-exported from App)"] + end + + subgraph Hooks["app/hooks/ (U2–U6)"] + H2["notification SSE hooks\nmailbox · chat-unread · stash"] + H3["useApprovalBanner\n+ GitHub-star trigger\n(existent useGitHubStarPrompt untouched)"] + H4["useBranchTaskFilters"] + H5["health · capacity · dismiss\nauth-recovery · shell-onboarding"] + H6["task-detail · board-scroll\npopped-out windows"] + end + + subgraph Components["app/components/dashboard/ (U7)"] + MC["MainContent\n(view-switch dispatcher)"] + DB["DashboardBanners\n(conditional banner cluster)"] + end + + UL --> H3 + UL --> H4 + UL --> H5 + H2 --> ORCH + H3 --> ORCH + H4 --> ORCH + H5 --> ORCH + H6 --> ORCH + ORCH -- "props bag" --> MC + ORCH -- "banner state" --> DB + AppTest["App.test.tsx\nfull-render behavior contract"] -. asserts .-> ORCH +``` + +The dependencies flow downward and rightward: utils feed constants/helpers to the hooks; hooks expose state + actions to `AppInner`, which stays the orchestrator (hook ordering, handler composition, the provider/shell tree); `AppInner` passes a props bag into the two extracted presentational components. `App.test.tsx` keeps rendering the real ``, so every extracted hook still runs for real unless the test mocks it by path. + +--- + +## Scope Boundaries + +**In scope:** `packages/dashboard/app/App.tsx` and the new modules it spawns under `app/utils/`, `app/hooks/`, and `app/components/dashboard/`. + +**Out of scope (non-goals):** + +- The separate terminal-dashboard TUI at `packages/cli/src/commands/dashboard-tui/app.tsx` (different file, different surface). +- Any reorganization of the `lazy()` view declarations or the `lazy-loaded-views-docs.test.ts` inventory. +- Any functional, UX, rendering, or timing change; new features; dependency additions or upgrades; changeset creation (private package + behavior-preserving). + +### Deferred to Follow-Up Work + +- **`AppShell` full-layout wrapper** — extracting the provider tree + `Header` + `LeftSidebarNav` + `ExecutorStatusBar` + `MobileNavBar` + floating windows + `AppModals` into one shell component. Highest prop-surface, lowest marginal benefit once U1–U7 land; pull in only if `App.tsx` is still over target after the other units (per the KTD1 line budget it should not be needed). +- **Re-pointing the seven pure-function test imports** from `'../../App'` to the new `app/utils/` module and dropping the KTD2 re-export shims. Deferred to avoid test churn inside a behavior-preservation change; the shim is explicitly transient debt, not a long-term surface. +- **Capturing the breakup's institutional knowledge** (module boundaries, hook seams, the FOUC-import and static-literal-lazy constraints) via `/ce-compound` — there is currently no `docs/solutions/` entry for an `App.tsx` decomposition. +- A dedicated `useMobileKeyboardFlags` hook for the ~30-line keyboard-flag + scroll-lock block; marginal and tightly coupled to `AppInner`'s `isMobile`/modal state. + +--- + +## Implementation Units + +The units are phased: U1 foundation → U2–U6 state extraction → U7 render extraction → U8 verification. Each unit is independently landable as one commit (U7 lands as two: `MainContent` then `DashboardBanners`) and should be verified against the gate (`pnpm --filter @fusion/dashboard typecheck`, `pnpm lint`) plus the relevant dashboard test project before the next begins. + +### U1. Extract pure helpers and constants to `app/utils/appLifecycle.ts` + +- **Goal:** Move the module-level pure functions and constants out of `App.tsx`, keeping `App`'s public exports stable via re-export so no existing test import breaks (KTD2). +- **Requirements:** R2, R3, R4. +- **Dependencies:** none. +- **Files:** + - `packages/dashboard/app/utils/appLifecycle.ts` (new) — receives the moved definitions. + - `packages/dashboard/app/App.tsx` (modify) — removes the definitions, imports them, and re-exports the seven tested symbols plus any types imported elsewhere (`ApprovalBannerCandidate`, `CliActionDeps`). +- **Approach:** Move `didEnterAwaitingApproval`, `didEnterDone`, `parseDateMs`, `loadApprovalBannerDismissals`, `persistApprovalBannerDismissals`, `buildRemoteDashboardUrl`, `shouldShowFirstEverBootLoader`, `requiresNativeShellOnboarding`, `isSessionNeedingInputForBanner`, `getCliActionDisabledReasonForBanner`, `executeCliSessionBannerAction`, and the `ApprovalBannerCandidate` and `CliActionDeps` interfaces into the new util, along with the module-level constants: the storage-key strings (`SETUP_WARNING_DISMISSED_KEY`, `WORKING_BRANCH_FILTER_STORAGE_KEY`, `BASE_BRANCH_FILTER_STORAGE_KEY`, `APPROVAL_BANNER_DISMISSED_STORAGE_KEY`, `CAPACITY_RISK_DISMISSED_KEY`), the `NO_BRANCH_FILTER_VALUE` sentinel, and the `RETRY_WARNING_RATIO` numeric threshold. `App.tsx` keeps `export { … } from "./utils/appLifecycle";` for the seven tested symbols so existing `from "../../App"` test imports resolve unchanged. +- **Patterns to follow:** existing `app/utils/` helpers (e.g. `boardScrollSnapshot.ts`, `mobileBarKeyboardFlags.ts`) — plain typed functions, no `any`, relative imports. +- **Test scenarios:** + - The three pure-function test files import from `App` and pass unchanged; `App.test.tsx`'s use of `didEnterAwaitingApproval`/`didEnterDone` still resolves and passes (happy-path correctness regression check). +- **Verification:** `pnpm --filter @fusion/dashboard typecheck`, `pnpm lint`, and the three pure-function test files (`App.boot-gate.test.tsx`, `App.shell-onboarding.test.tsx`, `app-cli-action-wiring.test.tsx`) plus the pure-function assertions in `App.test.tsx` all green. + +### U2. Extract notification SSE hooks (`useMailboxUnread`, `useChatUnreadBadge`, `useStashOrphanCount`) + +- **Goal:** Extract the mailbox, chat, and stash badge state plus their SSE/poll wiring into self-contained hooks. +- **Requirements:** R1, R3, R4, R7. +- **Dependencies:** none (these clusters do not depend on U1's helpers). +- **Files:** + - `packages/dashboard/app/hooks/useMailboxUnread.ts` (new) + - `packages/dashboard/app/hooks/useChatUnreadBadge.ts` (new) + - `packages/dashboard/app/hooks/useStashOrphanCount.ts` (new) + - `packages/dashboard/app/hooks/__tests__/useMailboxUnread.test.ts`, `useChatUnreadBadge.test.ts`, `useStashOrphanCount.test.ts` (new) + - `packages/dashboard/app/App.tsx` (modify — consume the three hooks) +- **Approach:** `useMailboxUnread(projectId)` owns `mailboxUnreadCount`/`mailboxPendingApprovalCount`, the `fetchUnreadCount` refresh, and the `message:*` and `approval:*` count-refresh SSE handlers (it subscribes to `approval:requested`/`approval:updated`/`approval:decided` for count refresh only). `useChatUnreadBadge(projectId, { taskView, quickChatOpen })` owns `chatHasUnreadResponse`, the `chat:message:added`/`chat:room:message:added` handlers, and the clear-on-chat-view effect. `useStashOrphanCount(projectId)` owns the 30-second `/stash-recovery/orphans` poll with its `cancelled` teardown. Per KTD4, the approval-banner *trigger* and the `task:updated` subscriber stay in `useApprovalBanner` (U3); the `task:updated`→awaiting-approval mailbox refresh is preserved via the `onTaskEnteredAwaitingApproval` callback wired in `AppInner`, not by a second `task:updated` handler. `subscribeSse` multiplexes, so multiple subscriptions to `/api/events` share one `EventSource`. +- **Patterns to follow:** `useAgents.ts` (KTD3) — SWR/cache hydration where relevant, `subscribeSse` with teardown, generation-counter stale suppression, object return + `UseXxxOptions`/`UseXxxResult` interfaces. +- **Test scenarios:** + - `message:sent`/`message:received`/`message:read`/`message:deleted` each refresh the unread count; `approval:requested`/`approval:decided`/`approval:updated` refresh the count. + - An assistant `chat:message:added` sets `chatHasUnreadResponse` when `taskView !== "chat"` and quick-chat is closed; a user-role message does not; opening chat/quick-chat clears it. + - Project mismatch (`payload.projectId !== currentProject.id`) is ignored for chat. + - Stash poll sets the count on success and falls back to `0` on error; the interval is cleared on unmount/project change. + - Project switch re-subscribes (dependency on `currentProject?.id`). +- **Verification:** typecheck, lint, `pnpm --filter @fusion/dashboard test:quality:app:foundation-hooks-utils`, and `App.test.tsx` green. + +### U3. Extract `useApprovalBanner` and the GitHub-star trigger + +- **Goal:** Extract the approval-banner dedupe/dismiss state machine and the first-completed-task GitHub-star prompt, preserving the single `task:updated` subscriber and its mailbox-refresh side effect (KTD4). +- **Requirements:** R1, R3, R4, R7, R8. +- **Dependencies:** U1 (storage constants, `parseDateMs`, `loadApprovalBannerDismissals`/`persistApprovalBannerDismissals`, `didEnterAwaitingApproval`, `didEnterDone`). +- **Files:** + - `packages/dashboard/app/hooks/useApprovalBanner.ts` (new) — owns `approvalBannerCandidate`, the `taskStatusByIdRef`/`seenApprovalKeysRef`/`approvalDismissalsRef` refs, the single `task:updated` and `approval:requested` handlers, the dismiss action, the per-`tasks` ref-sync effect, and the `onTaskEnteredAwaitingApproval` callback input. + - `packages/dashboard/app/hooks/useGitHubStarPromptTrigger.ts` (new) — exposes `{ showGitHubStarPrompt, trigger, markShown }`; `trigger` is the function `useApprovalBanner`'s `task:updated` handler calls to flip the prompt on (`!gitHubStarPromptShown && didEnterDone(...)`), mirroring KTD4's `onTaskEnteredAwaitingApproval` callback for the star path. **Do not create or overwrite `useGitHubStarPrompt.ts`** — that file already exists and exports the persisted cross-tab flag `useGitHubStarPromptShown`/`markGitHubStarPromptShown` (a `useSyncExternalStore` flag imported at App.tsx:54 and consumed at App.tsx:724/2455); it stays untouched. + - `packages/dashboard/app/hooks/__tests__/useApprovalBanner.test.ts` (new) + - `packages/dashboard/app/App.tsx` (modify) +- **Approach:** This is the trickiest unit — it carries the stale-closure and effect-identity hazards documented in `docs/solutions/ui-bugs/skill-autocomplete-highlight-reset-on-swr-revalidation.md` and `docs/solutions/logic-errors/queued-chat-message-flush-trusts-stale-isgenerating.md`. Preserve exactly: the ref-sync effect that rebuilds `taskStatusByIdRef`/`seenApprovalKeysRef` from `tasks` on every change; the dedupe-by-key behavior; the dismissal timestamp comparison (`updatedAtMs <= dismissedAt` suppresses); clearing a key when a task leaves `awaiting-approval`; the `didEnterDone` → star-prompt trigger firing at most once; and the `refreshMailboxUnreadCount()` call inside the `awaiting-approval` branch, now expressed as the `onTaskEnteredAwaitingApproval` callback that `AppInner` wires to `useMailboxUnread.refresh`. The ephemeral star trigger lives in `useGitHubStarPromptTrigger`; the *suppression guard* remains the existing `useGitHubStarPromptShown` flag, so the trigger fires only when `!gitHubStarPromptShown && didEnterDone(...)`. +- **Patterns to follow:** `useAgents.ts` ref + generation-counter patterns (KTD3); keep dependency arrays faithful (plain comment, not an eslint-disable, for any intentionally-trimmed array). +- **Test scenarios:** + - `approval:requested` for a new key triggers the banner; a repeat for the same key is a no-op. + - A dismissal persists and suppresses re-trigger until a newer `updatedAtMs` arrives. + - A `task:updated` to a non-`awaiting-approval` status clears the key and its dismissal. + - `didEnterDone` (first transition to `done`) fires the star prompt exactly once; `gitHubStarPromptShown` suppresses it. + - A `task:updated` entering `awaiting-approval` invokes `onTaskEnteredAwaitingApproval` (the mailbox refresh) exactly once. + - Refs rebuild from a fresh `tasks` array without resetting live banner state spuriously. +- **Verification:** typecheck, lint, foundation-hooks-utils test run, and `App.test.tsx`'s approval-banner assertions green. + +### U4. Extract `useBranchTaskFilters` + +- **Goal:** Extract the working/base branch-filter state, its scoped persistence, and the derived options/filtered-tasks memos. +- **Requirements:** R1, R3, R4, R7. +- **Dependencies:** U1 (`WORKING_BRANCH_FILTER_STORAGE_KEY`, `BASE_BRANCH_FILTER_STORAGE_KEY`, `NO_BRANCH_FILTER_VALUE`). +- **Files:** + - `packages/dashboard/app/hooks/useBranchTaskFilters.ts` (new) + - `packages/dashboard/app/hooks/__tests__/useBranchTaskFilters.test.ts` (new) + - `packages/dashboard/app/App.tsx` (modify) +- **Approach:** `useBranchTaskFilters({ boardSourceTasks, currentProjectId })` returns `{ branchFilter, baseBranchFilter, branchOptions, baseBranchOptions, filteredBoardTasks, onBranchFilterChange, onBaseBranchFilterChange }`. It must consume the already-resolved remote-aware `boardSourceTasks` (`isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks`), **not** raw `tasks`, so remote-node board filtering is preserved; `AppInner` passes `boardSourceTasks` in. It reads scoped values on project change via `getScopedItem`/`setScopedItem` (`app/utils/projectStorage.ts`) and recomputes `filteredBoardTasks` with the existing filter logic, including the `NO_BRANCH_FILTER_VALUE` ("no branch") sentinel that excludes tasks which *have* a branch. `branchOptions`/`baseBranchOptions` remain unique-sorted derivations of the task set. +- **Patterns to follow:** `useFavorites.ts` / `useProjectBookmarks.ts` for scoped-localStorage hydration hooks (KTD3). +- **Test scenarios:** + - Initial mount reads the scoped value for the current project; project switch reloads both filters. + - Changing a filter writes the scoped value and recomputes `filteredBoardTasks`. + - `NO_BRANCH_FILTER_VALUE` excludes tasks with a non-empty branch; a concrete filter excludes non-matching branches; base-branch filter composes independently. + - Options are unique and sorted; empty/whitespace branches are dropped. + - Remote-node tasks flow through identically to local tasks (consumes `boardSourceTasks`). +- **Verification:** typecheck, lint, foundation-hooks-utils test run, and `App.test.tsx` board-filter behavior green. + +### U5. Extract health, capacity, dismiss, auth-recovery, and shell-onboarding hooks + +- **Goal:** Extract the parallel banner-dismiss flags, dashboard health, capacity-risk signal, auth-token recovery, and native-shell onboarding. +- **Requirements:** R1, R3, R4, R7. +- **Dependencies:** U1 (`SETUP_WARNING_DISMISSED_KEY`, `CAPACITY_RISK_DISMISSED_KEY`, `requiresNativeShellOnboarding`). `useCapacityRiskBanner` must be called after `useAgents()` and `useAppSettings()` so its inputs are defined (avoid TDZ). +- **Files:** + - `packages/dashboard/app/hooks/useDashboardHealth.ts` (new) — `{ health, refreshing, refreshError, refresh, setHealth }`; mount fetch + `refreshDbCorruptionHealth`; preserves the `taskIdIntegrity` updater shape consumed by the banner. + - `packages/dashboard/app/hooks/useCapacityRiskBanner.ts` (new) — options `{ agentStats, inProgressCount, inReviewCount, capacityRiskBannerEnabled, capacityRiskTodoThreshold, settingsLoaded, currentProjectId }`; returns `{ signal, dismissed, dismiss, hydrated }`; `computeCapacityRisk` over the counts + threshold; mirrors the settings-hydrate guard effect and the re-enable-clears-dismissal effect (App.tsx ~1111) inside the hook. + - `packages/dashboard/app/hooks/useScopedDismissFlag.ts` (new) — options `{ storageKey, currentProjectId }`, returns `{ dismissed, dismiss }`; backed by `getScopedItem`/`setScopedItem` and **owns the project-change re-read effect** (re-run `getScopedItem` on `currentProjectId` change, App.tsx ~964–972) so a dismissal in one project does not leak into another. Powers the setup-warning dismiss (and is reused internally by `useCapacityRiskBanner` for its dismiss). + - `packages/dashboard/app/hooks/useAuthTokenRecovery.ts` (new) — `{ open }`; the `AUTH_TOKEN_RECOVERY_REQUIRED_EVENT` window listener. + - `packages/dashboard/app/hooks/useShellOnboarding.ts` (new) — `{ onboardingComplete, connectionManagerOpen, requiresOnboarding, setConnectionManagerOpen }`; the connection-manager open effect keyed on `openConnectionManagerSignal`/shell state. + - Co-located `__tests__/` for the hooks with non-trivial logic. + - `packages/dashboard/app/App.tsx` (modify) +- **Approach:** These are small, mostly-parallel clusters; group them as one unit to avoid a flurry of micro-commits while keeping each hook single-purpose. `useScopedDismissFlag` must own the project-change scoped re-read so dismissal state resets per project. The capacity-risk settings-hydrate guard (skip on first load or project change) must be preserved to avoid a spurious banner flash, and the re-enable-clears-dismissal behavior must be carried into `useCapacityRiskBanner`. The dashboard-health `setHealth` updater used by the `TaskIdIntegrityBanner` must keep its conditional status-derivation shape. +- **Patterns to follow:** `useUpdateCheck.ts` (KTD3; smallest mount-effect + dismiss template), `useScopedDismissFlag` mirrors the existing scoped-storage dismiss pattern. +- **Test scenarios:** + - Health: mount fetch sets/errs to `null`; `refresh` sets `refreshing`, updates health, clears on success, sets `refreshError` on failure. + - Capacity: signal computes from todo/in-progress/in-review/idle counts + threshold; dismiss persists scoped and hides; hydrate guard skips the first settings load and on project change; re-enabling the banner clears a prior dismissal. + - Scoped-dismiss: dismiss writes scoped `"true"` and flips the flag; switching `currentProjectId` re-reads the scoped value so a dismissal in project A does not persist into project B. + - Auth-recovery: the recovery event sets `open`. + - Shell-onboarding: the connection-manager effect opens on the signal; `requiresOnboarding` follows the existing `requiresNativeShellOnboarding` logic. +- **Verification:** typecheck, lint, foundation-hooks-utils test run, and `App.test.tsx` banner/onboarding assertions green. + +### U6. Extract task-detail, board-scroll, and popped-out-windows hooks + +- **Goal:** Extract the main-panel task-detail state, board scroll snapshot/restore, and popped-out task windows. +- **Requirements:** R1, R3, R4, R7. +- **Dependencies:** none (consume the existing `app/utils/boardScrollSnapshot.ts` helpers). +- **Files:** + - `packages/dashboard/app/hooks/useMainPanelTaskDetail.ts` (new) — `{ task, initialTab, open, close, setTask, setInitialTab }`. + - `packages/dashboard/app/hooks/useBoardScrollRestore.ts` (new) — `{ capture, restore }` + the `requestAnimationFrame` double-frame restore effect keyed on `taskView`. + - `packages/dashboard/app/hooks/usePoppedOutTasks.ts` (new) — `{ tasks, popOut, close }`. + - Co-located `__tests__/` where logic warrants. + - `packages/dashboard/app/App.tsx` (modify) +- **Approach:** These hooks expose primitives; `AppInner` stays the place that composes them with navigation-history pushes (`pushNav`), because the `popstate`↔React-state coordination documented in `docs/solutions/ui-bugs/navigation-history-stale-modal-stack.md` is fragile across the `App.tsx` + `AppModals.tsx` + `useModalManager` seam. Do not move the `pushNav`/`replaceCurrent`/`removeNav` composition out of `AppInner`. Preserve the double-`requestAnimationFrame` restore timing (with the `requestAnimationFrame`/`setTimeout` fallback) exactly. +- **Patterns to follow:** existing `boardScrollSnapshot.ts` util (KTD3); keep the `useRef` snapshot holders inside the hooks. +- **Test scenarios:** + - Detail: `open(task, tab)` sets task + tab; `close` clears; `setTask` merges updates for the matching id only. + - Scroll: capture stores the snapshot; restore fires on board remount via the rAF chain; both frame handles are cancelled on cleanup. + - Popped-out: `popOut` dedupes by task id (re-pop is a no-op); `close` removes by id. +- **Verification:** typecheck, lint, foundation-hooks-utils test run, and `App.test.tsx` task-detail navigation assertions green. + +### U7. Extract `MainContent` and `DashboardBanners` components + +- **Goal:** Extract the two largest render blocks into presentational components under a new `app/components/dashboard/` directory. +- **Requirements:** R1, R3, R4, R5, R6, R8. +- **Dependencies:** U2–U6 (consumes the extracted hooks' outputs as props). +- **Files:** + - `packages/dashboard/app/components/dashboard/MainContent.tsx` (new) — the `renderMainContent()` view-switch (~650 lines), as a pure presentational switch. + - `packages/dashboard/app/components/dashboard/DashboardBanners.tsx` (new) — the conditional banner cluster (~15 banners). + - `packages/dashboard/app/components/dashboard/types.ts` (new) — shared prop-bag interfaces to avoid drift between `App` and the two components. + - `packages/dashboard/app/App.tsx` (modify — render the two components, keep the provider/shell tree inline). +- **Approach:** Land as separate commits within the unit (`MainContent` first, then `DashboardBanners`) since each is independently verifiable. `MainContent`'s prop bag is large (~80–100 fields across ~24 view branches), so keep branch-local consts and render-prop arrows (e.g. `closeSettingsView`, the `renderTaskCard` arrow, `pluginTasks`) co-located *inside* `MainContent` rather than threading them as props — this shrinks the surface to the data/handlers each branch actually needs. Define the remaining prop interfaces in `types.ts` and have `App` pass a composed props bag; `MainContent` is a pure `switch` on `taskView`/`viewMode` returning the existing ``/`` subtrees unchanged. Carry every `FNXC:Navigation`/`FNXC:Settings`/`FNXC:TaskDetail` comment into the component that now owns its JSX. Keep the eager `./components/ChatView.css` import at the `App.tsx` top level (do **not** move it into `MainContent`) — R6. The "Settings renders ahead of the overview branch" ordering and the "board-opened task detail replaces the board" behavior must be preserved verbatim. +- **Patterns to follow:** existing presentational components (`Header.tsx`, `LeftSidebarNav.tsx`) (KTD3) — typed prop interfaces, co-located `.css` only if the component owns styles (these two own none — they compose existing styled children). +- **Test scenarios:** + - `MainContent` renders the correct view for each `taskView` (board, list, settings, chat, mailbox, missions, agents, documents, pull-requests, insights, research, evals, memory, secrets, goalsView, todos, command-center, planning, workflows, import-tasks, automations, devserver, task-detail) and the `viewMode === "overview"` ProjectOverview branch. + - Settings renders ahead of the overview branch when `taskView === "settings"` even with no project selected. + - Backend-connection-error page renders when `showBackendConnectionErrorPage`. + - `DashboardBanners` shows each banner only under its exact condition (test-mode, engine-unavailable, OAuth-relogin, session-needing-input, CLI-binary-install, onboarding resume/post-onboarding, update-available, merge-advance-notice, task-id-integrity anomaly, db-corruption, setup-warning, approval, GitHub-star, capacity-risk). + - `App.test.tsx` DOM assertions (`getByTitle('Settings')`, `data-testid="dashboard-project-shell"`, banner presence) pass. +- **Verification:** typecheck, lint, `pnpm build`, `App.test.tsx` green, and a browser smoke against a freshly built bundle. + +### U8. Verification, line-count graduation, and docs/test sync + +- **Goal:** Confirm end-to-end behavior preservation, graduate `App.tsx` off the ratchet, and confirm the docs invariants are intact. +- **Requirements:** R1, R3, R5, R6, R8, R9. +- **Dependencies:** U1–U7. +- **Files:** + - `scripts/line-count-baseline.json` (modify, via the reviewed `node scripts/check-file-line-count.mjs --update`). + - `packages/dashboard/app/App.tsx` (final). +- **Approach:** Run the full dashboard suite (`pnpm --filter @fusion/dashboard test`), `pnpm lint`, `packages/dashboard` typecheck, and `pnpm build`. Run a browser smoke against a freshly built bundle using the worktree-safe recipe (`FUSION_CLIENT_DIR=$PWD/packages/dashboard/dist/client node packages/cli/bin.mjs dashboard --dev --port 4101 --token cetest123`; never port 4040, never `fn daemon`) to catch the stale-dist regression class. Confirm `App.tsx` is < 2,000 lines and remove it from the ratchet baseline via `--update` after review. Confirm `lazy-loaded-views-docs.test.ts` is green and the AGENTS.md 20-view inventory is unchanged. Audit that `FNXC` comments were carried into the new modules and that the seven pure functions are still re-exported from `App`. +- **Test expectation:** none — this is a verification harness; the assertions are the gate outputs and the line-count/file-inventory invariants. +- **Verification:** line-count audit passes with `App.tsx` removed from the baseline; the full merge gate green; `App.test.tsx` green; browser smoke shows no visual/behavioral regression. + +--- + +## Risks & Dependencies + +- **Stale-closure / effect-identity regressions during hook extraction.** Moving effects out of `AppInner` can subtly change when they fire (fresh array identities in dependency arrays re-triggered the SWR highlight bug; stale client state drove the queued-chat flush bug). Mitigation: preserve every effect's exact dependencies and ref semantics; prefer plain comments over trimmed arrays; `App.test.tsx` plus new `renderHook` tests as the regression net (KTD5). (`docs/solutions/ui-bugs/skill-autocomplete-highlight-reset-on-swr-revalidation.md`, `docs/solutions/logic-errors/queued-chat-message-flush-trusts-stale-isgenerating.md`) +- **`task:updated` cross-concern wiring.** The single `task:updated` handler drives approval, star, and mailbox refresh together (KTD4). Mitigation: keep one subscriber in `useApprovalBanner` and surface the mailbox refresh via the `onTaskEnteredAwaitingApproval` callback rather than duplicating the handler. +- **Navigation-history ↔ modal coordination desync.** The `pushState`/`popstate`/React-state alignment across `App.tsx` + `AppModals.tsx` + `useModalManager` is documented-fragile. Mitigation: keep nav composition in `AppInner` (U6); do not push it into the extracted hooks. (`docs/solutions/ui-bugs/navigation-history-stale-modal-stack.md`) +- **`eslint-disable react-hooks/exhaustive-deps` is a hard CI error** because the rule is unregistered in the flat config, and `pnpm test`/vitest never run ESLint so it only fails the PR Lint job. Mitigation: never use the directive; run `pnpm lint` locally on every unit. (`docs/solutions/build-errors/eslint-exhaustive-deps-rule-not-registered-fails-ci-lint.md`) +- **jsdom tests pass against source while the browser serves a stale dist.** A refactor can pass `App.test.tsx` yet ship a broken bundle. Mitigation: browser smoke against a freshly built bundle in U7/U8 (KTD5). (`docs/solutions/developer-experience/browser-testing-dashboard-from-worktree-safely.md`) +- **`lazy()` static-literal constraint.** Any temptation to abstract the lazy imports behind a helper/variable breaks Vite code-splitting and the inventory test. Mitigation: do not touch the lazy-const block (R5). (`docs/solutions/integration-issues/bundled-plugin-vite-alias-missing.md`) +- **Large prop surface on `MainContent`.** The view-switch closes over ~80–100 fields; threading them as props risks a dropped prop silently changing a view. Mitigation: shared `types.ts` interfaces, co-locating branch-local consts inside `MainContent` (U7), and the `App.test.tsx` per-view render assertions. + +--- + +## Sources / Research + +- `packages/dashboard/app/App.tsx` — the refactor target; structural read of the `AppInner` body (hook ordering, state clusters, the `renderMainContent()` switch at ~1611–2258, the shell tree at ~2272–2706). +- `packages/dashboard/app/hooks/useAgents.ts`, `useTasks.ts`, `useUpdateCheck.ts` — the hook-extraction templates (object return, `UseXxxOptions`/`UseXxxResult`, `readCache`/`writeCache` + `subscribeSse`, generation counters). +- `packages/dashboard/app/hooks/useGitHubStarPrompt.ts` — the existing persisted cross-tab flag hook (`useGitHubStarPromptShown`/`markGitHubStarPromptShown`); must not be clobbered by the new ephemeral trigger (U3). +- `packages/dashboard/app/sse-bus.ts` — confirms `subscribeSse` multiplexes same-URL subscribers onto one shared `EventSource` (KTD4). +- `packages/dashboard/app/components/__tests__/App.test.tsx` — the 4,273-line full-render behavior contract; mocks hooks/components by relative path and renders the real ``. +- `packages/dashboard/vitest.config.ts` — the ~11 project partition (new hook/util tests auto-route to `dashboard-app-quality-foundation-hooks-utils`; new component tests to `dashboard-app-quality-components-a/b`; the backfill project catches any unlisted new test). +- `scripts/check-file-line-count.mjs` + `scripts/line-count-baseline.json` — the 2,000-line cap with `App.tsx` grandfathered at 2,729 (file currently 2,724); `--update` is the reviewed graduation path. +- `packages/dashboard/app/__tests__/lazy-loaded-views-docs.test.ts` — the 20-view (14 App-level + `_`-prefixed embedded) inventory guard over `App.tsx` and `AppModals.tsx`. +- `AGENTS.md` — merge-gate definition, changeset rule (no changeset for behavior-preserving refactors), `FNXC` comment convention, Lazy-Loaded Heavy Views inventory. +- `docs/solutions/` — the six learnings cited in Risks & Dependencies (eslint-disable, browser-testing, navigation-history modal stack, SWR highlight reset, queued-chat stale flush, bundled-plugin Vite alias). +- `STRATEGY.md` / `CONCEPTS.md` — domain vocabulary (Surface, Workflow Runtime, Task) used to keep the plan in project terms. diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 4b03a187dd..6d9ae38755 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -1,44 +1,18 @@ import { useState, useCallback, useEffect, useMemo, useRef, lazy, Suspense } from "react"; import { useTranslation } from "react-i18next"; import { - computeCapacityRisk, - DEFAULT_CAPACITY_RISK_TODO_THRESHOLD, - type ChatRoomMessage, type Task, type TaskDetail, type WorkflowStep, } from "@fusion/core"; -import { isNearDuplicateCanonicalInactive } from "../../core/src/near-duplicate-canonical"; import { Header, useViewportMode } from "./components/Header"; -import { Board } from "./components/Board"; -import { TaskCard } from "./components/TaskCard"; -import { ListView } from "./components/ListView"; import { TaskDetailContent } from "./components/TaskDetailModal"; import { FloatingWindow } from "./components/FloatingWindow"; -import { ProjectOverview } from "./components/ProjectOverview"; -import { MissionManager } from "./components/MissionManager"; -import { MailboxView } from "./components/MailboxView"; -import { PageErrorBoundary } from "./components/ErrorBoundary"; import { AppModals } from "./components/AppModals"; -import { BackendConnectionErrorPage } from "./components/BackendConnectionErrorPage"; import { DashboardLoader, type DashboardLoaderStage } from "./components/DashboardLoader"; import { TopProgressBar } from "./components/TopProgressBar"; import { ExecutorStatusBar } from "./components/ExecutorStatusBar"; -import { SessionNotificationBanner, type CliActionId } from "./components/SessionNotificationBanner"; -import { CliBinaryInstallBanner } from "./components/CliBinaryInstallBanner"; -import { SetupWarningBanner } from "./components/SetupWarningBanner"; -import { CapacityRiskBanner } from "./components/CapacityRiskBanner"; -import { TestModeBanner } from "./components/TestModeBanner"; -import { EngineUnavailableBanner } from "./components/EngineUnavailableBanner"; -import { OAuthReloginBanner } from "./components/OAuthReloginBanner"; -import { TaskIdIntegrityBanner } from "./components/TaskIdIntegrityBanner"; -import { DbCorruptionBanner } from "./components/DbCorruptionBanner"; -import { UpdateAvailableBanner } from "./components/UpdateAvailableBanner"; -import MergeAdvanceNotice from "./components/MergeAdvanceNotice"; -import { ApprovalNotificationBanner } from "./components/ApprovalNotificationBanner"; -import { GitHubStarPrompt } from "./components/GitHubStarPrompt"; -import { OnboardingResumeCard } from "./components/OnboardingResumeCard"; -import { PostOnboardingRecommendations } from "./components/PostOnboardingRecommendations"; +import { type CliActionId } from "./components/SessionNotificationBanner"; import { isOnboardingCompleted, isOnboardingResumable, @@ -71,17 +45,11 @@ import { useAuthOnboarding } from "./hooks/useAuthOnboarding"; import { useMobileKeyboard } from "./hooks/useMobileKeyboard"; import { isIOS, useMobileKeyboardViewportLock, useMobileViewportRestoreReset } from "./hooks/useMobileScrollLock"; import { computeMobileBarKeyboardFlags } from "./utils/mobileBarKeyboardFlags"; -import { - captureBoardScrollSnapshot, - restoreBoardScrollSnapshot, - type BoardScrollSnapshot, -} from "./utils/boardScrollSnapshot"; import { useSetupReadiness } from "./hooks/useSetupReadiness"; import { useUpdateCheck } from "./hooks/useUpdateCheck"; import { useViewState, type TaskView } from "./hooks/useViewState"; import { NavigationHistoryProvider, useNavigationHistory } from "./hooks/useNavigationHistory"; import { usePluginDashboardViews } from "./hooks/usePluginDashboardViews"; -import { PluginDashboardViewHost } from "./plugins/PluginDashboardViewHost"; import { isPluginViewId, isPluginViewRegistered } from "./plugins/pluginViewRegistry"; import { registerBundledPluginViews } from "./plugins/registerBundledPluginViews"; import { useProjectActions } from "./hooks/useProjectActions"; @@ -95,18 +63,50 @@ import { ShellProvider } from "./context/ShellContext"; import { RetryWarningProvider } from "./context/RetryWarningContext"; import { ShellHostProvider, useShellHostContext } from "./context/ShellHostContext"; import { useShellConnection } from "./hooks/useShellConnection"; +import { useStashOrphanCount } from "./hooks/useStashOrphanCount"; +import { useChatUnreadBadge } from "./hooks/useChatUnreadBadge"; +import { useMailboxUnread } from "./hooks/useMailboxUnread"; +import { useApprovalBanner } from "./hooks/useApprovalBanner"; +import { useBranchTaskFilters } from "./hooks/useBranchTaskFilters"; +import { useDashboardHealth } from "./hooks/useDashboardHealth"; +import { useAuthTokenRecovery } from "./hooks/useAuthTokenRecovery"; +import { useScopedDismissFlag } from "./hooks/useScopedDismissFlag"; +import { useCapacityRiskBanner } from "./hooks/useCapacityRiskBanner"; +import { useMainPanelTaskDetail } from "./hooks/useMainPanelTaskDetail"; +import { useBoardScrollRestore } from "./hooks/useBoardScrollRestore"; +import { usePoppedOutTasks } from "./hooks/usePoppedOutTasks"; import { NativeShellOnboardingModal } from "./components/NativeShellOnboardingModal"; import { NativeShellConnectionManager } from "./components/NativeShellConnectionManager"; import { ShellConnectionStatus } from "./components/ShellConnectionStatus"; import { getShellConnectionNativeResult, type ShellConnectionNativeResult } from "./shell-native"; -import type { AiSessionSummary, DashboardHealthResponse, PluginDashboardViewEntry } from "./api"; -import { api, fetchDashboardHealth, fetchUnreadCount, fetchTaskDetail, fetchWorkflowSteps, refreshDashboardHealth, relaunchCliSession } from "./api"; -import { getScopedItem, removeScopedItem, setScopedItem } from "./utils/projectStorage"; +import type { AiSessionSummary, PluginDashboardViewEntry } from "./api"; +import { fetchTaskDetail, fetchWorkflowSteps } from "./api"; +import { + SETUP_WARNING_DISMISSED_KEY, + RETRY_WARNING_RATIO, + buildRemoteDashboardUrl, + requiresNativeShellOnboarding, + shouldShowFirstEverBootLoader, + isSessionNeedingInputForBanner, + getCliActionDisabledReasonForBanner, + executeCliSessionBannerAction, +} from "./utils/appLifecycle"; +// Re-export the unit-tested lifecycle helpers so existing `from "./App"` / +// `from "../../App"` imports keep resolving after the bodies moved to utils. +export { + didEnterAwaitingApproval, + didEnterDone, + requiresNativeShellOnboarding, + shouldShowFirstEverBootLoader, + isSessionNeedingInputForBanner, + getCliActionDisabledReasonForBanner, + executeCliSessionBannerAction, +} from "./utils/appLifecycle"; import { subscribeSse } from "./sse-bus"; -import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } from "./auth"; import { AuthTokenRecoveryDialog } from "./components/AuthTokenRecoveryDialog"; -import { PlanningModeModal } from "./components/PlanningModeModal"; -import { PlanningWorkflowSwitcherSlot } from "./components/PlanningWorkflowSwitcherSlot"; +import { MainContent } from "./components/dashboard/MainContent"; +import { DashboardBanners } from "./components/dashboard/DashboardBanners"; +import type { DashboardBannersProps, MainContentProps } from "./components/dashboard/types"; // ChatView's CSS is imported eagerly so the styles bundle into the main // CSS file. Without this, the lazy ChatView JS chunk loaded its own CSS @@ -180,175 +180,6 @@ function prefetchLazyViews() { registerBundledPluginViews(); -const SETUP_WARNING_DISMISSED_KEY = "kb-setup-warning-dismissed"; -const WORKING_BRANCH_FILTER_STORAGE_KEY = "kb-dashboard-working-branch-filter"; -const BASE_BRANCH_FILTER_STORAGE_KEY = "kb-dashboard-base-branch-filter"; -const NO_BRANCH_FILTER_VALUE = "__fusion:no-branch__"; -const APPROVAL_BANNER_DISMISSED_STORAGE_KEY = "fusion:approval-banner-dismissed"; -const CAPACITY_RISK_DISMISSED_KEY = "kb-capacity-risk-banner-dismissed"; -const RETRY_WARNING_RATIO = 0.8; - -interface ApprovalBannerCandidate { - dedupeKey: string; - updatedAtMs: number; -} - -export function didEnterAwaitingApproval(nextStatus: string | undefined, previousStatus: string | undefined): boolean { - return nextStatus === "awaiting-approval" && previousStatus !== "awaiting-approval"; -} - -export function didEnterDone(nextStatus: string | undefined, previousStatus: string | undefined): boolean { - return nextStatus === "done" && previousStatus !== undefined && previousStatus !== "done"; -} - -function parseDateMs(value: string | undefined): number { - if (!value) return 0; - const parsed = Date.parse(value); - return Number.isFinite(parsed) ? parsed : 0; -} - -function loadApprovalBannerDismissals(): Map { - if (typeof window === "undefined") return new Map(); - try { - const raw = window.localStorage.getItem(APPROVAL_BANNER_DISMISSED_STORAGE_KEY); - if (!raw) return new Map(); - const parsed = JSON.parse(raw) as Record; - const map = new Map(); - for (const [key, value] of Object.entries(parsed)) { - if (typeof value === "number" && Number.isFinite(value)) { - map.set(key, value); - } - } - return map; - } catch { - return new Map(); - } -} - -function persistApprovalBannerDismissals(map: Map): void { - if (typeof window === "undefined") return; - try { - const data: Record = {}; - for (const [key, value] of map) { - data[key] = value; - } - window.localStorage.setItem(APPROVAL_BANNER_DISMISSED_STORAGE_KEY, JSON.stringify(data)); - } catch { - // no-op - } -} - -function buildRemoteDashboardUrl(serverUrl: string, authToken?: string | null): string { - const url = new URL(serverUrl); - if (authToken) { - url.searchParams.set("rt", authToken); - } - return url.toString(); -} - -export function requiresNativeShellOnboarding( - shellState: { host: "web" | "mobile-shell" | "desktop-shell"; desktopMode?: "local" | "remote"; activeProfileId: string | null }, - shellReady: boolean, - shellOnboardingComplete: boolean, -): boolean { - if (!shellReady || shellOnboardingComplete || shellState.host === "web") { - return false; - } - - if (shellState.host === "mobile-shell") { - return !shellState.activeProfileId; - } - - if (shellState.desktopMode === "local") { - return false; - } - - return !shellState.activeProfileId; -} - -export function shouldShowFirstEverBootLoader(projectsLoading: boolean, projectCount: number): boolean { - return projectsLoading && projectCount === 0; -} - -export function isSessionNeedingInputForBanner(session: AiSessionSummary): boolean { - return ( - session.status === "awaiting_input" || - session.status === "error" || - session.status === "waiting_on_input" || - session.status === "needs_attention" - ); -} - -export function getCliActionDisabledReasonForBanner(session: AiSessionSummary, action: CliActionId): string | null { - if ((action === "advance" || action === "relaunch") && !session.cliSessionId) { - return "CLI session id is missing."; - } - return null; -} - -interface CliActionDeps { - currentProjectId?: string; - retryTask: (id: string) => Promise; - moveTask: (id: string, column: "todo") => Promise; - openAuthenticationSettings: () => void; - addToast: (message: string, type: "success" | "error") => void; - apiClient?: typeof api; - relaunchCliSessionClient?: typeof relaunchCliSession; -} - -export async function executeCliSessionBannerAction( - session: AiSessionSummary, - action: CliActionId, - deps: CliActionDeps, -): Promise { - try { - /* - * FNXC:SessionBanner 2026-06-14-19:32: - * CLI banner verbs must either call an existing dashboard route/flow or be disabled by the banner. `advance` confirms the CLI session, `retry` and `cancel` reuse task operations keyed by the session id until summaries expose a distinct task id, and `reauthenticate` opens the existing authentication settings flow. - * - * FNXC:SessionBanner 2026-06-14-20:16: - * `relaunch` is now a supported route-backed action for resume-exhausted CLI sessions; if `cliSessionId` is absent the handler exits without firing a malformed API call, preserving the no-silent-no-op invariant through the banner disabled reason. - */ - if (action === "advance") { - if (!session.cliSessionId) { - throw new Error("CLI session id is required to advance this session."); - } - await (deps.apiClient ?? api)(`/cli-sessions/${encodeURIComponent(session.cliSessionId)}/confirm-advance`, { - method: "POST", - body: JSON.stringify({ decision: "advance", ...(deps.currentProjectId ? { projectId: deps.currentProjectId } : {}) }), - }); - return; - } - - if (action === "relaunch") { - if (!session.cliSessionId) return; - await (deps.relaunchCliSessionClient ?? relaunchCliSession)(session.cliSessionId, deps.currentProjectId); - deps.addToast("CLI session relaunch requested", "success"); - return; - } - - if (action === "retry") { - await deps.retryTask(session.id); - return; - } - - if (action === "cancel") { - await deps.moveTask(session.id, "todo"); - return; - } - - if (action === "reauthenticate") { - deps.openAuthenticationSettings(); - return; - } - - throw new Error("This CLI action is not supported yet."); - } catch (err) { - const message = err instanceof Error ? err.message : "CLI action failed"; - deps.addToast(message, "error"); - } -} - function AppInner() { const { t } = useTranslation("app"); const { toasts, addToast, removeToast } = useToast(); @@ -418,23 +249,6 @@ function AppInner() { // Search query state - must be defined before useTasks const [searchQuery, setSearchQuery] = useState(""); - const [branchFilter, setBranchFilter] = useState(""); - const [baseBranchFilter, setBaseBranchFilter] = useState(""); - - useEffect(() => { - setBranchFilter(getScopedItem(WORKING_BRANCH_FILTER_STORAGE_KEY, currentProject?.id) ?? ""); - setBaseBranchFilter(getScopedItem(BASE_BRANCH_FILTER_STORAGE_KEY, currentProject?.id) ?? ""); - }, [currentProject?.id]); - - const handleBranchFilterChange = useCallback((value: string) => { - setBranchFilter(value); - setScopedItem(WORKING_BRANCH_FILTER_STORAGE_KEY, value, currentProject?.id); - }, [currentProject?.id]); - - const handleBaseBranchFilterChange = useCallback((value: string) => { - setBaseBranchFilter(value); - setScopedItem(BASE_BRANCH_FILTER_STORAGE_KEY, value, currentProject?.id); - }, [currentProject?.id]); // Host capability handed to plugin dashboard views: subscribe to a plugin's // custom SSE events (forwarded by the server as `plugin:custom`, scoped to the @@ -560,55 +374,13 @@ function AppInner() { FNXC:TaskDetail 2026-06-23-00:41: Board task-card secondary actions can deep-link into the inline main-panel task detail. Files-changed must land on the embedded Changes tab instead of reopening the task in the modal path. */ - const [mainPanelDetailTask, setMainPanelDetailTask] = useState(null); - const [mainPanelDetailInitialTab, setMainPanelDetailInitialTab] = useState("chat"); - const boardScrollSnapshotRef = useRef(null); - const pendingBoardScrollRestoreRef = useRef(false); - - const captureCurrentBoardScrollSnapshot = useCallback(() => { - boardScrollSnapshotRef.current = captureBoardScrollSnapshot(); - }, []); - - const restoreCurrentBoardScrollSnapshot = useCallback(() => { - if (restoreBoardScrollSnapshot(boardScrollSnapshotRef.current)) { - pendingBoardScrollRestoreRef.current = false; - } - }, []); - - useEffect(() => { - if (taskView !== "board" || !pendingBoardScrollRestoreRef.current) return; - const scheduleFrame = typeof window.requestAnimationFrame === "function" - ? window.requestAnimationFrame.bind(window) - : ((callback: FrameRequestCallback) => window.setTimeout(() => callback(performance.now()), 0)); - const cancelFrame = typeof window.cancelAnimationFrame === "function" - ? window.cancelAnimationFrame.bind(window) - : window.clearTimeout.bind(window); - let firstFrame = 0; - let secondFrame = 0; - /* - FNXC:BoardNavigation 2026-06-22-20:15: - Board-card task detail replaces the board instead of overlaying it. Preserve horizontal board scroll and per-column vertical scroll before opening detail, then restore after Back to board remounts the board so users return to the same lane/card context. - */ - firstFrame = scheduleFrame(() => { - secondFrame = scheduleFrame(restoreCurrentBoardScrollSnapshot); - }); - return () => { - cancelFrame(firstFrame); - cancelFrame(secondFrame); - }; - }, [restoreCurrentBoardScrollSnapshot, taskView]); - + const { task: mainPanelDetailTask, initialTab: mainPanelDetailInitialTab, setTask: setMainPanelDetailTask, setInitialTab: setMainPanelDetailInitialTab } = useMainPanelTaskDetail(); + const { capture: captureCurrentBoardScrollSnapshot, requestRestore } = useBoardScrollRestore(taskView); /* FNXC:FloatingWindow 2026-06-22-20:45: Open popped-out task-detail windows. Each entry is a task snapshot rendered inside its own movable, resizable, non-blocking FloatingWindow. Several can be open at once and coexist with the right-dock pop-out and terminal (all click-through overlays). Snapshots survive a tasks revalidation; rendering prefers the live row by id and falls back to the snapshot. Pop-out dedupes by task id — re-popping an already-open task is a no-op (its window stays; focus-to-front in FloatingWindow handles re-raising on click). */ - const [poppedOutTasks, setPoppedOutTasks] = useState>([]); - const popOutTaskDetail = useCallback((task: Task | TaskDetail) => { - setPoppedOutTasks((current) => (current.some((entry) => entry.id === task.id) ? current : [...current, task])); - }, []); - const closePoppedOutTask = useCallback((taskId: string) => { - setPoppedOutTasks((current) => current.filter((entry) => entry.id !== taskId)); - }, []); + const { tasks: poppedOutTasks, popOut: popOutTaskDetail, close: closePoppedOutTask } = usePoppedOutTasks(); const previousTaskViewRef = useRef(taskView); @@ -712,222 +484,29 @@ function AppInner() { useMobileViewportRestoreReset(isMobile); // App-level mailbox/chat unread state (used for header/mobile nav badges) - const [mailboxUnreadCount, setMailboxUnreadCount] = useState(0); - const [mailboxPendingApprovalCount, setMailboxPendingApprovalCount] = useState(0); - const [chatHasUnreadResponse, setChatHasUnreadResponse] = useState(false); - const [stashOrphanCount, setStashOrphanCount] = useState(0); - const [approvalBannerCandidate, setApprovalBannerCandidate] = useState(null); + const { mailboxUnreadCount, mailboxPendingApprovalCount, setMailboxUnreadCount, refresh: mailboxRefresh } = useMailboxUnread(currentProject?.id); + const { chatHasUnreadResponse } = useChatUnreadBadge(currentProject?.id, { taskView, quickChatOpen }); + const { stashOrphanCount } = useStashOrphanCount(currentProject?.id); const [showGitHubStarPrompt, setShowGitHubStarPrompt] = useState(false); - const taskStatusByIdRef = useRef>(new Map()); - const seenApprovalKeysRef = useRef>(new Set()); - const approvalDismissalsRef = useRef>(loadApprovalBannerDismissals()); const gitHubStarPromptShown = useGitHubStarPromptShown(); + const handleStarPrompt = useCallback(() => setShowGitHubStarPrompt(true), []); + const { candidate: approvalBannerCandidate, dismissApproval } = useApprovalBanner({ + tasks, + currentProjectId: currentProject?.id, + gitHubStarPromptShown, + onStarPrompt: handleStarPrompt, + onMailboxRefresh: mailboxRefresh, + }); - const refreshMailboxUnreadCount = useCallback(() => { - fetchUnreadCount(currentProject?.id) - .then((data: { unreadCount: number; pendingApprovalCount?: number }) => { - setMailboxUnreadCount(data.unreadCount); - setMailboxPendingApprovalCount(data.pendingApprovalCount ?? 0); - }) - .catch((err) => { - console.warn("[App] Failed to fetch mailbox unread count:", err); - }); - }, [currentProject?.id]); - - useEffect(() => { - const next = new Map(); - const nextSeen = new Set(); - for (const task of tasks) { - next.set(task.id, task.status); - if (task.status === "awaiting-approval") { - nextSeen.add(`task:${task.id}`); - } - } - taskStatusByIdRef.current = next; - seenApprovalKeysRef.current = nextSeen; - }, [tasks]); - - // Initial fetch + live updates from mailbox SSE events. - useEffect(() => { - refreshMailboxUnreadCount(); - - const params = new URLSearchParams(); - if (currentProject?.id) { - params.set("projectId", currentProject.id); - } - const query = params.size > 0 ? `?${params.toString()}` : ""; - - const triggerApprovalBanner = (candidate: ApprovalBannerCandidate) => { - const dismissedAt = approvalDismissalsRef.current.get(candidate.dedupeKey); - if (dismissedAt !== undefined && candidate.updatedAtMs <= dismissedAt) { - return; - } - setApprovalBannerCandidate(candidate); - }; - - return subscribeSse(`/api/events${query}`, { - onReconnect: refreshMailboxUnreadCount, - events: { - "message:sent": refreshMailboxUnreadCount, - "message:received": refreshMailboxUnreadCount, - "message:read": refreshMailboxUnreadCount, - "message:deleted": refreshMailboxUnreadCount, - "approval:requested": (event: MessageEvent) => { - refreshMailboxUnreadCount(); - try { - const payload = JSON.parse(event.data) as { id?: string; taskId?: string; updatedAt?: string; createdAt?: string }; - const dedupeKey = payload.id ? `approval:${payload.id}` : payload.taskId ? `task:${payload.taskId}` : undefined; - if (!dedupeKey || seenApprovalKeysRef.current.has(dedupeKey)) { - return; - } - seenApprovalKeysRef.current.add(dedupeKey); - triggerApprovalBanner({ - dedupeKey, - updatedAtMs: parseDateMs(payload.updatedAt ?? payload.createdAt), - }); - } catch { - // no-op - } - }, - "approval:updated": refreshMailboxUnreadCount, - "approval:decided": refreshMailboxUnreadCount, - "task:updated": (event: MessageEvent) => { - try { - const payload = JSON.parse(event.data) as { id?: string; status?: string; updatedAt?: string }; - if (!payload?.id) { - return; - } - const dedupeKey = `task:${payload.id}`; - const previousStatus = taskStatusByIdRef.current.get(payload.id); - taskStatusByIdRef.current.set(payload.id, payload.status); - if (!gitHubStarPromptShown && didEnterDone(payload.status, previousStatus)) { - setShowGitHubStarPrompt(true); - } - if (payload.status !== "awaiting-approval") { - seenApprovalKeysRef.current.delete(dedupeKey); - approvalDismissalsRef.current.delete(dedupeKey); - persistApprovalBannerDismissals(approvalDismissalsRef.current); - return; - } - if (seenApprovalKeysRef.current.has(dedupeKey)) { - return; - } - if (didEnterAwaitingApproval(payload.status, previousStatus)) { - seenApprovalKeysRef.current.add(dedupeKey); - triggerApprovalBanner({ - dedupeKey, - updatedAtMs: parseDateMs(payload.updatedAt), - }); - refreshMailboxUnreadCount(); - } - } catch { - // no-op - } - }, - }, - }); - }, [currentProject?.id, gitHubStarPromptShown, refreshMailboxUnreadCount]); - - useEffect(() => { - if (taskView === "chat" || quickChatOpen) { - setChatHasUnreadResponse(false); - } - }, [quickChatOpen, taskView]); - - useEffect(() => { - let cancelled = false; - const load = async () => { - try { - const data = await api<{ count: number }>("/stash-recovery/orphans"); - if (!cancelled) setStashOrphanCount(data.count ?? 0); - } catch { - if (!cancelled) setStashOrphanCount(0); - } - }; - void load(); - const timer = window.setInterval(() => void load(), 30000); - return () => { - cancelled = true; - window.clearInterval(timer); - }; - }, [currentProject?.id]); - - useEffect(() => { - const params = new URLSearchParams(); - if (currentProject?.id) { - params.set("projectId", currentProject.id); - } - const query = params.size > 0 ? `?${params.toString()}` : ""; - - return subscribeSse(`/api/events${query}`, { - events: { - "chat:message:added": (event: MessageEvent) => { - try { - const payload = JSON.parse(event.data) as { role?: string; projectId?: string | null }; - if (payload.role !== "assistant") return; - if (taskView === "chat" || quickChatOpen) return; - if (payload.projectId && currentProject?.id && payload.projectId !== currentProject.id) return; - setChatHasUnreadResponse(true); - } catch { - // no-op - } - }, - "chat:room:message:added": (event: MessageEvent) => { - try { - const payload = JSON.parse(event.data) as ChatRoomMessage & { projectId?: string | null }; - if (payload.role === "user") return; - if (taskView === "chat" || quickChatOpen) return; - if (payload.projectId && currentProject?.id && payload.projectId !== currentProject.id) return; - setChatHasUnreadResponse(true); - } catch { - // no-op - } - }, - }, - }); - }, [currentProject?.id, quickChatOpen, taskView]); - - const branchOptions = useMemo(() => { - return Array.from( - new Set( - boardSourceTasks - .map((task) => task.branch?.trim()) - .filter((branch): branch is string => Boolean(branch && branch.length > 0)), - ), - ).sort((a, b) => a.localeCompare(b)); - }, [boardSourceTasks]); - - const baseBranchOptions = useMemo(() => { - return Array.from( - new Set( - boardSourceTasks - .map((task) => task.baseBranch?.trim()) - .filter((baseBranch): baseBranch is string => Boolean(baseBranch && baseBranch.length > 0)), - ), - ).sort((a, b) => a.localeCompare(b)); - }, [boardSourceTasks]); - - const filteredBoardTasks = useMemo(() => { - return boardSourceTasks.filter((task) => { - const taskBranch = task.branch?.trim() ?? ""; - const taskBaseBranch = task.baseBranch?.trim() ?? ""; - if (branchFilter === NO_BRANCH_FILTER_VALUE) { - if (taskBranch.length > 0) { - return false; - } - } else if (branchFilter.length > 0 && taskBranch !== branchFilter) { - return false; - } - if (baseBranchFilter === NO_BRANCH_FILTER_VALUE) { - if (taskBaseBranch.length > 0) { - return false; - } - } else if (baseBranchFilter.length > 0 && taskBaseBranch !== baseBranchFilter) { - return false; - } - return true; - }); - }, [boardSourceTasks, branchFilter, baseBranchFilter]); + const { + branchFilter, + baseBranchFilter, + branchOptions, + baseBranchOptions, + filteredBoardTasks, + onBranchFilterChange: handleBranchFilterChange, + onBaseBranchFilterChange: handleBaseBranchFilterChange, + } = useBranchTaskFilters({ boardSourceTasks, currentProjectId: currentProject?.id }); const [retryingProjects, setRetryingProjects] = useState(false); const [missionResumeSessionId, setMissionResumeSessionId] = useState(undefined); @@ -950,82 +529,15 @@ function AppInner() { setSelectedPrId(undefined); } }, [selectedPrId, taskView]); - const [authTokenRecoveryOpen, setAuthTokenRecoveryOpen] = useState(false); - const [dashboardHealth, setDashboardHealth] = useState(null); - const [dbCorruptionRefreshing, setDbCorruptionRefreshing] = useState(false); - const [dbCorruptionRefreshError, setDbCorruptionRefreshError] = useState(null); - const [setupWarningDismissed, setSetupWarningDismissed] = useState( - () => getScopedItem(SETUP_WARNING_DISMISSED_KEY, currentProject?.id) === "true", - ); - const [capacityRiskDismissed, setCapacityRiskDismissed] = useState( - () => getScopedItem(CAPACITY_RISK_DISMISSED_KEY, currentProject?.id) === "true", - ); - - useEffect(() => { - setSetupWarningDismissed( - getScopedItem(SETUP_WARNING_DISMISSED_KEY, currentProject?.id) === "true", - ); - }, [currentProject?.id]); - - useEffect(() => { - setCapacityRiskDismissed( - getScopedItem(CAPACITY_RISK_DISMISSED_KEY, currentProject?.id) === "true", - ); - }, [currentProject?.id]); - - const refreshDbCorruptionHealth = useCallback(async () => { - setDbCorruptionRefreshing(true); - setDbCorruptionRefreshError(null); - try { - const health = await refreshDashboardHealth(); - setDashboardHealth(health); - } catch (error) { - setDbCorruptionRefreshError(error instanceof Error ? error.message : "Failed to refresh database health."); - } finally { - setDbCorruptionRefreshing(false); - } - }, []); - - useEffect(() => { - let cancelled = false; - - fetchDashboardHealth() - .then((health) => { - if (!cancelled) { - setDashboardHealth(health); - } - }) - .catch(() => { - if (!cancelled) { - setDashboardHealth(null); - } - }); - - return () => { - cancelled = true; - }; - }, []); - - useEffect(() => { - const handleDaemonAuthFailure = () => { - setAuthTokenRecoveryOpen(true); - }; - - window.addEventListener(AUTH_TOKEN_RECOVERY_REQUIRED_EVENT, handleDaemonAuthFailure); - return () => { - window.removeEventListener(AUTH_TOKEN_RECOVERY_REQUIRED_EVENT, handleDaemonAuthFailure); - }; - }, []); - - const handleDismissSetupWarning = useCallback(() => { - setScopedItem(SETUP_WARNING_DISMISSED_KEY, "true", currentProject?.id); - setSetupWarningDismissed(true); - }, [currentProject?.id]); - - const handleDismissCapacityRisk = useCallback(() => { - setScopedItem(CAPACITY_RISK_DISMISSED_KEY, "true", currentProject?.id); - setCapacityRiskDismissed(true); - }, [currentProject?.id]); + const { open: authTokenRecoveryOpen } = useAuthTokenRecovery(); + const { + health: dashboardHealth, + setHealth: setDashboardHealth, + refreshing: dbCorruptionRefreshing, + refreshError: dbCorruptionRefreshError, + refresh: refreshDbCorruptionHealth, + } = useDashboardHealth(); + const { dismissed: setupWarningDismissed, dismiss: handleDismissSetupWarning } = useScopedDismissFlag(SETUP_WARNING_DISMISSED_KEY, currentProject?.id); // Settings state const { @@ -1072,50 +584,15 @@ function AppInner() { () => boardSourceTasks.filter((task) => task.column === "in-review").length, [boardSourceTasks], ); - const capacityRiskSignal = useMemo( - () => - computeCapacityRisk({ - todoCount: agentStats?.todoTaskCount ?? 0, - inProgressCount, - inReviewCount, - idleNonEphemeralAgentCount: agentStats?.idleNonEphemeralCount ?? 0, - threshold: capacityRiskTodoThreshold ?? DEFAULT_CAPACITY_RISK_TODO_THRESHOLD, - }), - [agentStats?.todoTaskCount, agentStats?.idleNonEphemeralCount, inProgressCount, inReviewCount, capacityRiskTodoThreshold], - ); - - const previousCapacityRiskBannerEnabledRef = useRef(capacityRiskBannerEnabled); - const previousCapacityRiskTodoThresholdRef = useRef(capacityRiskTodoThreshold); - const previousCapacityRiskProjectIdRef = useRef(currentProject?.id); - const capacityRiskSettingsHydratedRef = useRef(false); - - useEffect(() => { - if (!settingsLoaded) { - return; - } - - if (!capacityRiskSettingsHydratedRef.current || previousCapacityRiskProjectIdRef.current !== currentProject?.id) { - capacityRiskSettingsHydratedRef.current = true; - previousCapacityRiskProjectIdRef.current = currentProject?.id; - previousCapacityRiskBannerEnabledRef.current = capacityRiskBannerEnabled; - previousCapacityRiskTodoThresholdRef.current = capacityRiskTodoThreshold; - return; - } - - const wasEnabled = previousCapacityRiskBannerEnabledRef.current; - const previousThreshold = previousCapacityRiskTodoThresholdRef.current; - const bannerEnabledChangedToTrue = !wasEnabled && capacityRiskBannerEnabled; - const thresholdChanged = previousThreshold !== capacityRiskTodoThreshold; - - if (bannerEnabledChangedToTrue || thresholdChanged) { - removeScopedItem(CAPACITY_RISK_DISMISSED_KEY, currentProject?.id); - setCapacityRiskDismissed(false); - } - - previousCapacityRiskProjectIdRef.current = currentProject?.id; - previousCapacityRiskBannerEnabledRef.current = capacityRiskBannerEnabled; - previousCapacityRiskTodoThresholdRef.current = capacityRiskTodoThreshold; - }, [settingsLoaded, capacityRiskBannerEnabled, capacityRiskTodoThreshold, currentProject?.id]); + const { signal: capacityRiskSignal, dismissed: capacityRiskDismissed, dismiss: handleDismissCapacityRisk } = useCapacityRiskBanner({ + agentStats, + inProgressCount, + inReviewCount, + capacityRiskBannerEnabled, + capacityRiskTodoThreshold, + settingsLoaded, + currentProjectId: currentProject?.id, + }); /* FNXC:DefaultNavigation 2026-06-23-01:26: Skills graduated from Experimental and should remain visible on upgrades even when stale `experimentalFeatures.skillsView=false` is present. */ const skillsEnabled = true; @@ -1337,7 +814,7 @@ function AppInner() { // FNXC:Navigation 2026-06-22-00:00: Leaving task-detail clears the snapshot so a stale task never lingers if the view is reopened empty. const closeTaskDetailMainPanel = useCallback(() => { - pendingBoardScrollRestoreRef.current = true; + requestRestore(); setMainPanelDetailTask(null); setMainPanelDetailInitialTab("chat"); handleTaskViewChange("board"); @@ -1607,654 +1084,150 @@ function AppInner() { Boolean(projectsError) && !isSuppressedProjectResumeError; - // Render main content based on view mode - const renderMainContent = () => { - if (showBackendConnectionErrorPage) { - return ( - { - void shellApi.openConnectionManager(); - } : undefined} - /> - ); - } - - /* - FNXC:Settings 2026-06-22-00:00: - Settings renders ahead of the overview branch so the header gear opens the embedded Settings view even when no project is selected (viewMode === "overview"), matching the prior modal which opened regardless of view mode. - */ - if (taskView === "settings") { - const closeSettingsView = () => { - modalManager.closeSettings(); - handleChangeTaskView("board"); - }; - return ( - - - <_SettingsView - onClose={closeSettingsView} - addToast={addToast} - initialSection={modalManager.settingsInitialSection} - projectId={currentProject?.id} - themeMode={themeMode} - colorTheme={colorTheme} - onThemeModeChange={setThemeMode} - onColorThemeChange={setColorTheme} - dashboardFontScalePct={dashboardFontScalePct} - shadcnCustomColors={shadcnCustomColors} - resolvedThemeMode={resolvedThemeMode} - onDashboardFontScaleChange={setDashboardFontScalePct} - onShadcnCustomColorsChange={setShadcnCustomColors} - onQuickChatButtonModeChange={setQuickChatButtonModeImmediate} - onReopenOnboarding={reopenOnboardingWithNav} - onOpenApprovals={() => handleChangeTaskView("mailbox")} - onOpenWorkflowSettings={() => { - closeSettingsView(); - modalManager.openWorkflowEditor("settings"); - }} - /> - - - ); - } - - if (viewMode === "overview") { - return ( - - - - ); - } - - const resolvedPluginTaskView = taskView === "graph" ? graphPluginTaskView : (isPluginViewId(taskView) ? taskView : null); - - // Project view - if (resolvedPluginTaskView) { - const pluginTasks = isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks; - return ( - - openDetailTask(task, initialTab), - openFile: openFileInBrowser, - renderTaskCard: (task: Task | TaskDetail) => ( - openDetailTask(value)} - addToast={addToast} - workflowStepNameLookup={workflowStepNameLookup} - disableDrag={true} - prAuthAvailable={prAuthAvailable} - autoMergeEnabled={autoMerge} - nearDuplicateCanonicalInactive={typeof task.sourceMetadata?.nearDuplicateOf === "string" - ? isNearDuplicateCanonicalInactive(pluginTasks.find((candidate) => candidate.id === task.sourceMetadata?.nearDuplicateOf)) - : undefined} - /> - ), - addToast, - }} - /> - - ); - } - - if (taskView === "skills") { - if (!settingsLoaded || !skillsEnabled) { - return null; - } - return ( - - - handleChangeTaskView("board")} - /> - - - ); - } - - if (taskView === "chat") { - return ( - - - setQuickChatOpen(true)} - /> - - - ); - } - - if (taskView === "mailbox") { - return ( - - - - ); - } - - - if (taskView === "missions") { - return ( - - { - setMissionTargetId(undefined); - setMissionResumeSessionId(undefined); - setMilestoneSliceResumeSessionId(undefined); - handleChangeTaskView("board"); - }} - addToast={addToast} - projectId={currentProject?.id} - onSelectTask={(taskId) => { - const task = tasks.find((t) => t.id === taskId); - if (task) openDetailTask(task as TaskDetail); - }} - availableTasks={tasks.map((t) => ({ id: t.id, title: t.title }))} - resumeSessionId={missionResumeSessionId} - targetMissionId={missionTargetId} - milestoneSliceResumeSessionId={milestoneSliceResumeSessionId} - onMilestoneSliceResumeFetchError={() => setMilestoneSliceResumeSessionId(undefined)} - onNavigateToGoal={(goalId) => { - setGoalAnchorId(goalId); - handleChangeTaskView("goalsView"); - }} - /> - - ); - } - - if (taskView === "agents" && agentsEnabled) { - return ( - - - - - - ); - } - - if (taskView === "documents") { - return ( - - - - - - ); - } - - if (taskView === "pull-requests") { - return ( - - - - - - ); - } - - if (taskView === "insights") { - if (!settingsLoaded || !insightsEnabled) { - return null; - } - return ( - - - handleChangeTaskView("board")} - onCreateTask={handleInsightTaskCreate} - /> - - - ); - } - - if (taskView === "research") { - if (!settingsLoaded || !researchEnabled) { - return null; - } - return ( - - - openSettingsWithNav(section as SectionId)} - readinessVersion={researchReadinessVersion} - /> - - - ); - } - - if (taskView === "evals") { - if (!settingsLoaded || !evalsEnabled) { - return null; - } - return ( - - - openSettingsWithNav(section as SectionId)} - onOpenTaskDetail={(taskId) => { - void fetchTaskDetail(taskId, currentProject?.id) - .then((task) => openDetailTask(task as TaskDetail)) - .catch((error) => addToast(error instanceof Error ? error.message : "Failed to open task detail", "error")); - }} - /> - - - ); - } - - if (taskView === "memory") { - if (!settingsLoaded || !memoryEnabled) { - return null; - } - return ( - - - - - - ); - } - - if (taskView === "secrets") { - return ( - - - - - - ); - } - - if (taskView === "goalsView") { - if (!settingsLoaded || !goalsEnabled) { - return null; - } - return ( - - - - - - ); - } - if (taskView === "todos") { - // FNXC:Todos 2026-06-21-09:21: Todos render as a docked right-content view, not a modal overlay, per FN-6829 so all dashboard navigation surfaces share the same taskView routing model. - if (!settingsLoaded || !todosEnabled) return null; - return ( - - - ingestCreatedTasks([task])} /> - - - ); - } - if (taskView === "command-center") { - return ( - - - - - - ); - } - - if (taskView === "planning") { - /* - FNXC:Navigation 2026-06-21-00:00: - FN-6886 renders Planning Mode as a top-level main-content destination. Sidebar navigation opens an empty planning view, while Board, Todos, inline create, and resume entry points carry their initial plan/workflow/session state through modalManager. - */ - const closePlanningView = () => { - modalManager.closePlanning(); - handleChangeTaskView("board"); - }; - return ( - - {/* - FNXC:Navigation 2026-06-22-00:00: - Planning shows the same board WorkflowSwitcher in the same Header workflow slot as Board/List (portaled by PlanningWorkflowSwitcherSlot), so workflow selection is reachable from the left-sidebar Planning destination. - */} - - - - ); - } - - /* - FNXC:Navigation 2026-06-22-00:00: - Workflows, Import Tasks (GitHub import), and Automations are left-sidebar destinations that render embedded in the main content area instead of as modal overlays. Closing returns to the board. The same components still mount as modals in AppModals for the mobile overflow path. - */ - if (taskView === "workflows") { - return ( - - - <_WorkflowEditorView - isOpen={true} - onClose={() => handleChangeTaskView("board")} - addToast={addToast} - projectId={currentProject?.id} - presentation="embedded" - /> - - - ); - } - - if (taskView === "import-tasks") { - return ( - - - <_ImportTasksView - isOpen={true} - onClose={() => handleChangeTaskView("board")} - onImport={handleGitHubImport} - tasks={tasks} - projectId={currentProject?.id} - presentation="embedded" - /> - - - ); - } - - if (taskView === "automations") { - return ( - - - <_AutomationsView - onClose={() => handleChangeTaskView("board")} - addToast={addToast} - projectId={currentProject?.id} - presentation="embedded" - /> - - - ); - } - - if (taskView === "devserver" || taskView === "dev-server") { - if (!settingsLoaded || !devServerEnabled) { - return null; - } - return ( - - - - - - ); - } - - /* - FNXC:Navigation 2026-06-22-00:00: - Board-opened task detail renders as a full main-content view that replaces the board. A Back-to-board button sits above an embedded TaskDetailContent (same props ListView passes to its split-detail pane). The live task is preferred from `tasks` by id so the detail updates on revalidation; the stored snapshot is the fallback. If neither resolves (snapshot cleared), fall back to the board so the panel is never blank. - */ - if (taskView === "task-detail") { - const liveDetailTask = mainPanelDetailTask - ? (tasks.find((candidate) => candidate.id === mainPanelDetailTask.id) ?? mainPanelDetailTask) - : null; - if (!liveDetailTask) { - return ( - - - - ); - } - return ( - -
-
- { popOutTaskDetail(task); closeTaskDetailMainPanel(); }} - onOpenDetail={(value) => { - setMainPanelDetailTask(value); - setMainPanelDetailInitialTab("chat"); - }} - onMoveTask={moveTask} - onDeleteTask={deleteTask} - onMergeTask={mergeTask} - onRetryTask={retryTask} - onResetTask={resetTask} - onDuplicateTask={duplicateTask} - /* - FNXC:Navigation 2026-06-22-09:00: - The full-panel task-detail must dismiss back to the board when a destructive/terminal action (delete/merge/archive/retry/reset/duplicate) fires, mirroring the modal path. Without onRequestClose the panel kept showing a ghost of the just-acted-on task. - */ - onRequestClose={closeTaskDetailMainPanel} - onTaskUpdated={(updatedTask) => { - setMainPanelDetailTask((previous) => { - if (!previous || previous.id !== updatedTask.id) return previous; - return { ...previous, ...updatedTask }; - }); - }} - addToast={addToast} - prAuthAvailable={prAuthAvailable} - autoMergeEnabled={autoMerge} - /> -
-
-
- ); - } - - if (taskView === "board") { - return ( - - {capacityRiskBannerEnabled && !capacityRiskDismissed ? ( - - ) : null} - - - ); - } - - // List view - return ( - - 0 ? remoteData.tasks : tasks} - projectId={currentProject?.id} - onMoveTask={moveTask} - onRetryTask={retryTask} - onDeleteTask={deleteTask} - onPauseTask={pauseTask} - onUnpauseTask={unpauseTask} - onArchiveTask={archiveTask} - onMergeTask={mergeTask} - onResetTask={resetTask} - onDuplicateTask={duplicateTask} - onOpenDetail={(task, options) => openDetailTask(task, undefined, options)} - onPopOut={popOutTaskDetail} - addToast={addToast} - globalPaused={globalPaused} - onNewTask={openNewTaskWithNav} - onQuickCreate={handleBoardQuickCreate} - onPlanningMode={openPlanningWithInitialPlanWithNav} - onSubtaskBreakdown={subtaskBreakdownEnabled ? openSubtaskBreakdownWithNav : undefined} - availableModels={availableModels} - favoriteProviders={favoriteProviders} - favoriteModels={favoriteModels} - onToggleFavorite={handleToggleFavorite} - onToggleModelFavorite={handleToggleModelFavorite} - taskStuckTimeoutMs={taskStuckTimeoutMs} - searchQuery={searchQuery} - lastFetchTimeMs={lastFetchTimeMs} - prAuthAvailable={prAuthAvailable} - autoMerge={autoMerge} - onOpenWorkflowEditor={openWorkflowEditorWithNav} - onCreateWorkflow={openCreateWorkflowWithNav} - workflowColumnsEnabled - settingsLoaded={settingsLoaded} - workflowControlsInHeader={sidebarActive || isMobile} - /> - - ); + // Props for the extracted switch (see components/dashboard/MainContent.tsx). + // Every value is passed by its App name; the switch renders the same subtrees as before. + const mainContentProps: MainContentProps = { + showBackendConnectionErrorPage, + projectsError, + t, + retryingProjects, + handleRetryProjects, + shellApi, + taskView, + modalManager, + handleChangeTaskView, + addToast, + currentProject, + themeMode, + setThemeMode, + colorTheme, + setColorTheme, + dashboardFontScalePct, + setDashboardFontScalePct, + shadcnCustomColors, + setShadcnCustomColors, + resolvedThemeMode, + setQuickChatButtonModeImmediate, + reopenOnboardingWithNav, + viewMode, + projects, + projectsLoading, + handleSelectProject, + handleAddProject, + handlePauseProject, + handleResumeProject, + handleRemoveProject, + nodes, + graphPluginTaskView, + isRemote, + remoteData, + tasks, + workflowSteps, + subscribePluginEvents, + openDetailTask, + openFileInBrowser, + workflowStepNameLookup, + prAuthAvailable, + autoMerge, + settingsLoaded, + skillsEnabled, + experimentalFeatures, + setQuickChatOpen, + setMailboxUnreadCount, + setMissionTargetId, + setMissionResumeSessionId, + setMilestoneSliceResumeSessionId, + missionResumeSessionId, + missionTargetId, + milestoneSliceResumeSessionId, + setGoalAnchorId, + goalAnchorId, + agentsEnabled, + agentOnboardingEnabled, + handleOpenTaskLogs, + popOutTaskDetail, + selectedPrId, + insightsEnabled, + handleInsightTaskCreate, + researchEnabled, + openSettingsWithNav, + researchReadinessVersion, + evalsEnabled, + memoryEnabled, + goalsEnabled, + handleOpenMission, + todosEnabled, + openPlanningWithInitialPlanWithNav, + ingestCreatedTasks, + nodesEnabled, + openWorkflowEditorWithNav, + handlePlanningTaskCreated, + handlePlanningTasksCreated, + handleGitHubImport, + devServerEnabled, + mainPanelDetailTask, + filteredBoardTasks, + maxConcurrent, + moveTask, + pauseTask, + openTaskDetailInMainPanel, + openGroupModalWithNav, + handleBoardQuickCreate, + openNewTaskWithNav, + subtaskBreakdownEnabled, + openSubtaskBreakdownWithNav, + toggleAutoMerge, + globalPaused, + updateTask, + retryTask, + archiveTask, + unarchiveTask, + deleteTask, + archiveAllDone, + loadArchivedTasks, + searchQuery, + availableModels, + favoriteProviders, + favoriteModels, + handleOpenDetailWithTab, + handleToggleFavorite, + handleToggleModelFavorite, + taskStuckTimeoutMs, + staleHighFanoutBlockerAgeThresholdMs, + lastFetchTimeMs, + openCreateWorkflowWithNav, + sidebarActive, + isMobile, + mainPanelDetailInitialTab, + closeTaskDetailMainPanel, + setMainPanelDetailTask, + setMainPanelDetailInitialTab, + mergeTask, + resetTask, + duplicateTask, + unpauseTask, + capacityRiskBannerEnabled, + capacityRiskDismissed, + capacityRiskSignal, + handleDismissCapacityRisk, + AgentsView, + ChatView, + CommandCenter, + DevServerView, + DocumentsView, + EvalsView, + GoalsView, + InsightsView, + MemoryView, + PullRequestView, + ResearchView, + SecretsView, + SkillsView, + TodoView, + _AutomationsView, + _ImportTasksView, + _SettingsView, + _WorkflowEditorView, }; const showOnboardingResumeCard = !modalManager.modelOnboardingOpen && isOnboardingResumable(); @@ -2267,6 +1240,50 @@ function AppInner() { // Top progress bar reflects any in-flight revalidation: projects, current-project, or tasks. // Add new sources here, not inside TopProgressBar. const isRevalidating = projectsLoading || currentProjectLoading || isStale; + + // Props for the extracted cluster (see components/dashboard/DashboardBanners.tsx). + // Every value is passed by its App name; the cluster renders the same banners as before. + const dashboardBannersProps: DashboardBannersProps = { + viewMode, + currentProject, + isTestMode, + dashboardHealth, + setDashboardHealth, + taskView, + modalManager, + sessionBannersHidden, + sessionsNeedingInput, + handleOpenBackgroundSession, + handleDismissNeedingInputSession, + handleDismissAllNeedingInputSessions, + handleCliAction, + getCliActionDisabledReasonForBanner, + openSettingsWithNav, + showOnboardingResumeCard, + showPostOnboardingRecommendations, + updateAvailable, + latestVersion, + currentVersion, + updateBannerDismissed, + dismissUpdateBanner, + refreshDbCorruptionHealth, + dbCorruptionRefreshing, + dbCorruptionRefreshError, + setupReadinessLoading, + hasWarnings, + setupWarningDismissed, + handleDismissSetupWarning, + hasAiProvider, + hasGithub, + approvalBannerCandidate, + dismissApproval, + mailboxPendingApprovalCount, + handleTaskViewChange, + showGitHubStarPrompt, + gitHubStarPromptShown, + markGitHubStarPromptShown, + setShowGitHubStarPrompt, + }; const rightDock = useRightDockController({ active: rightDockActive, projectId: currentProject?.id, addToast, settingsLoaded, researchReadinessVersion, goalAnchorId, tasks: isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks, workflowSteps, subscribePluginEvents, openDetailTask, openFileInBrowser, openSettings: (section?: string) => openSettingsWithNav(section as SectionId), onOpenUsage: openUsageWithNav, onOpenActivityLog: openActivityLogWithNav, onOpenGitHubImport: openGitHubImportWithNav, onOpenGitManager: openGitManagerWithNav, onOpenSchedules: openSchedulesWithNav, onSendSelectionToTask: modalManager.openNewTaskWithDescription, onCreateTaskFromInsight: handleInsightTaskCreate, onNavigateToMission: handleOpenMission, onTaskCreated: (task: Task) => ingestCreatedTasks([task]), workflowStepNameLookup, prAuthAvailable, autoMerge, visibilityOptions: { experimentalFeatures: { insights: insightsEnabled, memoryView: memoryEnabled, devServerView: devServerEnabled, researchView: researchEnabled, evalsView: evalsEnabled, goalsView: goalsEnabled }, showSkillsTab: skillsEnabled, todosEnabled, pluginDashboardViews }, footerVisible: executorFooterVisible }); return ( @@ -2352,114 +1369,7 @@ function AppInner() { ) : undefined } /> - {viewMode === "project" && currentProject && ( - <> - - - openSettingsWithNav("authentication" as SectionId)} - /> - - )} - {viewMode === "project" && currentProject && taskView !== "missions" && !modalManager.isPlanningOpen && !sessionBannersHidden && ( - - )} - {viewMode === "project" && currentProject && ( - openSettingsWithNav("general" as SectionId)} - /> - )} - {viewMode === "project" && currentProject && showOnboardingResumeCard && ( - - )} - {viewMode === "project" && currentProject && showPostOnboardingRecommendations && ( - openSettingsWithNav(section as SectionId)} - /> - )} - {viewMode === "project" && currentProject && updateAvailable && latestVersion && currentVersion && !updateBannerDismissed && ( - - )} - {viewMode === "project" && currentProject && ( - - )} - {viewMode === "project" && currentProject && dashboardHealth?.taskIdIntegrity?.status === "anomaly" && dashboardHealth.taskIdIntegrity.recommendedAction && ( - { - setDashboardHealth((current) => { - if (!current) { - return null; - } - return { - ...current, - status: - report.status === "anomaly" - || !current.database.healthy - || current.database.corruptionDetected - ? "degraded" - : "ok", - taskIdIntegrity: { - ...report, - recommendedAction, - }, - }; - }); - }} - /> - )} - {viewMode === "project" && currentProject && dashboardHealth?.database?.corruptionDetected === true && ( - - )} - {viewMode === "project" && currentProject && !setupReadinessLoading && hasWarnings && !setupWarningDismissed && ( - - )} - {viewMode === "project" && currentProject && approvalBannerCandidate && ( - handleTaskViewChange("mailbox")} - onDismiss={() => { - approvalDismissalsRef.current.set( - approvalBannerCandidate.dedupeKey, - Math.max(Date.now(), approvalBannerCandidate.updatedAtMs), - ); - persistApprovalBannerDismissals(approvalDismissalsRef.current); - setApprovalBannerCandidate(null); - }} - /> - )} - {/* FNXC:Onboarding 2026-06-22-03:11: The one-time GitHub star prompt stays tied to first completed task, but first-run setup must finish the optional persistent-agent create/skip step before any star ask can surface. Do not add a second setup-specific star prompt. */} - {viewMode === "project" && currentProject && showGitHubStarPrompt && !gitHubStarPromptShown && !modalManager.setupWizardOpen && ( - { - markGitHubStarPromptShown(); - setShowGitHubStarPrompt(false); - }} - /> - )} +
{sidebarActive && ( - {renderMainContent()} +
{rightDock.dock} diff --git a/packages/dashboard/app/components/dashboard/DashboardBanners.tsx b/packages/dashboard/app/components/dashboard/DashboardBanners.tsx new file mode 100644 index 0000000000..fa51b69bcc --- /dev/null +++ b/packages/dashboard/app/components/dashboard/DashboardBanners.tsx @@ -0,0 +1,168 @@ +/* +FNXC:DashboardBanners 2026-06-24-00:00: +DashboardBanners is the conditional banner cluster rendered above the dashboard-project-shell, extracted verbatim from AppInner's main return JSX. It is a pure render of the same gated banners (every condition, prop, FNXC comment, and the TaskIdIntegrityBanner setDashboardHealth updater preserved byte-for-byte); the banner components are imported directly from their siblings. +*/ +import type { DashboardBannersProps } from "./types"; +import type { SectionId } from "../SettingsModal"; +import { TestModeBanner } from "../TestModeBanner"; +import { EngineUnavailableBanner } from "../EngineUnavailableBanner"; +import { OAuthReloginBanner } from "../OAuthReloginBanner"; +import { SessionNotificationBanner } from "../SessionNotificationBanner"; +import { CliBinaryInstallBanner } from "../CliBinaryInstallBanner"; +import { OnboardingResumeCard } from "../OnboardingResumeCard"; +import { PostOnboardingRecommendations } from "../PostOnboardingRecommendations"; +import { UpdateAvailableBanner } from "../UpdateAvailableBanner"; +import MergeAdvanceNotice from "../MergeAdvanceNotice"; +import { TaskIdIntegrityBanner } from "../TaskIdIntegrityBanner"; +import { DbCorruptionBanner } from "../DbCorruptionBanner"; +import { SetupWarningBanner } from "../SetupWarningBanner"; +import { ApprovalNotificationBanner } from "../ApprovalNotificationBanner"; +import { GitHubStarPrompt } from "../GitHubStarPrompt"; + +export function DashboardBanners({ + viewMode, + currentProject, + isTestMode, + dashboardHealth, + setDashboardHealth, + taskView, + modalManager, + sessionBannersHidden, + sessionsNeedingInput, + handleOpenBackgroundSession, + handleDismissNeedingInputSession, + handleDismissAllNeedingInputSessions, + handleCliAction, + getCliActionDisabledReasonForBanner, + openSettingsWithNav, + showOnboardingResumeCard, + showPostOnboardingRecommendations, + updateAvailable, + latestVersion, + currentVersion, + updateBannerDismissed, + dismissUpdateBanner, + refreshDbCorruptionHealth, + dbCorruptionRefreshing, + dbCorruptionRefreshError, + setupReadinessLoading, + hasWarnings, + setupWarningDismissed, + handleDismissSetupWarning, + hasAiProvider, + hasGithub, + approvalBannerCandidate, + dismissApproval, + mailboxPendingApprovalCount, + handleTaskViewChange, + showGitHubStarPrompt, + gitHubStarPromptShown, + markGitHubStarPromptShown, + setShowGitHubStarPrompt, +}: DashboardBannersProps) { + return ( + <> + {viewMode === "project" && currentProject && ( + <> + + + openSettingsWithNav("authentication" as SectionId)} + /> + + )} + {viewMode === "project" && currentProject && taskView !== "missions" && !modalManager.isPlanningOpen && !sessionBannersHidden && ( + + )} + {viewMode === "project" && currentProject && ( + openSettingsWithNav("general" as SectionId)} + /> + )} + {viewMode === "project" && currentProject && showOnboardingResumeCard && ( + + )} + {viewMode === "project" && currentProject && showPostOnboardingRecommendations && ( + openSettingsWithNav(section as SectionId)} + /> + )} + {viewMode === "project" && currentProject && updateAvailable && latestVersion && currentVersion && !updateBannerDismissed && ( + + )} + {viewMode === "project" && currentProject && ( + + )} + {viewMode === "project" && currentProject && dashboardHealth?.taskIdIntegrity?.status === "anomaly" && dashboardHealth.taskIdIntegrity.recommendedAction && ( + { + setDashboardHealth((current) => { + if (!current) { + return null; + } + return { + ...current, + status: + report.status === "anomaly" + || !current.database.healthy + || current.database.corruptionDetected + ? "degraded" + : "ok", + taskIdIntegrity: { + ...report, + recommendedAction, + }, + }; + }); + }} + /> + )} + {viewMode === "project" && currentProject && dashboardHealth?.database?.corruptionDetected === true && ( + + )} + {viewMode === "project" && currentProject && !setupReadinessLoading && hasWarnings && !setupWarningDismissed && ( + + )} + {viewMode === "project" && currentProject && approvalBannerCandidate && ( + handleTaskViewChange("mailbox")} + onDismiss={() => dismissApproval(approvalBannerCandidate)} + /> + )} + {/* FNXC:Onboarding 2026-06-22-03:11: The one-time GitHub star prompt stays tied to first completed task, but first-run setup must finish the optional persistent-agent create/skip step before any star ask can surface. Do not add a second setup-specific star prompt. */} + {viewMode === "project" && currentProject && showGitHubStarPrompt && !gitHubStarPromptShown && !modalManager.setupWizardOpen && ( + { + markGitHubStarPromptShown(); + setShowGitHubStarPrompt(false); + }} + /> + )} + + ); +} diff --git a/packages/dashboard/app/components/dashboard/MainContent.tsx b/packages/dashboard/app/components/dashboard/MainContent.tsx new file mode 100644 index 0000000000..cea7bbb71b --- /dev/null +++ b/packages/dashboard/app/components/dashboard/MainContent.tsx @@ -0,0 +1,816 @@ +/* +FNXC:MainContent 2026-06-24-00:00: +MainContent is the presentational switch for the dashboard's main content area, extracted verbatim from AppInner's renderMainContent(). It is a pure switch on taskView/viewMode returning the existing / subtrees unchanged. The lazy view chunks (and their leading-underscore inventory convention) stay declared in App.tsx per the docs guard and are threaded in as props; the eager ChatView.css import remains in App.tsx so the styles bundle into the main CSS file. +*/ +import { Suspense } from "react"; +import type { Task, TaskDetail } from "@fusion/core"; +import { Board } from "../Board"; +import { TaskCard } from "../TaskCard"; +import { ListView } from "../ListView"; +import { TaskDetailContent } from "../TaskDetailModal"; +import { ProjectOverview } from "../ProjectOverview"; +import { MissionManager } from "../MissionManager"; +import { MailboxView } from "../MailboxView"; +import { PageErrorBoundary } from "../ErrorBoundary"; +import { BackendConnectionErrorPage } from "../BackendConnectionErrorPage"; +import { CapacityRiskBanner } from "../CapacityRiskBanner"; +import { PlanningModeModal } from "../PlanningModeModal"; +import { PlanningWorkflowSwitcherSlot } from "../PlanningWorkflowSwitcherSlot"; +import { PluginDashboardViewHost } from "../../plugins/PluginDashboardViewHost"; +import { isPluginViewId } from "../../plugins/pluginViewRegistry"; +import { isNearDuplicateCanonicalInactive } from "../../../../core/src/near-duplicate-canonical"; +import { fetchTaskDetail } from "../../api"; +import type { DetailTaskTab } from "../../hooks/useModalManager"; +import type { SectionId } from "../SettingsModal"; +import type { MainContentProps } from "./types"; + +export function MainContent({ + showBackendConnectionErrorPage, + projectsError, + t, + retryingProjects, + handleRetryProjects, + shellApi, + taskView, + modalManager, + handleChangeTaskView, + addToast, + currentProject, + themeMode, + setThemeMode, + colorTheme, + setColorTheme, + dashboardFontScalePct, + setDashboardFontScalePct, + shadcnCustomColors, + setShadcnCustomColors, + resolvedThemeMode, + setQuickChatButtonModeImmediate, + reopenOnboardingWithNav, + viewMode, + projects, + projectsLoading, + handleSelectProject, + handleAddProject, + handlePauseProject, + handleResumeProject, + handleRemoveProject, + nodes, + graphPluginTaskView, + isRemote, + remoteData, + tasks, + workflowSteps, + subscribePluginEvents, + openDetailTask, + openFileInBrowser, + workflowStepNameLookup, + prAuthAvailable, + autoMerge, + settingsLoaded, + skillsEnabled, + experimentalFeatures, + setQuickChatOpen, + setMailboxUnreadCount, + setMissionTargetId, + setMissionResumeSessionId, + setMilestoneSliceResumeSessionId, + missionResumeSessionId, + missionTargetId, + milestoneSliceResumeSessionId, + setGoalAnchorId, + goalAnchorId, + agentsEnabled, + agentOnboardingEnabled, + handleOpenTaskLogs, + popOutTaskDetail, + selectedPrId, + insightsEnabled, + handleInsightTaskCreate, + researchEnabled, + openSettingsWithNav, + researchReadinessVersion, + evalsEnabled, + memoryEnabled, + goalsEnabled, + handleOpenMission, + todosEnabled, + openPlanningWithInitialPlanWithNav, + ingestCreatedTasks, + nodesEnabled, + openWorkflowEditorWithNav, + handlePlanningTaskCreated, + handlePlanningTasksCreated, + handleGitHubImport, + devServerEnabled, + mainPanelDetailTask, + filteredBoardTasks, + maxConcurrent, + moveTask, + pauseTask, + openTaskDetailInMainPanel, + openGroupModalWithNav, + handleBoardQuickCreate, + openNewTaskWithNav, + subtaskBreakdownEnabled, + openSubtaskBreakdownWithNav, + toggleAutoMerge, + globalPaused, + updateTask, + retryTask, + archiveTask, + unarchiveTask, + deleteTask, + archiveAllDone, + loadArchivedTasks, + searchQuery, + availableModels, + favoriteProviders, + favoriteModels, + handleOpenDetailWithTab, + handleToggleFavorite, + handleToggleModelFavorite, + taskStuckTimeoutMs, + staleHighFanoutBlockerAgeThresholdMs, + lastFetchTimeMs, + openCreateWorkflowWithNav, + sidebarActive, + isMobile, + mainPanelDetailInitialTab, + closeTaskDetailMainPanel, + setMainPanelDetailTask, + setMainPanelDetailInitialTab, + mergeTask, + resetTask, + duplicateTask, + unpauseTask, + capacityRiskBannerEnabled, + capacityRiskDismissed, + capacityRiskSignal, + handleDismissCapacityRisk, + AgentsView, + ChatView, + CommandCenter, + DevServerView, + DocumentsView, + EvalsView, + GoalsView, + InsightsView, + MemoryView, + PullRequestView, + ResearchView, + SecretsView, + SkillsView, + TodoView, + _AutomationsView, + _ImportTasksView, + _SettingsView, + _WorkflowEditorView, +}: MainContentProps) { + if (showBackendConnectionErrorPage) { + return ( + { + void shellApi.openConnectionManager(); + } : undefined} + /> + ); + } + + /* + FNXC:Settings 2026-06-22-00:00: + Settings renders ahead of the overview branch so the header gear opens the embedded Settings view even when no project is selected (viewMode === "overview"), matching the prior modal which opened regardless of view mode. + */ + if (taskView === "settings") { + const closeSettingsView = () => { + modalManager.closeSettings(); + handleChangeTaskView("board"); + }; + return ( + + + <_SettingsView + onClose={closeSettingsView} + addToast={addToast} + initialSection={modalManager.settingsInitialSection} + projectId={currentProject?.id} + themeMode={themeMode} + colorTheme={colorTheme} + onThemeModeChange={setThemeMode} + onColorThemeChange={setColorTheme} + dashboardFontScalePct={dashboardFontScalePct} + shadcnCustomColors={shadcnCustomColors} + resolvedThemeMode={resolvedThemeMode} + onDashboardFontScaleChange={setDashboardFontScalePct} + onShadcnCustomColorsChange={setShadcnCustomColors} + onQuickChatButtonModeChange={setQuickChatButtonModeImmediate} + onReopenOnboarding={reopenOnboardingWithNav} + onOpenApprovals={() => handleChangeTaskView("mailbox")} + onOpenWorkflowSettings={() => { + closeSettingsView(); + modalManager.openWorkflowEditor("settings"); + }} + /> + + + ); + } + + if (viewMode === "overview") { + return ( + + + + ); + } + + const resolvedPluginTaskView = taskView === "graph" ? graphPluginTaskView : (isPluginViewId(taskView) ? taskView : null); + + // Project view + if (resolvedPluginTaskView) { + const pluginTasks = isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks; + return ( + + openDetailTask(task, initialTab), + openFile: openFileInBrowser, + renderTaskCard: (task: Task | TaskDetail) => ( + openDetailTask(value)} + addToast={addToast} + workflowStepNameLookup={workflowStepNameLookup} + disableDrag={true} + prAuthAvailable={prAuthAvailable} + autoMergeEnabled={autoMerge} + nearDuplicateCanonicalInactive={typeof task.sourceMetadata?.nearDuplicateOf === "string" + ? isNearDuplicateCanonicalInactive(pluginTasks.find((candidate) => candidate.id === task.sourceMetadata?.nearDuplicateOf)) + : undefined} + /> + ), + addToast, + }} + /> + + ); + } + + if (taskView === "skills") { + if (!settingsLoaded || !skillsEnabled) { + return null; + } + return ( + + + handleChangeTaskView("board")} + /> + + + ); + } + + if (taskView === "chat") { + return ( + + + setQuickChatOpen(true)} + /> + + + ); + } + + if (taskView === "mailbox") { + return ( + + + + ); + } + + + if (taskView === "missions") { + return ( + + { + setMissionTargetId(undefined); + setMissionResumeSessionId(undefined); + setMilestoneSliceResumeSessionId(undefined); + handleChangeTaskView("board"); + }} + addToast={addToast} + projectId={currentProject?.id} + onSelectTask={(taskId) => { + const task = tasks.find((t) => t.id === taskId); + if (task) openDetailTask(task as TaskDetail); + }} + availableTasks={tasks.map((t) => ({ id: t.id, title: t.title }))} + resumeSessionId={missionResumeSessionId} + targetMissionId={missionTargetId} + milestoneSliceResumeSessionId={milestoneSliceResumeSessionId} + onMilestoneSliceResumeFetchError={() => setMilestoneSliceResumeSessionId(undefined)} + onNavigateToGoal={(goalId) => { + setGoalAnchorId(goalId); + handleChangeTaskView("goalsView"); + }} + /> + + ); + } + + if (taskView === "agents" && agentsEnabled) { + return ( + + + + + + ); + } + + if (taskView === "documents") { + return ( + + + + + + ); + } + + if (taskView === "pull-requests") { + return ( + + + + + + ); + } + + if (taskView === "insights") { + if (!settingsLoaded || !insightsEnabled) { + return null; + } + return ( + + + handleChangeTaskView("board")} + onCreateTask={handleInsightTaskCreate} + /> + + + ); + } + + if (taskView === "research") { + if (!settingsLoaded || !researchEnabled) { + return null; + } + return ( + + + openSettingsWithNav(section as SectionId)} + readinessVersion={researchReadinessVersion} + /> + + + ); + } + + if (taskView === "evals") { + if (!settingsLoaded || !evalsEnabled) { + return null; + } + return ( + + + openSettingsWithNav(section as SectionId)} + onOpenTaskDetail={(taskId) => { + void fetchTaskDetail(taskId, currentProject?.id) + .then((task) => openDetailTask(task as TaskDetail)) + .catch((error) => addToast(error instanceof Error ? error.message : "Failed to open task detail", "error")); + }} + /> + + + ); + } + + if (taskView === "memory") { + if (!settingsLoaded || !memoryEnabled) { + return null; + } + return ( + + + + + + ); + } + + if (taskView === "secrets") { + return ( + + + + + + ); + } + + if (taskView === "goalsView") { + if (!settingsLoaded || !goalsEnabled) { + return null; + } + return ( + + + + + + ); + } + if (taskView === "todos") { + // FNXC:Todos 2026-06-21-09:21: Todos render as a docked right-content view, not a modal overlay, per FN-6829 so all dashboard navigation surfaces share the same taskView routing model. + if (!settingsLoaded || !todosEnabled) return null; + return ( + + + ingestCreatedTasks([task])} /> + + + ); + } + if (taskView === "command-center") { + return ( + + + + + + ); + } + + if (taskView === "planning") { + /* + FNXC:Navigation 2026-06-21-00:00: + FN-6886 renders Planning Mode as a top-level main-content destination. Sidebar navigation opens an empty planning view, while Board, Todos, inline create, and resume entry points carry their initial plan/workflow/session state through modalManager. + */ + const closePlanningView = () => { + modalManager.closePlanning(); + handleChangeTaskView("board"); + }; + return ( + + {/* + FNXC:Navigation 2026-06-22-00:00: + Planning shows the same board WorkflowSwitcher in the same Header workflow slot as Board/List (portaled by PlanningWorkflowSwitcherSlot), so workflow selection is reachable from the left-sidebar Planning destination. + */} + + + + ); + } + + /* + FNXC:Navigation 2026-06-22-00:00: + Workflows, Import Tasks (GitHub import), and Automations are left-sidebar destinations that render embedded in the main content area instead of as modal overlays. Closing returns to the board. The same components still mount as modals in AppModals for the mobile overflow path. + */ + if (taskView === "workflows") { + return ( + + + <_WorkflowEditorView + isOpen={true} + onClose={() => handleChangeTaskView("board")} + addToast={addToast} + projectId={currentProject?.id} + presentation="embedded" + /> + + + ); + } + + if (taskView === "import-tasks") { + return ( + + + <_ImportTasksView + isOpen={true} + onClose={() => handleChangeTaskView("board")} + onImport={handleGitHubImport} + tasks={tasks} + projectId={currentProject?.id} + presentation="embedded" + /> + + + ); + } + + if (taskView === "automations") { + return ( + + + <_AutomationsView + onClose={() => handleChangeTaskView("board")} + addToast={addToast} + projectId={currentProject?.id} + presentation="embedded" + /> + + + ); + } + + if (taskView === "devserver" || taskView === "dev-server") { + if (!settingsLoaded || !devServerEnabled) { + return null; + } + return ( + + + + + + ); + } + + /* + FNXC:Navigation 2026-06-22-00:00: + Board-opened task detail renders as a full main-content view that replaces the board. A Back-to-board button sits above an embedded TaskDetailContent (same props ListView passes to its split-detail pane). The live task is preferred from `tasks` by id so the detail updates on revalidation; the stored snapshot is the fallback. If neither resolves (snapshot cleared), fall back to the board so the panel is never blank. + */ + if (taskView === "task-detail") { + const liveDetailTask = mainPanelDetailTask + ? (tasks.find((candidate) => candidate.id === mainPanelDetailTask.id) ?? mainPanelDetailTask) + : null; + if (!liveDetailTask) { + return ( + + + + ); + } + return ( + +
+
+ { popOutTaskDetail(task); closeTaskDetailMainPanel(); }} + onOpenDetail={(value) => { + setMainPanelDetailTask(value); + setMainPanelDetailInitialTab("chat"); + }} + onMoveTask={moveTask} + onDeleteTask={deleteTask} + onMergeTask={mergeTask} + onRetryTask={retryTask} + onResetTask={resetTask} + onDuplicateTask={duplicateTask} + /* + FNXC:Navigation 2026-06-22-09:00: + The full-panel task-detail must dismiss back to the board when a destructive/terminal action (delete/merge/archive/retry/reset/duplicate) fires, mirroring the modal path. Without onRequestClose the panel kept showing a ghost of the just-acted-on task. + */ + onRequestClose={closeTaskDetailMainPanel} + onTaskUpdated={(updatedTask) => { + setMainPanelDetailTask((previous) => { + if (!previous || previous.id !== updatedTask.id) return previous; + return { ...previous, ...updatedTask }; + }); + }} + addToast={addToast} + prAuthAvailable={prAuthAvailable} + autoMergeEnabled={autoMerge} + /> +
+
+
+ ); + } + + if (taskView === "board") { + return ( + + {capacityRiskBannerEnabled && !capacityRiskDismissed ? ( + + ) : null} + + + ); + } + + // List view + return ( + + 0 ? remoteData.tasks : tasks} + projectId={currentProject?.id} + onMoveTask={moveTask} + onRetryTask={retryTask} + onDeleteTask={deleteTask} + onPauseTask={pauseTask} + onUnpauseTask={unpauseTask} + onArchiveTask={archiveTask} + onMergeTask={mergeTask} + onResetTask={resetTask} + onDuplicateTask={duplicateTask} + onOpenDetail={(task, options) => openDetailTask(task, undefined, options)} + onPopOut={popOutTaskDetail} + addToast={addToast} + globalPaused={globalPaused} + onNewTask={openNewTaskWithNav} + onQuickCreate={handleBoardQuickCreate} + onPlanningMode={openPlanningWithInitialPlanWithNav} + onSubtaskBreakdown={subtaskBreakdownEnabled ? openSubtaskBreakdownWithNav : undefined} + availableModels={availableModels} + favoriteProviders={favoriteProviders} + favoriteModels={favoriteModels} + onToggleFavorite={handleToggleFavorite} + onToggleModelFavorite={handleToggleModelFavorite} + taskStuckTimeoutMs={taskStuckTimeoutMs} + searchQuery={searchQuery} + lastFetchTimeMs={lastFetchTimeMs} + prAuthAvailable={prAuthAvailable} + autoMerge={autoMerge} + onOpenWorkflowEditor={openWorkflowEditorWithNav} + onCreateWorkflow={openCreateWorkflowWithNav} + workflowColumnsEnabled + settingsLoaded={settingsLoaded} + workflowControlsInHeader={sidebarActive || isMobile} + /> + + ); +} diff --git a/packages/dashboard/app/components/dashboard/types.ts b/packages/dashboard/app/components/dashboard/types.ts new file mode 100644 index 0000000000..6e5769883f --- /dev/null +++ b/packages/dashboard/app/components/dashboard/types.ts @@ -0,0 +1,274 @@ +/** + * Props for MainContent — the presentational switch that renders the dashboard's + * main content area based on taskView/viewMode. Extracted verbatim from + * AppInner's renderMainContent(); every field is an AppInner-scoped value that + * the switch closes over. The lazy view chunks stay declared in App.tsx (per the + * inventory guard) and are threaded here as props; other helpers, types, and + * components are imported directly by MainContent.tsx. + */ +import type { Dispatch, LazyExoticComponent, SetStateAction } from "react"; +import type { TFunction } from "i18next"; +import type { + CapacityRiskSignal, + ColorTheme, + ColumnId, + GithubIssueAction, + MergeResult, + Task, + TaskCreateInput, + TaskDetail, + ThemeMode, + WorkflowStep, +} from "@fusion/core"; +import type { + AiSessionSummary, + DashboardHealthResponse, + ModelInfo, + NodeInfo, + ProjectInfo, + ProjectInfoWithSource, +} from "../../api"; +import type { FusionShellApi } from "../../types/native-shell"; +import type { DetailTaskOrigin, DetailTaskTab, ModalManager } from "../../hooks/useModalManager"; +import type { PluginTaskView, TaskView, ViewMode } from "../../hooks/useViewState"; +import type { ToastType } from "../../hooks/useToast"; +import type { QuickChatButtonMode } from "../../hooks/useAppSettings"; +import type { UseRemoteNodeDataResult } from "../../hooks/useRemoteNodeData"; +import type { SectionId } from "../SettingsModal"; +import type { CliActionId } from "../SessionNotificationBanner"; +import type { ApprovalBannerCandidate } from "../../utils/appLifecycle"; +// The lazy view components are value exports; importing them as values lets us +// spell their types via `typeof` so MainContent's JSX gets full prop checking. +import { SettingsView } from "../SettingsModal"; +import { AgentsView } from "../AgentsView"; +import { ChatView } from "../ChatView"; +import { CommandCenter } from "../command-center/CommandCenter"; +import { DevServerView } from "../DevServerView"; +import { DocumentsView } from "../DocumentsView"; +import { EvalsView } from "../EvalsView"; +import { GitHubImportModal } from "../GitHubImportModal"; +import { GoalsView } from "../GoalsView"; +import { InsightsView } from "../InsightsView"; +import { MemoryView } from "../MemoryView"; +import { PullRequestView } from "../PullRequestView"; +import { ResearchView } from "../ResearchView"; +import { ScheduledTasksModal } from "../ScheduledTasksModal"; +import { SecretsView } from "../SecretsView"; +import { SkillsView } from "../SkillsView"; +import { TodoView } from "../TodoView"; +import { WorkflowNodeEditor } from "../WorkflowNodeEditor"; + +export interface MainContentProps { + showBackendConnectionErrorPage: boolean; + projectsError: string | null; + t: TFunction; + retryingProjects: boolean; + handleRetryProjects: () => Promise; + shellApi: FusionShellApi | null; + taskView: TaskView; + modalManager: ModalManager; + handleChangeTaskView: (newView: TaskView) => void; + addToast: (message: string, type?: ToastType) => void; + currentProject: ProjectInfo | null; + themeMode: ThemeMode; + setThemeMode: (mode: ThemeMode) => void; + colorTheme: ColorTheme; + setColorTheme: (theme: ColorTheme) => void; + dashboardFontScalePct: number; + setDashboardFontScalePct: (scalePct: number) => void; + shadcnCustomColors: Record; + setShadcnCustomColors: (colors: Record) => void; + resolvedThemeMode: "dark" | "light"; + setQuickChatButtonModeImmediate: (mode: QuickChatButtonMode) => void; + reopenOnboardingWithNav: () => void; + viewMode: ViewMode; + projects: ProjectInfoWithSource[]; + projectsLoading: boolean; + handleSelectProject: (project: ProjectInfo) => void; + handleAddProject: () => void; + handlePauseProject: (project: ProjectInfo) => Promise; + handleResumeProject: (project: ProjectInfo) => Promise; + handleRemoveProject: (project: ProjectInfo) => Promise; + nodes: NodeInfo[]; + graphPluginTaskView: PluginTaskView | null; + isRemote: boolean; + remoteData: UseRemoteNodeDataResult; + tasks: Task[]; + workflowSteps: WorkflowStep[]; + subscribePluginEvents: ( + pluginId: string, + onEvent: (e: { event: string; payload: unknown }) => void, + ) => () => void; + openDetailTask: ( + task: Task | TaskDetail, + initialTab?: DetailTaskTab, + options?: { origin?: DetailTaskOrigin }, + ) => void; + openFileInBrowser: (path: string, opts?: { workspace?: string; line?: number; col?: number }) => void; + workflowStepNameLookup: Map; + prAuthAvailable: boolean; + autoMerge: boolean; + settingsLoaded: boolean; + skillsEnabled: boolean; + experimentalFeatures: Record; + setQuickChatOpen: Dispatch>; + setMailboxUnreadCount: (count: number) => void; + setMissionTargetId: Dispatch>; + setMissionResumeSessionId: Dispatch>; + setMilestoneSliceResumeSessionId: Dispatch>; + missionResumeSessionId: string | undefined; + missionTargetId: string | undefined; + milestoneSliceResumeSessionId: string | undefined; + setGoalAnchorId: Dispatch>; + goalAnchorId: string | undefined; + agentsEnabled: boolean; + agentOnboardingEnabled: boolean; + handleOpenTaskLogs: (taskId: string) => Promise; + popOutTaskDetail: (task: Task | TaskDetail) => void; + selectedPrId: string | undefined; + insightsEnabled: boolean; + handleInsightTaskCreate: (input: { insightId: string; title: string; description: string }) => Promise; + researchEnabled: boolean; + openSettingsWithNav: (section?: SectionId) => void; + researchReadinessVersion: number; + evalsEnabled: boolean; + memoryEnabled: boolean; + goalsEnabled: boolean; + handleOpenMission: (missionId: string) => void; + todosEnabled: boolean; + openPlanningWithInitialPlanWithNav: (initialPlan: string, workflowId?: string | null) => void; + ingestCreatedTasks: (tasks: Task[]) => void; + nodesEnabled: boolean; + openWorkflowEditorWithNav: (workflowId?: string) => void; + handlePlanningTaskCreated: (task: Task) => void; + handlePlanningTasksCreated: (tasks: Task[]) => void; + handleGitHubImport: (task: Task) => void; + devServerEnabled: boolean; + mainPanelDetailTask: Task | TaskDetail | null; + filteredBoardTasks: Task[]; + maxConcurrent: number; + moveTask: ( + id: string, + column: ColumnId, + optionsOrPosition?: { preserveProgress?: boolean } | number, + ) => Promise; + pauseTask: (id: string) => Promise; + openTaskDetailInMainPanel: (task: Task | TaskDetail, initialTab?: DetailTaskTab) => void; + openGroupModalWithNav: (groupId: string) => void; + handleBoardQuickCreate: (input: TaskCreateInput) => Promise; + openNewTaskWithNav: () => void; + subtaskBreakdownEnabled: boolean; + openSubtaskBreakdownWithNav: (description: string, workflowId?: string | null) => void; + toggleAutoMerge: () => Promise; + globalPaused: boolean; + updateTask: ( + id: string, + updates: { title?: string; description?: string; dependencies?: string[]; dismissNearDuplicate?: boolean }, + ) => Promise; + retryTask: (id: string) => Promise; + archiveTask: (id: string, options?: { removeLineageReferences?: boolean }) => Promise; + unarchiveTask: (id: string) => Promise; + deleteTask: ( + id: string, + options?: { + removeDependencyReferences?: boolean; + removeLineageReferences?: boolean; + githubIssueAction?: GithubIssueAction; + allowResurrection?: boolean; + }, + ) => Promise; + archiveAllDone: () => Promise; + loadArchivedTasks: () => Promise; + searchQuery: string; + availableModels: ModelInfo[]; + favoriteProviders: string[]; + favoriteModels: string[]; + handleOpenDetailWithTab: (task: Task | TaskDetail, initialTab: "changes" | "retries" | "workflow") => void; + handleToggleFavorite: (provider: string) => Promise; + handleToggleModelFavorite: (modelId: string) => Promise; + taskStuckTimeoutMs: number | undefined; + staleHighFanoutBlockerAgeThresholdMs: number; + lastFetchTimeMs: number | undefined; + openCreateWorkflowWithNav: () => void; + sidebarActive: boolean; + isMobile: boolean; + mainPanelDetailInitialTab: DetailTaskTab; + closeTaskDetailMainPanel: () => void; + setMainPanelDetailTask: Dispatch>; + setMainPanelDetailInitialTab: (tab: DetailTaskTab) => void; + mergeTask: (id: string) => Promise; + resetTask: (id: string) => Promise; + duplicateTask: (id: string) => Promise; + unpauseTask: (id: string) => Promise; + capacityRiskBannerEnabled: boolean; + capacityRiskDismissed: boolean; + capacityRiskSignal: CapacityRiskSignal; + handleDismissCapacityRisk: () => void; + // App-level lazy view chunks (declared in App.tsx, threaded in as props). + AgentsView: LazyExoticComponent; + ChatView: LazyExoticComponent; + CommandCenter: LazyExoticComponent; + DevServerView: LazyExoticComponent; + DocumentsView: LazyExoticComponent; + EvalsView: LazyExoticComponent; + GoalsView: LazyExoticComponent; + InsightsView: LazyExoticComponent; + MemoryView: LazyExoticComponent; + PullRequestView: LazyExoticComponent; + ResearchView: LazyExoticComponent; + SecretsView: LazyExoticComponent; + SkillsView: LazyExoticComponent; + TodoView: LazyExoticComponent; + _AutomationsView: LazyExoticComponent; + _ImportTasksView: LazyExoticComponent; + _SettingsView: LazyExoticComponent; + _WorkflowEditorView: LazyExoticComponent; +} + +/** + * Props for DashboardBanners — the conditional banner cluster rendered above + * the dashboard-project-shell, extracted verbatim from AppInner's main return + * JSX. Every field is an AppInner-scoped value the cluster closes over; the + * banner components are imported directly by DashboardBanners.tsx. + */ +export interface DashboardBannersProps { + viewMode: ViewMode; + currentProject: ProjectInfo | null; + isTestMode: boolean; + dashboardHealth: DashboardHealthResponse | null; + setDashboardHealth: Dispatch>; + taskView: TaskView; + modalManager: ModalManager; + sessionBannersHidden: boolean; + sessionsNeedingInput: AiSessionSummary[]; + handleOpenBackgroundSession: (session: AiSessionSummary) => void; + handleDismissNeedingInputSession: () => void; + handleDismissAllNeedingInputSessions: () => void; + handleCliAction: (session: AiSessionSummary, action: CliActionId) => Promise; + getCliActionDisabledReasonForBanner: (session: AiSessionSummary, action: CliActionId) => string | null; + openSettingsWithNav: (section?: SectionId) => void; + showOnboardingResumeCard: boolean; + showPostOnboardingRecommendations: boolean; + updateAvailable: boolean; + latestVersion: string | null; + currentVersion: string | null; + updateBannerDismissed: boolean; + dismissUpdateBanner: () => void; + refreshDbCorruptionHealth: () => Promise; + dbCorruptionRefreshing: boolean; + dbCorruptionRefreshError: string | null; + setupReadinessLoading: boolean; + hasWarnings: boolean; + setupWarningDismissed: boolean; + handleDismissSetupWarning: () => void; + hasAiProvider: boolean; + hasGithub: boolean; + approvalBannerCandidate: ApprovalBannerCandidate | null; + dismissApproval: (candidate: ApprovalBannerCandidate) => void; + mailboxPendingApprovalCount: number; + handleTaskViewChange: (newView: TaskView) => void; + showGitHubStarPrompt: boolean; + gitHubStarPromptShown: boolean; + markGitHubStarPromptShown: () => void; + setShowGitHubStarPrompt: Dispatch>; +} diff --git a/packages/dashboard/app/hooks/__tests__/sseSplitIntegration.test.ts b/packages/dashboard/app/hooks/__tests__/sseSplitIntegration.test.ts new file mode 100644 index 0000000000..0e2d2897b3 --- /dev/null +++ b/packages/dashboard/app/hooks/__tests__/sseSplitIntegration.test.ts @@ -0,0 +1,115 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderHook, act, waitFor } from "@testing-library/react"; +import type { Task } from "@fusion/core"; + +interface CapturedSubscription { + url: string; + onReconnect?: () => void; + events: Record void>; +} + +const { subscriptions } = vi.hoisted(() => ({ + subscriptions: [] as CapturedSubscription[], +})); + +vi.mock("../../sse-bus", () => ({ + subscribeSse: vi.fn( + ( + url: string, + sub: { onReconnect?: () => void; events: Record void> }, + ) => { + subscriptions.push({ url, onReconnect: sub.onReconnect, events: { ...sub.events } }); + return () => {}; + }, + ), +})); + +const fetchUnreadCount = vi.fn(async () => ({ unreadCount: 0 })); +vi.mock("../../api", () => ({ + fetchUnreadCount: (...a: unknown[]) => fetchUnreadCount(...a), +})); + +import { useMailboxUnread } from "../useMailboxUnread"; +import { useApprovalBanner } from "../useApprovalBanner"; +import { msg } from "./sseTestHelpers"; + +describe("SSE split (KTD4): mailbox-refresh vs approval-banner", () => { + beforeEach(() => { + subscriptions.length = 0; + fetchUnreadCount.mockReset(); + fetchUnreadCount.mockResolvedValue({ unreadCount: 0 }); + }); + + it("co-mount keeps the awaiting-approval refresh single-fired and the banner independent", async () => { + const mailboxSpy = vi.fn(); + const tasks: Task[] = []; + const onStarPrompt = vi.fn(); + + // Two independent mounts → two subscribeSse calls captured separately so + // the split handlers never overwrite each other. + renderHook(() => useMailboxUnread("p1")); + const approval = renderHook(() => + useApprovalBanner({ + tasks, + currentProjectId: "p1", + gitHubStarPromptShown: true, + onStarPrompt, + onMailboxRefresh: mailboxSpy, + }), + ); + + // Drain the mailbox hook's mount fetch deterministically — wait for the + // refresh call to fire and settle, so its setState doesn't leak past the + // test. (Replaces a magic 2x microtask flush.) + await act(async () => { + await waitFor(() => expect(fetchUnreadCount).toHaveBeenCalled()); + }); + + // Distinguish the two subscriptions: mailbox listens to message:sent, + // the banner listens to task:updated. + const mailboxSub = subscriptions.find((s) => "message:sent" in s.events); + const approvalSub = subscriptions.find((s) => "task:updated" in s.events); + expect(mailboxSub).toBeTruthy(); + expect(approvalSub).toBeTruthy(); + // The split extends to reconnect handling: the mailbox subscription wires + // an onReconnect (re-fetch counts), the approval banner does not. + expect(mailboxSub!.onReconnect).toBeTruthy(); + expect(approvalSub!.onReconnect).toBeUndefined(); + + // (i) approval:requested sets the banner candidate but does NOT fire + // mailbox-refresh; the mailbox hook's approval:requested handler + // (count refresh) is a distinct function from the banner's. + act(() => { + approvalSub!.events["approval:requested"]?.(msg({ id: "a1", updatedAt: "2026-01-01T00:00:00Z" })); + }); + expect(approval.result.current.candidate?.dedupeKey).toBe("approval:a1"); + expect(mailboxSpy).not.toHaveBeenCalled(); + expect(mailboxSub!.events["approval:requested"]).toBeTruthy(); + expect(mailboxSub!.events["approval:requested"]).not.toBe(approvalSub!.events["approval:requested"]); + // (ib) … and the mailbox handler actually refreshes the count (wires to + // fetchUnreadCount), proving it's a live handler — not merely present. + const refreshCallsBefore = fetchUnreadCount.mock.calls.length; + act(() => { + mailboxSub!.events["approval:requested"]?.(msg({ id: "a2", updatedAt: "2026-01-02T00:00:00Z" })); + }); + expect(fetchUnreadCount).toHaveBeenCalledTimes(refreshCallsBefore + 1); + + // (ii) task:updated → awaiting-approval sets the candidate + fires the + // mailbox refresh exactly once. + act(() => { + approvalSub!.events["task:updated"]?.( + msg({ id: "t1", status: "awaiting-approval", updatedAt: "2026-01-02T00:00:00Z" }), + ); + }); + expect(approval.result.current.candidate?.dedupeKey).toBe("task:t1"); + expect(mailboxSpy).toHaveBeenCalledTimes(1); + + // (iii) a second awaiting-approval for the same task is deduped — no second refresh. + act(() => { + approvalSub!.events["task:updated"]?.( + msg({ id: "t1", status: "awaiting-approval", updatedAt: "2026-01-03T00:00:00Z" }), + ); + }); + expect(mailboxSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/dashboard/app/hooks/__tests__/sseTestHelpers.ts b/packages/dashboard/app/hooks/__tests__/sseTestHelpers.ts new file mode 100644 index 0000000000..0e0adaee01 --- /dev/null +++ b/packages/dashboard/app/hooks/__tests__/sseTestHelpers.ts @@ -0,0 +1,13 @@ +/** + * Shared helpers for SSE-driven hook tests. Builds a synthetic MessageEvent + * whose `data` is the JSON-stringified payload, matching the shape these hooks + * parse inside their event handlers. + * + * NOTE: each consuming test still owns its own `vi.mock("../../sse-bus", …)` + * factory — vitest hoists `vi.mock` and resolves the path relative to the + * caller, so the mock cannot be shared from here. + */ +export const msg = (data: object): MessageEvent => + ({ data: JSON.stringify(data) } as MessageEvent); + +export const message = msg; diff --git a/packages/dashboard/app/hooks/__tests__/useApprovalBanner.test.ts b/packages/dashboard/app/hooks/__tests__/useApprovalBanner.test.ts new file mode 100644 index 0000000000..5d54569bcb --- /dev/null +++ b/packages/dashboard/app/hooks/__tests__/useApprovalBanner.test.ts @@ -0,0 +1,196 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import type { Task } from "@fusion/core"; + +const { handlers } = vi.hoisted(() => ({ + handlers: {} as Record void>, +})); + +vi.mock("../../sse-bus", () => ({ + subscribeSse: vi.fn((_url: string, opts: { events: Record void> }) => { + Object.assign(handlers, opts.events); + return () => {}; + }), +})); + +import { useApprovalBanner } from "../useApprovalBanner"; +import { msg } from "./sseTestHelpers"; + +const task = (id: string, status: string): Task => ({ id, status, title: id } as Task); + +describe("useApprovalBanner", () => { + beforeEach(() => { + for (const key of Object.keys(handlers)) delete handlers[key]; + }); + + it("triggers the banner + mailbox refresh when a task enters awaiting-approval", () => { + const onMailboxRefresh = vi.fn(); + const { result } = renderHook(() => + useApprovalBanner({ + tasks: [], + currentProjectId: "p1", + gitHubStarPromptShown: true, + onStarPrompt: vi.fn(), + onMailboxRefresh, + }), + ); + + act(() => { + handlers["task:updated"]?.(msg({ id: "t1", status: "awaiting-approval", updatedAt: "2026-01-01T00:00:00Z" })); + }); + + expect(result.current.candidate?.dedupeKey).toBe("task:t1"); + expect(onMailboxRefresh).toHaveBeenCalledTimes(1); + }); + + it("fires the star prompt on the first transition to done", () => { + const onStarPrompt = vi.fn(); + renderHook(() => + useApprovalBanner({ + // Seed the status map so done is a transition from in-progress. + tasks: [task("t1", "in-progress")], + currentProjectId: "p1", + gitHubStarPromptShown: false, + onStarPrompt, + onMailboxRefresh: vi.fn(), + }), + ); + + act(() => { + handlers["task:updated"]?.(msg({ id: "t1", status: "done" })); + }); + + expect(onStarPrompt).toHaveBeenCalledTimes(1); + }); + + it("does not star-prompt again once the prompt has been shown", () => { + const onStarPrompt = vi.fn(); + renderHook(() => + useApprovalBanner({ + tasks: [task("t1", "in-progress")], + currentProjectId: "p1", + gitHubStarPromptShown: true, + onStarPrompt, + onMailboxRefresh: vi.fn(), + }), + ); + + act(() => { + handlers["task:updated"]?.(msg({ id: "t1", status: "done" })); + }); + + expect(onStarPrompt).not.toHaveBeenCalled(); + }); + + it("dedupes a repeated approval:requested for the same key", () => { + const { result } = renderHook(() => + useApprovalBanner({ + tasks: [], + currentProjectId: "p1", + gitHubStarPromptShown: true, + onStarPrompt: vi.fn(), + onMailboxRefresh: vi.fn(), + }), + ); + + act(() => { + handlers["approval:requested"]?.(msg({ id: "a1", updatedAt: "2026-01-01T00:00:00Z" })); + }); + expect(result.current.candidate?.dedupeKey).toBe("approval:a1"); + + act(() => { + handlers["approval:requested"]?.(msg({ id: "a1", updatedAt: "2026-01-02T00:00:00Z" })); + }); + // Same dedupeKey — candidate stays at the first trigger's value. + expect(result.current.candidate?.dedupeKey).toBe("approval:a1"); + }); + + it("dismiss clears the candidate and suppresses re-trigger until a newer timestamp", () => { + const { result } = renderHook(() => + useApprovalBanner({ + tasks: [], + currentProjectId: "p1", + gitHubStarPromptShown: true, + onStarPrompt: vi.fn(), + onMailboxRefresh: vi.fn(), + }), + ); + + act(() => { + handlers["approval:requested"]?.(msg({ id: "a1", updatedAt: "2026-01-01T00:00:00Z" })); + }); + const dismissed = result.current.candidate!; + expect(dismissed).toBeTruthy(); + + act(() => { + result.current.dismissApproval(dismissed); + }); + expect(result.current.candidate).toBeNull(); + + // Same-or-older timestamp is suppressed after dismissal. + act(() => { + handlers["approval:requested"]?.(msg({ id: "a1", updatedAt: "2026-01-01T00:00:00Z" })); + }); + expect(result.current.candidate).toBeNull(); + }); + it("re-triggers after leaving and re-entering awaiting-approval (clear-on-leave)", () => { + const onMailboxRefresh = vi.fn(); + const seedTasks: Task[] = [task("t1", "awaiting-approval")]; + const { result } = renderHook(() => + useApprovalBanner({ + tasks: seedTasks, + currentProjectId: "p1", + gitHubStarPromptShown: true, + onStarPrompt: vi.fn(), + onMailboxRefresh, + }), + ); + + // The seeded awaiting-approval task is already in the seen set, so a repeat + // event for it must NOT trigger. + act(() => { + handlers["task:updated"]?.(msg({ id: "t1", status: "awaiting-approval", updatedAt: "2026-01-01T00:00:00Z" })); + }); + expect(result.current.candidate).toBeNull(); + expect(onMailboxRefresh).not.toHaveBeenCalled(); + + // Task leaves awaiting-approval → the seen-key for t1 is cleared. + act(() => { + handlers["task:updated"]?.(msg({ id: "t1", status: "approved", updatedAt: "2026-01-02T00:00:00Z" })); + }); + expect(result.current.candidate).toBeNull(); + + // Re-entering awaiting-approval re-triggers the candidate + mailbox refresh. + act(() => { + handlers["task:updated"]?.(msg({ id: "t1", status: "awaiting-approval", updatedAt: "2026-01-03T00:00:00Z" })); + }); + expect(result.current.candidate?.dedupeKey).toBe("task:t1"); + expect(onMailboxRefresh).toHaveBeenCalledTimes(1); + }); + + it("dedupes mailbox refresh on a repeated awaiting-approval task:updated", () => { + const onMailboxRefresh = vi.fn(); + const tasks: Task[] = []; + const { result } = renderHook(() => + useApprovalBanner({ + tasks, + currentProjectId: "p1", + gitHubStarPromptShown: true, + onStarPrompt: vi.fn(), + onMailboxRefresh, + }), + ); + + act(() => { + handlers["task:updated"]?.(msg({ id: "t1", status: "awaiting-approval", updatedAt: "2026-01-01T00:00:00Z" })); + }); + expect(result.current.candidate?.dedupeKey).toBe("task:t1"); + expect(onMailboxRefresh).toHaveBeenCalledTimes(1); + + // A second awaiting-approval for the same task is suppressed by seenApprovalKeys. + act(() => { + handlers["task:updated"]?.(msg({ id: "t1", status: "awaiting-approval", updatedAt: "2026-01-04T00:00:00Z" })); + }); + expect(onMailboxRefresh).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/dashboard/app/hooks/__tests__/useAuthTokenRecovery.test.ts b/packages/dashboard/app/hooks/__tests__/useAuthTokenRecovery.test.ts new file mode 100644 index 0000000000..54eec1cde2 --- /dev/null +++ b/packages/dashboard/app/hooks/__tests__/useAuthTokenRecovery.test.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } from "../../auth"; +import { useAuthTokenRecovery } from "../useAuthTokenRecovery"; + +describe("useAuthTokenRecovery", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + it("opens when the daemon auth-failure event fires", () => { + const { result } = renderHook(() => useAuthTokenRecovery()); + + expect(result.current.open).toBe(false); + + act(() => { + window.dispatchEvent(new Event(AUTH_TOKEN_RECOVERY_REQUIRED_EVENT)); + }); + + expect(result.current.open).toBe(true); + }); + it("removes the daemon auth-failure listener on unmount", () => { + const addSpy = vi.spyOn(window, "addEventListener"); + const removeSpy = vi.spyOn(window, "removeEventListener"); + const { unmount } = renderHook(() => useAuthTokenRecovery()); + + const addedCall = addSpy.mock.calls.find( + ([type]) => type === AUTH_TOKEN_RECOVERY_REQUIRED_EVENT, + ); + expect(addedCall).toBeTruthy(); + const addedHandler = addedCall![1] as EventListener; + + unmount(); + + expect(removeSpy).toHaveBeenCalledWith(AUTH_TOKEN_RECOVERY_REQUIRED_EVENT, addedHandler); + }); +}); diff --git a/packages/dashboard/app/hooks/__tests__/useBoardScrollRestore.test.ts b/packages/dashboard/app/hooks/__tests__/useBoardScrollRestore.test.ts new file mode 100644 index 0000000000..8da0ee7f93 --- /dev/null +++ b/packages/dashboard/app/hooks/__tests__/useBoardScrollRestore.test.ts @@ -0,0 +1,70 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import type { TaskView } from "../useViewState"; + +vi.mock("../../utils/boardScrollSnapshot", () => ({ + captureBoardScrollSnapshot: vi.fn(), + restoreBoardScrollSnapshot: vi.fn(() => true), +})); + +import { captureBoardScrollSnapshot, restoreBoardScrollSnapshot } from "../../utils/boardScrollSnapshot"; +import { useBoardScrollRestore } from "../useBoardScrollRestore"; + +const mockedCapture = vi.mocked(captureBoardScrollSnapshot); +const mockedRestore = vi.mocked(restoreBoardScrollSnapshot); + +describe("useBoardScrollRestore", () => { + beforeEach(() => { + mockedCapture.mockReset(); + mockedRestore.mockReset(); + mockedRestore.mockReturnValue(true); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("exposes capture and requestRestore without throwing", () => { + const { result } = renderHook(() => useBoardScrollRestore("board")); + + expect(typeof result.current.capture).toBe("function"); + expect(typeof result.current.requestRestore).toBe("function"); + expect(() => result.current.capture()).not.toThrow(); + expect(() => result.current.requestRestore()).not.toThrow(); + }); + + it("restores the captured snapshot after returning to the board view", () => { + const sentinel = { boardLeft: 42, boardTop: 7, columnTops: { c1: 3 } }; + mockedCapture.mockReturnValue(sentinel); + + // Make the double requestAnimationFrame fire synchronously so the restore + // lands inside the act() that commits the board-view effect. + vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb: FrameRequestCallback) => { + cb(0); + return 0; + }); + + const { result, rerender } = renderHook( + ({ taskView }: { taskView: TaskView }) => useBoardScrollRestore(taskView), + { initialProps: { taskView: "task-detail" } }, + ); + + // Off the board with nothing pending → no restore yet. + expect(mockedRestore).not.toHaveBeenCalled(); + + act(() => { + result.current.capture(); + result.current.requestRestore(); + }); + + // Restore waits for the view to return to "board". + expect(mockedRestore).not.toHaveBeenCalled(); + + act(() => { + rerender({ taskView: "board" }); + }); + + expect(mockedRestore).toHaveBeenCalledTimes(1); + expect(mockedRestore).toHaveBeenCalledWith(sentinel); + }); +}); diff --git a/packages/dashboard/app/hooks/__tests__/useBranchTaskFilters.test.ts b/packages/dashboard/app/hooks/__tests__/useBranchTaskFilters.test.ts new file mode 100644 index 0000000000..3b6b38d972 --- /dev/null +++ b/packages/dashboard/app/hooks/__tests__/useBranchTaskFilters.test.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import type { Task } from "@fusion/core"; + +vi.mock("../../utils/projectStorage", () => ({ + getScopedItem: vi.fn(() => null), + setScopedItem: vi.fn(), +})); + +import { getScopedItem, setScopedItem } from "../../utils/projectStorage"; +import { useBranchTaskFilters } from "../useBranchTaskFilters"; +import { NO_BRANCH_FILTER_VALUE } from "../../utils/appLifecycle"; + +const task = (id: string, branch?: string, baseBranch?: string): Task => + ({ id, title: id, branch, baseBranch } as Task); + +describe("useBranchTaskFilters", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("derives unique, sorted branch options and drops empty branches", () => { + const { result } = renderHook(() => + useBranchTaskFilters({ + boardSourceTasks: [task("1", "zebra"), task("2", " "), task("3", "alpha"), task("4", "alpha")], + currentProjectId: "p1", + }), + ); + + expect(result.current.branchOptions).toEqual(["alpha", "zebra"]); + }); + + it("excludes tasks that have a branch under the no-branch sentinel", () => { + const { result } = renderHook(() => + useBranchTaskFilters({ + boardSourceTasks: [task("1", "feat"), task("2")], + currentProjectId: "p1", + }), + ); + + act(() => { + result.current.onBranchFilterChange(NO_BRANCH_FILTER_VALUE); + }); + + expect(result.current.filteredBoardTasks.map((t) => t.id)).toEqual(["2"]); + }); + + it("excludes tasks whose branch does not match a concrete filter", () => { + const { result } = renderHook(() => + useBranchTaskFilters({ + boardSourceTasks: [task("1", "feat"), task("2", "main")], + currentProjectId: "p1", + }), + ); + + act(() => { + result.current.onBranchFilterChange("feat"); + }); + + expect(result.current.filteredBoardTasks.map((t) => t.id)).toEqual(["1"]); + }); + + it("composes the base-branch filter independently", () => { + const { result } = renderHook(() => + useBranchTaskFilters({ + boardSourceTasks: [ + task("1", "feat", "main"), + task("2", "feat", "release"), + task("3", "other", "main"), + ], + currentProjectId: "p1", + }), + ); + + act(() => { + result.current.onBranchFilterChange("feat"); + result.current.onBaseBranchFilterChange("main"); + }); + + expect(result.current.filteredBoardTasks.map((t) => t.id)).toEqual(["1"]); + }); + + it("persists filter changes to scoped storage", () => { + const { result } = renderHook(() => + useBranchTaskFilters({ boardSourceTasks: [], currentProjectId: "p1" }), + ); + + act(() => { + result.current.onBaseBranchFilterChange("release"); + }); + + expect(setScopedItem).toHaveBeenCalledWith(expect.any(String), "release", "p1"); + }); + + it("re-reads scoped values when the project changes", () => { + const { rerender } = renderHook( + (props: { currentProjectId: string | undefined }) => + useBranchTaskFilters({ boardSourceTasks: [], currentProjectId: props.currentProjectId }), + { initialProps: { currentProjectId: "p1" } }, + ); + + rerender({ currentProjectId: "p2" }); + + expect(getScopedItem).toHaveBeenCalledWith(expect.any(String), "p2"); + }); +}); diff --git a/packages/dashboard/app/hooks/__tests__/useCapacityRiskBanner.test.ts b/packages/dashboard/app/hooks/__tests__/useCapacityRiskBanner.test.ts new file mode 100644 index 0000000000..dce8981d14 --- /dev/null +++ b/packages/dashboard/app/hooks/__tests__/useCapacityRiskBanner.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderHook, act } from "@testing-library/react"; + +vi.mock("../../utils/projectStorage", () => ({ + getScopedItem: vi.fn(() => null), + setScopedItem: vi.fn(), + removeScopedItem: vi.fn(), +})); + +import { getScopedItem, removeScopedItem, setScopedItem } from "../../utils/projectStorage"; +import { useCapacityRiskBanner } from "../useCapacityRiskBanner"; + +const base = { + agentStats: { todoTaskCount: 5, idleNonEphemeralCount: 0 }, + inProgressCount: 1, + inReviewCount: 0, + capacityRiskBannerEnabled: true, + capacityRiskTodoThreshold: 3, + settingsLoaded: true, + currentProjectId: "p1", +}; + +describe("useCapacityRiskBanner", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("computes the capacity-risk signal from counts + threshold", () => { + const { result } = renderHook(() => useCapacityRiskBanner(base)); + + expect(result.current.signal).toBeTruthy(); + expect(result.current.signal.atRisk).toBe(true); + expect(result.current.signal.threshold).toBe(3); + }); + + it("dismiss persists to scoped storage and hides", () => { + const { result } = renderHook(() => useCapacityRiskBanner(base)); + + act(() => { + result.current.dismiss(); + }); + + expect(result.current.dismissed).toBe(true); + expect(setScopedItem).toHaveBeenCalledWith(expect.any(String), "true", "p1"); + }); + + it("clears a prior dismissal when the banner is re-enabled after hydrate", () => { + vi.mocked(getScopedItem).mockReturnValue("true"); + const { result, rerender } = renderHook( + (props: { enabled: boolean }) => + useCapacityRiskBanner({ ...base, capacityRiskBannerEnabled: props.enabled }), + { initialProps: { enabled: false } }, + ); + + // First settings load hydrates without clearing. + expect(result.current.dismissed).toBe(true); + expect(removeScopedItem).not.toHaveBeenCalled(); + + // Re-enabling the banner resurrects the dismissed banner. + rerender({ enabled: true }); + + expect(removeScopedItem).toHaveBeenCalledWith(expect.any(String), "p1"); + expect(result.current.dismissed).toBe(false); + }); + it("clears a prior dismissal when the todo threshold changes after hydrate", () => { + vi.mocked(getScopedItem).mockReturnValue("true"); + const { result, rerender } = renderHook( + (props: { threshold: number }) => + useCapacityRiskBanner({ ...base, capacityRiskTodoThreshold: props.threshold }), + { initialProps: { threshold: 3 } }, + ); + + // First settings load hydrates without clearing. + expect(result.current.dismissed).toBe(true); + expect(removeScopedItem).not.toHaveBeenCalled(); + + // Changing the threshold resurrects the previously-dismissed banner. + rerender({ threshold: 5 }); + + expect(removeScopedItem).toHaveBeenCalledWith(expect.any(String), "p1"); + expect(result.current.dismissed).toBe(false); + }); +}); diff --git a/packages/dashboard/app/hooks/__tests__/useChatUnreadBadge.test.ts b/packages/dashboard/app/hooks/__tests__/useChatUnreadBadge.test.ts new file mode 100644 index 0000000000..48c97b06fa --- /dev/null +++ b/packages/dashboard/app/hooks/__tests__/useChatUnreadBadge.test.ts @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import type { TaskView } from "../useViewState"; + +const { handlers } = vi.hoisted(() => ({ + handlers: {} as Record void>, +})); + +vi.mock("../../sse-bus", () => ({ + subscribeSse: vi.fn((_url: string, opts: { events: Record void> }) => { + Object.assign(handlers, opts.events); + return () => {}; + }), +})); + +import { useChatUnreadBadge } from "../useChatUnreadBadge"; +import { message } from "./sseTestHelpers"; + +describe("useChatUnreadBadge", () => { + beforeEach(() => { + for (const key of Object.keys(handlers)) delete handlers[key]; + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("marks unread on an assistant message while not viewing chat", () => { + const { result } = renderHook(() => + useChatUnreadBadge(undefined, { taskView: "board", quickChatOpen: false }), + ); + + act(() => { + handlers["chat:message:added"]?.(message({ role: "assistant" })); + }); + + expect(result.current.chatHasUnreadResponse).toBe(true); + }); + + it("ignores user-role messages", () => { + const { result } = renderHook(() => + useChatUnreadBadge(undefined, { taskView: "board", quickChatOpen: false }), + ); + + act(() => { + handlers["chat:message:added"]?.(message({ role: "user" })); + }); + + expect(result.current.chatHasUnreadResponse).toBe(false); + }); + + it("ignores assistant messages while the chat view is open", () => { + const { result } = renderHook(() => + useChatUnreadBadge(undefined, { taskView: "chat", quickChatOpen: false }), + ); + + act(() => { + handlers["chat:message:added"]?.(message({ role: "assistant" })); + }); + + expect(result.current.chatHasUnreadResponse).toBe(false); + }); + + it("clears the unread flag once the chat view opens", () => { + const { result, rerender } = renderHook( + ({ taskView }: { taskView: TaskView }) => + useChatUnreadBadge(undefined, { taskView, quickChatOpen: false }), + { initialProps: { taskView: "board" } }, + ); + + act(() => { + handlers["chat:message:added"]?.(message({ role: "assistant" })); + }); + expect(result.current.chatHasUnreadResponse).toBe(true); + + rerender({ taskView: "chat" }); + expect(result.current.chatHasUnreadResponse).toBe(false); + }); + it("marks unread on a non-user chat:room:message:added", () => { + const { result } = renderHook(() => + useChatUnreadBadge(undefined, { taskView: "board", quickChatOpen: false }), + ); + + act(() => { + handlers["chat:room:message:added"]?.(message({ role: "assistant" })); + }); + + expect(result.current.chatHasUnreadResponse).toBe(true); + }); + + it("ignores user-role chat:room:message:added events", () => { + const { result } = renderHook(() => + useChatUnreadBadge(undefined, { taskView: "board", quickChatOpen: false }), + ); + + act(() => { + handlers["chat:room:message:added"]?.(message({ role: "user" })); + }); + + expect(result.current.chatHasUnreadResponse).toBe(false); + }); + + it("ignores assistant messages scoped to a different project", () => { + const { result } = renderHook(() => + useChatUnreadBadge("p1", { taskView: "board", quickChatOpen: false }), + ); + + act(() => { + handlers["chat:message:added"]?.(message({ role: "assistant", projectId: "p2" })); + }); + + expect(result.current.chatHasUnreadResponse).toBe(false); + }); + it("ignores assistant chat:room:message:added events scoped to a different project", () => { + const { result } = renderHook(() => + useChatUnreadBadge("p1", { taskView: "board", quickChatOpen: false }), + ); + + act(() => { + handlers["chat:room:message:added"]?.(message({ role: "assistant", projectId: "p2" })); + }); + + expect(result.current.chatHasUnreadResponse).toBe(false); + }); +}); diff --git a/packages/dashboard/app/hooks/__tests__/useDashboardHealth.test.ts b/packages/dashboard/app/hooks/__tests__/useDashboardHealth.test.ts new file mode 100644 index 0000000000..e865938de9 --- /dev/null +++ b/packages/dashboard/app/hooks/__tests__/useDashboardHealth.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderHook, act, waitFor } from "@testing-library/react"; + +const fetchDashboardHealth = vi.fn(); +const refreshDashboardHealth = vi.fn(); +vi.mock("../../api", () => ({ + fetchDashboardHealth: (...a: unknown[]) => fetchDashboardHealth(...a), + refreshDashboardHealth: (...a: unknown[]) => refreshDashboardHealth(...a), +})); + +import { useDashboardHealth } from "../useDashboardHealth"; + +describe("useDashboardHealth", () => { + beforeEach(() => { + fetchDashboardHealth.mockReset(); + refreshDashboardHealth.mockReset(); + }); + + it("seeds health from the mount fetch and falls back to null on failure", async () => { + fetchDashboardHealth.mockResolvedValue({ status: "ok" }); + const { result } = renderHook(() => useDashboardHealth()); + + await waitFor(() => expect(result.current.health).toEqual({ status: "ok" })); + + fetchDashboardHealth.mockResolvedValue(undefined); + fetchDashboardHealth.mockRejectedValue(new Error("boom")); + const failing = renderHook(() => useDashboardHealth()); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(failing.result.current.health).toBeNull(); + }); + + it("refresh sets refreshing, updates health, and clears refreshing on success", async () => { + fetchDashboardHealth.mockResolvedValue(null); + refreshDashboardHealth.mockResolvedValue({ status: "degraded" }); + const { result } = renderHook(() => useDashboardHealth()); + + await act(async () => { + await result.current.refresh(); + }); + + expect(refreshDashboardHealth).toHaveBeenCalledTimes(1); + expect(result.current.health).toEqual({ status: "degraded" }); + expect(result.current.refreshing).toBe(false); + expect(result.current.refreshError).toBeNull(); + }); + + it("refresh records an error message on failure", async () => { + fetchDashboardHealth.mockResolvedValue(null); + refreshDashboardHealth.mockRejectedValue(new Error("nope")); + const { result } = renderHook(() => useDashboardHealth()); + + await act(async () => { + await result.current.refresh(); + }); + + expect(result.current.refreshError).toBe("nope"); + expect(result.current.refreshing).toBe(false); + }); + it("fires the mount fetch and tolerates an unmount before it resolves", async () => { + let resolveMount: (value: { status: string }) => void = () => {}; + fetchDashboardHealth.mockImplementation( + () => + new Promise<{ status: string }>((resolve) => { + resolveMount = resolve; + }), + ); + + const { result, unmount } = renderHook(() => useDashboardHealth()); + // The effect has fired the mount fetch; health starts null until it settles. + expect(fetchDashboardHealth).toHaveBeenCalledTimes(1); + expect(result.current.health).toBeNull(); + + // Unmount while the fetch is still in flight, then resolve it. + unmount(); + resolveMount({ status: "ok" }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + // NOTE: the effect's `cancelled` guard defensively suppresses setHealth + // after unmount, but under React 19 setState on an unmounted component is + // silently dropped — `result.current.health` stays null *whether or not the + // guard exists*. Asserting state here would give false confidence (the test + // passes even with the guard removed), so the guard is treated as a + // React-19-untestable-via-state invariant and is intentionally NOT asserted + // here. Verified empirically: removing the guard leaves the suite green. + }); +}); diff --git a/packages/dashboard/app/hooks/__tests__/useMailboxUnread.test.ts b/packages/dashboard/app/hooks/__tests__/useMailboxUnread.test.ts new file mode 100644 index 0000000000..ac13b005af --- /dev/null +++ b/packages/dashboard/app/hooks/__tests__/useMailboxUnread.test.ts @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderHook, act, waitFor } from "@testing-library/react"; + +const { handlers } = vi.hoisted(() => ({ + handlers: {} as Record void> & { onReconnect?: () => void }, +})); + +vi.mock("../../sse-bus", () => ({ + subscribeSse: vi.fn((_url: string, opts: { onReconnect?: () => void; events: Record void> }) => { + handlers.onReconnect = opts.onReconnect; + Object.assign(handlers, opts.events); + return () => {}; + }), +})); + +const fetchUnreadCount = vi.fn(); +vi.mock("../../api", () => ({ fetchUnreadCount: (...a: unknown[]) => fetchUnreadCount(...a) })); + +import { useMailboxUnread } from "../useMailboxUnread"; + +describe("useMailboxUnread", () => { + beforeEach(() => { + for (const key of Object.keys(handlers)) delete (handlers as Record)[key]; + fetchUnreadCount.mockReset(); + }); + + it("seeds counts from the initial fetch", async () => { + fetchUnreadCount.mockResolvedValue({ unreadCount: 4, pendingApprovalCount: 2 }); + const { result } = renderHook(() => useMailboxUnread("p1")); + + await waitFor(() => expect(result.current.mailboxUnreadCount).toBe(4)); + expect(result.current.mailboxPendingApprovalCount).toBe(2); + }); + + it("refreshes counts on a message:sent SSE event", async () => { + fetchUnreadCount.mockResolvedValue({ unreadCount: 1 }); + const { result } = renderHook(() => useMailboxUnread("p1")); + await waitFor(() => expect(result.current.mailboxUnreadCount).toBe(1)); + + fetchUnreadCount.mockResolvedValue({ unreadCount: 9 }); + await act(async () => { + handlers["message:sent"]?.({} as MessageEvent); + await Promise.resolve(); + }); + + await waitFor(() => expect(result.current.mailboxUnreadCount).toBe(9)); + }); + + it("exposes setMailboxUnreadCount for MailboxView's onUnreadCountChange", () => { + fetchUnreadCount.mockResolvedValue({ unreadCount: 0 }); + const { result } = renderHook(() => useMailboxUnread(undefined)); + + act(() => { + result.current.setMailboxUnreadCount(42); + }); + + expect(result.current.mailboxUnreadCount).toBe(42); + }); +}); diff --git a/packages/dashboard/app/hooks/__tests__/useMainPanelTaskDetail.test.ts b/packages/dashboard/app/hooks/__tests__/useMainPanelTaskDetail.test.ts new file mode 100644 index 0000000000..f10dcf33ad --- /dev/null +++ b/packages/dashboard/app/hooks/__tests__/useMainPanelTaskDetail.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useMainPanelTaskDetail } from "../useMainPanelTaskDetail"; + +const task = (id: string) => ({ id, title: id, status: "todo" } as never); + +describe("useMainPanelTaskDetail", () => { + it("setTask accepts both a value and an updater", () => { + const { result } = renderHook(() => useMainPanelTaskDetail()); + + act(() => { + result.current.setTask(task("1")); + }); + expect(result.current.task?.id).toBe("1"); + + act(() => { + result.current.setTask((previous) => (previous ? { ...previous, title: "renamed" } : previous)); + }); + expect(result.current.task?.title).toBe("renamed"); + }); + + it("setInitialTab updates the tab", () => { + const { result } = renderHook(() => useMainPanelTaskDetail()); + + act(() => { + result.current.setInitialTab("changes"); + }); + expect(result.current.initialTab).toBe("changes"); + }); +}); diff --git a/packages/dashboard/app/hooks/__tests__/usePoppedOutTasks.test.ts b/packages/dashboard/app/hooks/__tests__/usePoppedOutTasks.test.ts new file mode 100644 index 0000000000..3cdcd5873e --- /dev/null +++ b/packages/dashboard/app/hooks/__tests__/usePoppedOutTasks.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { usePoppedOutTasks } from "../usePoppedOutTasks"; + +const task = (id: string) => ({ id, title: id, status: "todo" } as never); + +describe("usePoppedOutTasks", () => { + it("popOut adds a task and dedupes by id", () => { + const { result } = renderHook(() => usePoppedOutTasks()); + + act(() => { + result.current.popOut(task("1")); + result.current.popOut(task("1")); + result.current.popOut(task("2")); + }); + + expect(result.current.tasks.map((t) => t.id)).toEqual(["1", "2"]); + }); + + it("close removes only the matching id", () => { + const { result } = renderHook(() => usePoppedOutTasks()); + + act(() => { + result.current.popOut(task("1")); + result.current.popOut(task("2")); + result.current.close("1"); + }); + + expect(result.current.tasks.map((t) => t.id)).toEqual(["2"]); + }); +}); diff --git a/packages/dashboard/app/hooks/__tests__/useScopedDismissFlag.test.ts b/packages/dashboard/app/hooks/__tests__/useScopedDismissFlag.test.ts new file mode 100644 index 0000000000..f093daf079 --- /dev/null +++ b/packages/dashboard/app/hooks/__tests__/useScopedDismissFlag.test.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderHook, act } from "@testing-library/react"; + +vi.mock("../../utils/projectStorage", () => ({ + getScopedItem: vi.fn(() => null), + setScopedItem: vi.fn(), +})); + +import { getScopedItem, setScopedItem } from "../../utils/projectStorage"; +import { useScopedDismissFlag } from "../useScopedDismissFlag"; + +describe("useScopedDismissFlag", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("seeds dismissed from scoped storage on mount", () => { + vi.mocked(getScopedItem).mockReturnValue("true"); + const { result } = renderHook(() => useScopedDismissFlag("key", "p1")); + + expect(result.current.dismissed).toBe(true); + }); + + it("dismiss writes scoped storage and flips the flag", () => { + vi.mocked(getScopedItem).mockReturnValue(null); + const { result } = renderHook(() => useScopedDismissFlag("key", "p1")); + + act(() => { + result.current.dismiss(); + }); + + expect(setScopedItem).toHaveBeenCalledWith("key", "true", "p1"); + expect(result.current.dismissed).toBe(true); + }); + + it("re-reads the scoped value when the project changes (no cross-project leak)", () => { + vi.mocked(getScopedItem).mockReturnValue(null); + const { rerender } = renderHook( + (props: { id: string | undefined }) => useScopedDismissFlag("key", props.id), + { initialProps: { id: "p1" } }, + ); + + rerender({ id: "p2" }); + + // The project-change re-read must consult scoped storage for the new project. + expect(getScopedItem).toHaveBeenCalledWith("key", "p2"); + }); +}); diff --git a/packages/dashboard/app/hooks/__tests__/useStashOrphanCount.test.ts b/packages/dashboard/app/hooks/__tests__/useStashOrphanCount.test.ts new file mode 100644 index 0000000000..95ec89cff2 --- /dev/null +++ b/packages/dashboard/app/hooks/__tests__/useStashOrphanCount.test.ts @@ -0,0 +1,79 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { renderHook, act } from "@testing-library/react"; + +vi.mock("../../api", () => ({ + api: vi.fn(), +})); + +import { api } from "../../api"; +import { useStashOrphanCount } from "../useStashOrphanCount"; + +const mockedApi = vi.mocked(api); + +describe("useStashOrphanCount", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("fetches the orphan count on mount and exposes it", async () => { + mockedApi.mockResolvedValue({ count: 7 }); + const { result } = renderHook(() => useStashOrphanCount(undefined)); + + // Drain the initial load() microtask without tripping the 30s interval. + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); + + expect(result.current.stashOrphanCount).toBe(7); + }); + + it("falls back to 0 when the fetch rejects", async () => { + mockedApi.mockRejectedValue(new Error("boom")); + const { result } = renderHook(() => useStashOrphanCount(undefined)); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); + + expect(result.current.stashOrphanCount).toBe(0); + }); + + it("re-polls on the 30s interval", async () => { + mockedApi.mockResolvedValue({ count: 1 }); + renderHook(() => useStashOrphanCount(undefined)); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); + expect(mockedApi).toHaveBeenCalledTimes(1); + + // Advance exactly one 30s poll tick. + await act(async () => { + await vi.advanceTimersByTimeAsync(30_000); + }); + expect(mockedApi).toHaveBeenCalledTimes(2); + }); + it("stops polling once unmounted", async () => { + mockedApi.mockResolvedValue({ count: 1 }); + const { unmount } = renderHook(() => useStashOrphanCount(undefined)); + + // Initial mount fetch. + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); + expect(mockedApi).toHaveBeenCalledTimes(1); + + unmount(); + + // Advance well past the 30s interval — the cleared timer must not fire. + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000); + }); + expect(mockedApi).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/dashboard/app/hooks/useApprovalBanner.ts b/packages/dashboard/app/hooks/useApprovalBanner.ts new file mode 100644 index 0000000000..4104c0ae65 --- /dev/null +++ b/packages/dashboard/app/hooks/useApprovalBanner.ts @@ -0,0 +1,146 @@ +/* +FNXC:ApprovalBanner 2026-06-24-00:00: +Approval-notification banner dedupe/dismiss state machine, driven by task:updated and approval:requested SSE events. Also fires the first-completed-task GitHub-star prompt and a mailbox-count refresh when a task enters awaiting-approval — preserving the former single-subscriber side effects via the onStarPrompt / onMailboxRefresh callbacks. Extracted from AppInner. + +FNXC:ApprovalBanner 2026-06-24-00:00: +Stale-closure / effect-identity hazard: the per-`tasks` ref-sync effect rebuilds the status + seen-key maps on every tasks change, and the dismissal-timestamp comparison (`updatedAtMs <= dismissedAt`) suppresses re-trigger. Preserve both exactly when touching this hook (see docs/solutions ui-bugs/skill-autocomplete-highlight-reset-on-swr-revalidation and logic-errors/queued-chat-message-flush-trusts-stale-isgenerating). +*/ + +import { useCallback, useEffect, useRef, useState } from "react"; +import type { Task } from "@fusion/core"; +import { subscribeSse } from "../sse-bus"; +import { + type ApprovalBannerCandidate, + didEnterAwaitingApproval, + didEnterDone, + loadApprovalBannerDismissals, + parseDateMs, + persistApprovalBannerDismissals, +} from "../utils/appLifecycle"; + +export interface UseApprovalBannerOptions { + tasks: Task[]; + currentProjectId: string | undefined; + gitHubStarPromptShown: boolean; + /** Invoked when a task first transitions to done (drives the GitHub-star prompt). */ + onStarPrompt: () => void; + /** Invoked when a task enters awaiting-approval (drives a mailbox-count refresh). */ + onMailboxRefresh: () => void; +} + +export interface UseApprovalBannerResult { + candidate: ApprovalBannerCandidate | null; + dismissApproval: (candidate: ApprovalBannerCandidate) => void; +} + +export function useApprovalBanner({ + tasks, + currentProjectId, + gitHubStarPromptShown, + onStarPrompt, + onMailboxRefresh, +}: UseApprovalBannerOptions): UseApprovalBannerResult { + const [candidate, setCandidate] = useState(null); + const taskStatusByIdRef = useRef>(new Map()); + const seenApprovalKeysRef = useRef>(new Set()); + const approvalDismissalsRef = useRef>(loadApprovalBannerDismissals()); + + useEffect(() => { + const next = new Map(); + const nextSeen = new Set(); + for (const task of tasks) { + next.set(task.id, task.status); + if (task.status === "awaiting-approval") { + nextSeen.add(`task:${task.id}`); + } + } + taskStatusByIdRef.current = next; + seenApprovalKeysRef.current = nextSeen; + }, [tasks]); + + useEffect(() => { + const params = new URLSearchParams(); + if (currentProjectId) { + params.set("projectId", currentProjectId); + } + const query = params.size > 0 ? `?${params.toString()}` : ""; + + const triggerApprovalBanner = (next: ApprovalBannerCandidate) => { + const dismissedAt = approvalDismissalsRef.current.get(next.dedupeKey); + if (dismissedAt !== undefined && next.updatedAtMs <= dismissedAt) { + return; + } + setCandidate(next); + }; + + return subscribeSse(`/api/events${query}`, { + events: { + "approval:requested": (event: MessageEvent) => { + try { + const payload = JSON.parse(event.data) as { + id?: string; + taskId?: string; + updatedAt?: string; + createdAt?: string; + }; + const dedupeKey = payload.id ? `approval:${payload.id}` : payload.taskId ? `task:${payload.taskId}` : undefined; + if (!dedupeKey || seenApprovalKeysRef.current.has(dedupeKey)) { + return; + } + seenApprovalKeysRef.current.add(dedupeKey); + triggerApprovalBanner({ + dedupeKey, + updatedAtMs: parseDateMs(payload.updatedAt ?? payload.createdAt), + }); + } catch { + // no-op + } + }, + "task:updated": (event: MessageEvent) => { + try { + const payload = JSON.parse(event.data) as { id?: string; status?: string; updatedAt?: string }; + if (!payload?.id) { + return; + } + const dedupeKey = `task:${payload.id}`; + const previousStatus = taskStatusByIdRef.current.get(payload.id); + taskStatusByIdRef.current.set(payload.id, payload.status); + if (!gitHubStarPromptShown && didEnterDone(payload.status, previousStatus)) { + onStarPrompt(); + } + if (payload.status !== "awaiting-approval") { + seenApprovalKeysRef.current.delete(dedupeKey); + approvalDismissalsRef.current.delete(dedupeKey); + persistApprovalBannerDismissals(approvalDismissalsRef.current); + return; + } + if (seenApprovalKeysRef.current.has(dedupeKey)) { + return; + } + if (didEnterAwaitingApproval(payload.status, previousStatus)) { + seenApprovalKeysRef.current.add(dedupeKey); + triggerApprovalBanner({ + dedupeKey, + updatedAtMs: parseDateMs(payload.updatedAt), + }); + onMailboxRefresh(); + } + } catch { + // no-op + } + }, + }, + }); + }, [currentProjectId, gitHubStarPromptShown, onStarPrompt, onMailboxRefresh]); + + const dismissApproval = useCallback((dismissed: ApprovalBannerCandidate) => { + approvalDismissalsRef.current.set( + dismissed.dedupeKey, + Math.max(Date.now(), dismissed.updatedAtMs), + ); + persistApprovalBannerDismissals(approvalDismissalsRef.current); + setCandidate(null); + }, []); + + return { candidate, dismissApproval }; +} diff --git a/packages/dashboard/app/hooks/useAuthTokenRecovery.ts b/packages/dashboard/app/hooks/useAuthTokenRecovery.ts new file mode 100644 index 0000000000..e799face87 --- /dev/null +++ b/packages/dashboard/app/hooks/useAuthTokenRecovery.ts @@ -0,0 +1,28 @@ +/* +FNXC:AuthTokenRecovery 2026-06-24-00:00: +App-level open state for the auth-token recovery dialog, opened when the daemon signals auth failure (AUTH_TOKEN_RECOVERY_REQUIRED_EVENT). Extracted verbatim from AppInner. +*/ + +import { useEffect, useState } from "react"; +import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } from "../auth"; + +export interface UseAuthTokenRecoveryResult { + open: boolean; +} + +export function useAuthTokenRecovery(): UseAuthTokenRecoveryResult { + const [open, setOpen] = useState(false); + + useEffect(() => { + const handleDaemonAuthFailure = () => { + setOpen(true); + }; + + window.addEventListener(AUTH_TOKEN_RECOVERY_REQUIRED_EVENT, handleDaemonAuthFailure); + return () => { + window.removeEventListener(AUTH_TOKEN_RECOVERY_REQUIRED_EVENT, handleDaemonAuthFailure); + }; + }, []); + + return { open }; +} diff --git a/packages/dashboard/app/hooks/useBoardScrollRestore.ts b/packages/dashboard/app/hooks/useBoardScrollRestore.ts new file mode 100644 index 0000000000..903ba47b52 --- /dev/null +++ b/packages/dashboard/app/hooks/useBoardScrollRestore.ts @@ -0,0 +1,57 @@ +/* +FNXC:BoardNavigation 2026-06-24-00:00: +Preserves horizontal board scroll and per-column vertical scroll across a board → task-detail → back-to-board round trip. capture() snapshots before opening detail; requestRestore() schedules a restore that fires (double requestAnimationFrame, after the board remounts) once the view returns to "board". Extracted from AppInner. +*/ + +import { useCallback, useEffect, useRef } from "react"; +import { + captureBoardScrollSnapshot, + restoreBoardScrollSnapshot, + type BoardScrollSnapshot, +} from "../utils/boardScrollSnapshot"; +import type { TaskView } from "./useViewState"; + +export interface UseBoardScrollRestoreResult { + capture: () => void; + requestRestore: () => void; +} + +export function useBoardScrollRestore(taskView: TaskView): UseBoardScrollRestoreResult { + const boardScrollSnapshotRef = useRef(null); + const pendingBoardScrollRestoreRef = useRef(false); + + const restore = useCallback(() => { + if (restoreBoardScrollSnapshot(boardScrollSnapshotRef.current)) { + pendingBoardScrollRestoreRef.current = false; + } + }, []); + + const capture = useCallback(() => { + boardScrollSnapshotRef.current = captureBoardScrollSnapshot(); + }, []); + + const requestRestore = useCallback(() => { + pendingBoardScrollRestoreRef.current = true; + }, []); + + useEffect(() => { + if (taskView !== "board" || !pendingBoardScrollRestoreRef.current) return; + const scheduleFrame = typeof window.requestAnimationFrame === "function" + ? window.requestAnimationFrame.bind(window) + : ((callback: FrameRequestCallback) => window.setTimeout(() => callback(performance.now()), 0)); + const cancelFrame = typeof window.cancelAnimationFrame === "function" + ? window.cancelAnimationFrame.bind(window) + : window.clearTimeout.bind(window); + let firstFrame = 0; + let secondFrame = 0; + firstFrame = scheduleFrame(() => { + secondFrame = scheduleFrame(restore); + }); + return () => { + cancelFrame(firstFrame); + cancelFrame(secondFrame); + }; + }, [restore, taskView]); + + return { capture, requestRestore }; +} diff --git a/packages/dashboard/app/hooks/useBranchTaskFilters.ts b/packages/dashboard/app/hooks/useBranchTaskFilters.ts new file mode 100644 index 0000000000..8454e3c1e1 --- /dev/null +++ b/packages/dashboard/app/hooks/useBranchTaskFilters.ts @@ -0,0 +1,103 @@ +/* +FNXC:BoardFilters 2026-06-24-00:00: +Working/base branch filters for the board, persisted per-project via scoped storage, plus the derived branch-option lists and the filtered task set (including the NO_BRANCH_FILTER_VALUE "no branch" sentinel that excludes tasks which have a branch). Extracted from AppInner. +*/ + +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { Task } from "@fusion/core"; +import { getScopedItem, setScopedItem } from "../utils/projectStorage"; +import { + BASE_BRANCH_FILTER_STORAGE_KEY, + NO_BRANCH_FILTER_VALUE, + WORKING_BRANCH_FILTER_STORAGE_KEY, +} from "../utils/appLifecycle"; + +export interface UseBranchTaskFiltersOptions { + boardSourceTasks: Task[]; + currentProjectId: string | undefined; +} + +export interface UseBranchTaskFiltersResult { + branchFilter: string; + baseBranchFilter: string; + branchOptions: string[]; + baseBranchOptions: string[]; + filteredBoardTasks: Task[]; + onBranchFilterChange: (value: string) => void; + onBaseBranchFilterChange: (value: string) => void; +} + +export function useBranchTaskFilters({ + boardSourceTasks, + currentProjectId, +}: UseBranchTaskFiltersOptions): UseBranchTaskFiltersResult { + const [branchFilter, setBranchFilter] = useState(""); + const [baseBranchFilter, setBaseBranchFilter] = useState(""); + + useEffect(() => { + setBranchFilter(getScopedItem(WORKING_BRANCH_FILTER_STORAGE_KEY, currentProjectId) ?? ""); + setBaseBranchFilter(getScopedItem(BASE_BRANCH_FILTER_STORAGE_KEY, currentProjectId) ?? ""); + }, [currentProjectId]); + + const onBranchFilterChange = useCallback((value: string) => { + setBranchFilter(value); + setScopedItem(WORKING_BRANCH_FILTER_STORAGE_KEY, value, currentProjectId); + }, [currentProjectId]); + + const onBaseBranchFilterChange = useCallback((value: string) => { + setBaseBranchFilter(value); + setScopedItem(BASE_BRANCH_FILTER_STORAGE_KEY, value, currentProjectId); + }, [currentProjectId]); + + const branchOptions = useMemo(() => { + return Array.from( + new Set( + boardSourceTasks + .map((task) => task.branch?.trim()) + .filter((branch): branch is string => Boolean(branch && branch.length > 0)), + ), + ).sort((a, b) => a.localeCompare(b)); + }, [boardSourceTasks]); + + const baseBranchOptions = useMemo(() => { + return Array.from( + new Set( + boardSourceTasks + .map((task) => task.baseBranch?.trim()) + .filter((baseBranch): baseBranch is string => Boolean(baseBranch && baseBranch.length > 0)), + ), + ).sort((a, b) => a.localeCompare(b)); + }, [boardSourceTasks]); + + const filteredBoardTasks = useMemo(() => { + return boardSourceTasks.filter((task) => { + const taskBranch = task.branch?.trim() ?? ""; + const taskBaseBranch = task.baseBranch?.trim() ?? ""; + if (branchFilter === NO_BRANCH_FILTER_VALUE) { + if (taskBranch.length > 0) { + return false; + } + } else if (branchFilter.length > 0 && taskBranch !== branchFilter) { + return false; + } + if (baseBranchFilter === NO_BRANCH_FILTER_VALUE) { + if (taskBaseBranch.length > 0) { + return false; + } + } else if (baseBranchFilter.length > 0 && taskBaseBranch !== baseBranchFilter) { + return false; + } + return true; + }); + }, [boardSourceTasks, branchFilter, baseBranchFilter]); + + return { + branchFilter, + baseBranchFilter, + branchOptions, + baseBranchOptions, + filteredBoardTasks, + onBranchFilterChange, + onBaseBranchFilterChange, + }; +} diff --git a/packages/dashboard/app/hooks/useCapacityRiskBanner.ts b/packages/dashboard/app/hooks/useCapacityRiskBanner.ts new file mode 100644 index 0000000000..dc6fe7ebc4 --- /dev/null +++ b/packages/dashboard/app/hooks/useCapacityRiskBanner.ts @@ -0,0 +1,99 @@ +/* +FNXC:CapacityRisk 2026-06-24-00:00: +Capacity-risk banner signal + per-project dismiss, with a settings-hydrate guard so the banner doesn't flash on first load or on project change, and a re-enable-clears-dismissal behavior (re-enabling the banner or changing the threshold resurrects a previously-dismissed banner). Extracted from AppInner. +*/ + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + computeCapacityRisk, + DEFAULT_CAPACITY_RISK_TODO_THRESHOLD, + type CapacityRiskSignal, +} from "@fusion/core"; +import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage"; +import { CAPACITY_RISK_DISMISSED_KEY } from "../utils/appLifecycle"; + +export interface UseCapacityRiskBannerOptions { + agentStats: { todoTaskCount?: number; idleNonEphemeralCount?: number } | null | undefined; + inProgressCount: number; + inReviewCount: number; + capacityRiskBannerEnabled: boolean | undefined; + capacityRiskTodoThreshold: number | undefined; + settingsLoaded: boolean; + currentProjectId: string | undefined; +} + +export interface UseCapacityRiskBannerResult { + signal: CapacityRiskSignal; + dismissed: boolean; + dismiss: () => void; +} + +export function useCapacityRiskBanner({ + agentStats, + inProgressCount, + inReviewCount, + capacityRiskBannerEnabled, + capacityRiskTodoThreshold, + settingsLoaded, + currentProjectId, +}: UseCapacityRiskBannerOptions): UseCapacityRiskBannerResult { + const [dismissed, setDismissed] = useState( + () => getScopedItem(CAPACITY_RISK_DISMISSED_KEY, currentProjectId) === "true", + ); + + useEffect(() => { + setDismissed(getScopedItem(CAPACITY_RISK_DISMISSED_KEY, currentProjectId) === "true"); + }, [currentProjectId]); + + const signal = useMemo( + () => + computeCapacityRisk({ + todoCount: agentStats?.todoTaskCount ?? 0, + inProgressCount, + inReviewCount, + idleNonEphemeralAgentCount: agentStats?.idleNonEphemeralCount ?? 0, + threshold: capacityRiskTodoThreshold ?? DEFAULT_CAPACITY_RISK_TODO_THRESHOLD, + }), + [agentStats?.todoTaskCount, agentStats?.idleNonEphemeralCount, inProgressCount, inReviewCount, capacityRiskTodoThreshold], + ); + + const previousBannerEnabledRef = useRef(capacityRiskBannerEnabled); + const previousThresholdRef = useRef(capacityRiskTodoThreshold); + const previousProjectIdRef = useRef(currentProjectId); + const settingsHydratedRef = useRef(false); + + useEffect(() => { + if (!settingsLoaded) { + return; + } + + if (!settingsHydratedRef.current || previousProjectIdRef.current !== currentProjectId) { + settingsHydratedRef.current = true; + previousProjectIdRef.current = currentProjectId; + previousBannerEnabledRef.current = capacityRiskBannerEnabled; + previousThresholdRef.current = capacityRiskTodoThreshold; + return; + } + + const wasEnabled = previousBannerEnabledRef.current; + const previousThreshold = previousThresholdRef.current; + const bannerEnabledChangedToTrue = !wasEnabled && capacityRiskBannerEnabled; + const thresholdChanged = previousThreshold !== capacityRiskTodoThreshold; + + if (bannerEnabledChangedToTrue || thresholdChanged) { + removeScopedItem(CAPACITY_RISK_DISMISSED_KEY, currentProjectId); + setDismissed(false); + } + + previousProjectIdRef.current = currentProjectId; + previousBannerEnabledRef.current = capacityRiskBannerEnabled; + previousThresholdRef.current = capacityRiskTodoThreshold; + }, [settingsLoaded, capacityRiskBannerEnabled, capacityRiskTodoThreshold, currentProjectId]); + + const dismiss = useCallback(() => { + setScopedItem(CAPACITY_RISK_DISMISSED_KEY, "true", currentProjectId); + setDismissed(true); + }, [currentProjectId]); + + return { signal, dismissed, dismiss }; +} diff --git a/packages/dashboard/app/hooks/useChatUnreadBadge.ts b/packages/dashboard/app/hooks/useChatUnreadBadge.ts new file mode 100644 index 0000000000..e988c3ccc1 --- /dev/null +++ b/packages/dashboard/app/hooks/useChatUnreadBadge.ts @@ -0,0 +1,68 @@ +/* +FNXC:ChatBadge 2026-06-24-00:00: +Header/mobile-nav unread indicator for assistant chat responses. Set when an assistant message arrives over SSE while the user is not viewing chat, and cleared when the chat view (or quick-chat window) opens. Extracted verbatim from AppInner. +*/ + +import { useEffect, useState } from "react"; +import type { ChatRoomMessage } from "@fusion/core"; +import { subscribeSse } from "../sse-bus"; +import type { TaskView } from "./useViewState"; + +export interface UseChatUnreadBadgeOptions { + taskView: TaskView; + quickChatOpen: boolean; +} + +export interface UseChatUnreadBadgeResult { + chatHasUnreadResponse: boolean; +} + +export function useChatUnreadBadge( + currentProjectId: string | undefined, + { taskView, quickChatOpen }: UseChatUnreadBadgeOptions, +): UseChatUnreadBadgeResult { + const [chatHasUnreadResponse, setChatHasUnreadResponse] = useState(false); + + useEffect(() => { + if (taskView === "chat" || quickChatOpen) { + setChatHasUnreadResponse(false); + } + }, [quickChatOpen, taskView]); + + useEffect(() => { + const params = new URLSearchParams(); + if (currentProjectId) { + params.set("projectId", currentProjectId); + } + const query = params.size > 0 ? `?${params.toString()}` : ""; + + return subscribeSse(`/api/events${query}`, { + events: { + "chat:message:added": (event: MessageEvent) => { + try { + const payload = JSON.parse(event.data) as { role?: string; projectId?: string | null }; + if (payload.role !== "assistant") return; + if (taskView === "chat" || quickChatOpen) return; + if (payload.projectId && currentProjectId && payload.projectId !== currentProjectId) return; + setChatHasUnreadResponse(true); + } catch { + // no-op + } + }, + "chat:room:message:added": (event: MessageEvent) => { + try { + const payload = JSON.parse(event.data) as ChatRoomMessage & { projectId?: string | null }; + if (payload.role === "user") return; + if (taskView === "chat" || quickChatOpen) return; + if (payload.projectId && currentProjectId && payload.projectId !== currentProjectId) return; + setChatHasUnreadResponse(true); + } catch { + // no-op + } + }, + }, + }); + }, [currentProjectId, quickChatOpen, taskView]); + + return { chatHasUnreadResponse }; +} diff --git a/packages/dashboard/app/hooks/useDashboardHealth.ts b/packages/dashboard/app/hooks/useDashboardHealth.ts new file mode 100644 index 0000000000..5cbf75b73d --- /dev/null +++ b/packages/dashboard/app/hooks/useDashboardHealth.ts @@ -0,0 +1,57 @@ +/* +FNXC:DashboardHealth 2026-06-24-00:00: +Dashboard backend health (engine availability, task-id integrity, db-corruption status), fetched on mount and refreshable on demand. Extracted from AppInner; exposes setHealth so the TaskIdIntegrityBanner can patch the cached health from its own remediation callback. +*/ + +import { useCallback, useEffect, useState, type Dispatch, type SetStateAction } from "react"; +import type { DashboardHealthResponse } from "../api"; +import { fetchDashboardHealth, refreshDashboardHealth } from "../api"; + +export interface UseDashboardHealthResult { + health: DashboardHealthResponse | null; + setHealth: Dispatch>; + refreshing: boolean; + refreshError: string | null; + refresh: () => Promise; +} + +export function useDashboardHealth(): UseDashboardHealthResult { + const [health, setHealth] = useState(null); + const [refreshing, setRefreshing] = useState(false); + const [refreshError, setRefreshError] = useState(null); + + const refresh = useCallback(async () => { + setRefreshing(true); + setRefreshError(null); + try { + const next = await refreshDashboardHealth(); + setHealth(next); + } catch (error) { + setRefreshError(error instanceof Error ? error.message : "Failed to refresh database health."); + } finally { + setRefreshing(false); + } + }, []); + + useEffect(() => { + let cancelled = false; + + fetchDashboardHealth() + .then((next) => { + if (!cancelled) { + setHealth(next); + } + }) + .catch(() => { + if (!cancelled) { + setHealth(null); + } + }); + + return () => { + cancelled = true; + }; + }, []); + + return { health, setHealth, refreshing, refreshError, refresh }; +} diff --git a/packages/dashboard/app/hooks/useMailboxUnread.ts b/packages/dashboard/app/hooks/useMailboxUnread.ts new file mode 100644 index 0000000000..b4421b7ddb --- /dev/null +++ b/packages/dashboard/app/hooks/useMailboxUnread.ts @@ -0,0 +1,56 @@ +/* +FNXC:MailboxBadge 2026-06-24-00:00: +Header/mobile-nav unread + pending-approval counts for the mailbox, refreshed on message and approval SSE events. Extracted from AppInner; exposes `refresh` (so the approval-banner hook can re-fetch counts when a task enters awaiting-approval, preserving the former single-subscriber side effect) and `setMailboxUnreadCount` (MailboxView reports its own count changes through onUnreadCountChange). +*/ + +import { useCallback, useEffect, useState } from "react"; +import { fetchUnreadCount } from "../api"; +import { subscribeSse } from "../sse-bus"; + +export interface UseMailboxUnreadResult { + mailboxUnreadCount: number; + mailboxPendingApprovalCount: number; + setMailboxUnreadCount: (count: number) => void; + refresh: () => void; +} + +export function useMailboxUnread(currentProjectId: string | undefined): UseMailboxUnreadResult { + const [mailboxUnreadCount, setMailboxUnreadCount] = useState(0); + const [mailboxPendingApprovalCount, setMailboxPendingApprovalCount] = useState(0); + + const refresh = useCallback(() => { + fetchUnreadCount(currentProjectId) + .then((data: { unreadCount: number; pendingApprovalCount?: number }) => { + setMailboxUnreadCount(data.unreadCount); + setMailboxPendingApprovalCount(data.pendingApprovalCount ?? 0); + }) + .catch((err) => { + console.warn("[App] Failed to fetch mailbox unread count:", err); + }); + }, [currentProjectId]); + + useEffect(() => { + refresh(); + + const params = new URLSearchParams(); + if (currentProjectId) { + params.set("projectId", currentProjectId); + } + const query = params.size > 0 ? `?${params.toString()}` : ""; + + return subscribeSse(`/api/events${query}`, { + onReconnect: refresh, + events: { + "message:sent": refresh, + "message:received": refresh, + "message:read": refresh, + "message:deleted": refresh, + "approval:requested": refresh, + "approval:updated": refresh, + "approval:decided": refresh, + }, + }); + }, [currentProjectId, refresh]); + + return { mailboxUnreadCount, mailboxPendingApprovalCount, setMailboxUnreadCount, refresh }; +} diff --git a/packages/dashboard/app/hooks/useMainPanelTaskDetail.ts b/packages/dashboard/app/hooks/useMainPanelTaskDetail.ts new file mode 100644 index 0000000000..df4b5c367e --- /dev/null +++ b/packages/dashboard/app/hooks/useMainPanelTaskDetail.ts @@ -0,0 +1,22 @@ +/* +FNXC:TaskDetail 2026-06-24-00:00: +Snapshot of the task whose detail is shown in the main panel (Board card click → full-panel detail), plus its initial tab. Kept as a snapshot so the view survives a tasks revalidation. Exposes the setters so App can compose open/close with view navigation, and so the embedded detail can patch the snapshot on task updates (setTask accepts the updater form). Extracted from AppInner. +*/ + +import { useState, type Dispatch, type SetStateAction } from "react"; +import type { Task, TaskDetail } from "@fusion/core"; +import type { DetailTaskTab } from "./useModalManager"; + +export interface UseMainPanelTaskDetailResult { + task: Task | TaskDetail | null; + initialTab: DetailTaskTab; + setTask: Dispatch>; + setInitialTab: (tab: DetailTaskTab) => void; +} + +export function useMainPanelTaskDetail(): UseMainPanelTaskDetailResult { + const [task, setTask] = useState(null); + const [initialTab, setInitialTab] = useState("chat"); + + return { task, initialTab, setTask, setInitialTab }; +} diff --git a/packages/dashboard/app/hooks/usePoppedOutTasks.ts b/packages/dashboard/app/hooks/usePoppedOutTasks.ts new file mode 100644 index 0000000000..8ee1be198b --- /dev/null +++ b/packages/dashboard/app/hooks/usePoppedOutTasks.ts @@ -0,0 +1,27 @@ +/* +FNXC:FloatingWindow 2026-06-24-00:00: +Popped-out task-detail windows — movable, resizable, non-blocking FloatingWindows. Each entry is a task snapshot; several can be open at once. Snapshots survive a tasks revalidation (rendering prefers the live row by id). Pop-out dedupes by task id. Extracted from AppInner. +*/ + +import { useCallback, useState } from "react"; +import type { Task, TaskDetail } from "@fusion/core"; + +export interface UsePoppedOutTasksResult { + tasks: Array; + popOut: (task: Task | TaskDetail) => void; + close: (taskId: string) => void; +} + +export function usePoppedOutTasks(): UsePoppedOutTasksResult { + const [tasks, setTasks] = useState>([]); + + const popOut = useCallback((task: Task | TaskDetail) => { + setTasks((current) => (current.some((entry) => entry.id === task.id) ? current : [...current, task])); + }, []); + + const close = useCallback((taskId: string) => { + setTasks((current) => current.filter((entry) => entry.id !== taskId)); + }, []); + + return { tasks, popOut, close }; +} diff --git a/packages/dashboard/app/hooks/useScopedDismissFlag.ts b/packages/dashboard/app/hooks/useScopedDismissFlag.ts new file mode 100644 index 0000000000..b7bbc444be --- /dev/null +++ b/packages/dashboard/app/hooks/useScopedDismissFlag.ts @@ -0,0 +1,32 @@ +/* +FNXC:ScopedDismissFlag 2026-06-24-00:00: +A per-project dismissable boolean banner flag (e.g. setup-warning, capacity-risk) backed by scoped storage. Owns the initial scoped read, the project-change re-read (so a dismissal in one project does not leak into another), and the dismiss action. Extracted from AppInner. +*/ + +import { useCallback, useEffect, useState } from "react"; +import { getScopedItem, setScopedItem } from "../utils/projectStorage"; + +export interface UseScopedDismissFlagResult { + dismissed: boolean; + dismiss: () => void; +} + +export function useScopedDismissFlag( + storageKey: string, + currentProjectId: string | undefined, +): UseScopedDismissFlagResult { + const [dismissed, setDismissed] = useState( + () => getScopedItem(storageKey, currentProjectId) === "true", + ); + + useEffect(() => { + setDismissed(getScopedItem(storageKey, currentProjectId) === "true"); + }, [storageKey, currentProjectId]); + + const dismiss = useCallback(() => { + setScopedItem(storageKey, "true", currentProjectId); + setDismissed(true); + }, [storageKey, currentProjectId]); + + return { dismissed, dismiss }; +} diff --git a/packages/dashboard/app/hooks/useStashOrphanCount.ts b/packages/dashboard/app/hooks/useStashOrphanCount.ts new file mode 100644 index 0000000000..74ceb8c180 --- /dev/null +++ b/packages/dashboard/app/hooks/useStashOrphanCount.ts @@ -0,0 +1,37 @@ +/* +FNXC:StashRecovery 2026-06-24-00:00: +App-level count of orphaned stash-recovery entries, polled every 30s and surfaced as a header/mobile-nav badge. Extracted verbatim from AppInner so the root component no longer owns the polling loop. +*/ + +import { useEffect, useState } from "react"; +import { api } from "../api"; + +export interface UseStashOrphanCountResult { + stashOrphanCount: number; +} + +const POLL_INTERVAL_MS = 30000; + +export function useStashOrphanCount(currentProjectId: string | undefined): UseStashOrphanCountResult { + const [stashOrphanCount, setStashOrphanCount] = useState(0); + + useEffect(() => { + let cancelled = false; + const load = async () => { + try { + const data = await api<{ count: number }>("/stash-recovery/orphans"); + if (!cancelled) setStashOrphanCount(data.count ?? 0); + } catch { + if (!cancelled) setStashOrphanCount(0); + } + }; + void load(); + const timer = window.setInterval(() => void load(), POLL_INTERVAL_MS); + return () => { + cancelled = true; + window.clearInterval(timer); + }; + }, [currentProjectId]); + + return { stashOrphanCount }; +} diff --git a/packages/dashboard/app/utils/appLifecycle.ts b/packages/dashboard/app/utils/appLifecycle.ts new file mode 100644 index 0000000000..0855df94cb --- /dev/null +++ b/packages/dashboard/app/utils/appLifecycle.ts @@ -0,0 +1,177 @@ +/* +FNXC:AppLifecycle 2026-06-24-00:00: +Module-level lifecycle helpers, storage-key constants, and banner/CLI-banner pure functions extracted out of App.tsx so the root component stays an orchestrator. Behavior is byte-identical to the former inline definitions; App.tsx re-exports the unit-tested symbols to preserve its import contract. +*/ + +import type { AiSessionSummary } from "../api"; +import { api, relaunchCliSession } from "../api"; +import type { CliActionId } from "../components/SessionNotificationBanner"; + +export const SETUP_WARNING_DISMISSED_KEY = "kb-setup-warning-dismissed"; +export const WORKING_BRANCH_FILTER_STORAGE_KEY = "kb-dashboard-working-branch-filter"; +export const BASE_BRANCH_FILTER_STORAGE_KEY = "kb-dashboard-base-branch-filter"; +export const NO_BRANCH_FILTER_VALUE = "__fusion:no-branch__"; +export const APPROVAL_BANNER_DISMISSED_STORAGE_KEY = "fusion:approval-banner-dismissed"; +export const CAPACITY_RISK_DISMISSED_KEY = "kb-capacity-risk-banner-dismissed"; +export const RETRY_WARNING_RATIO = 0.8; + +export interface ApprovalBannerCandidate { + dedupeKey: string; + updatedAtMs: number; +} + +export function didEnterAwaitingApproval(nextStatus: string | undefined, previousStatus: string | undefined): boolean { + return nextStatus === "awaiting-approval" && previousStatus !== "awaiting-approval"; +} + +export function didEnterDone(nextStatus: string | undefined, previousStatus: string | undefined): boolean { + return nextStatus === "done" && previousStatus !== undefined && previousStatus !== "done"; +} + +export function parseDateMs(value: string | undefined): number { + if (!value) return 0; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +export function loadApprovalBannerDismissals(): Map { + if (typeof window === "undefined") return new Map(); + try { + const raw = window.localStorage.getItem(APPROVAL_BANNER_DISMISSED_STORAGE_KEY); + if (!raw) return new Map(); + const parsed = JSON.parse(raw) as Record; + const map = new Map(); + for (const [key, value] of Object.entries(parsed)) { + if (typeof value === "number" && Number.isFinite(value)) { + map.set(key, value); + } + } + return map; + } catch { + return new Map(); + } +} + +export function persistApprovalBannerDismissals(map: Map): void { + if (typeof window === "undefined") return; + try { + const data: Record = {}; + for (const [key, value] of map) { + data[key] = value; + } + window.localStorage.setItem(APPROVAL_BANNER_DISMISSED_STORAGE_KEY, JSON.stringify(data)); + } catch { + // no-op + } +} + +export function buildRemoteDashboardUrl(serverUrl: string, authToken?: string | null): string { + const url = new URL(serverUrl); + if (authToken) { + url.searchParams.set("rt", authToken); + } + return url.toString(); +} + +export function requiresNativeShellOnboarding( + shellState: { host: "web" | "mobile-shell" | "desktop-shell"; desktopMode?: "local" | "remote"; activeProfileId: string | null }, + shellReady: boolean, + shellOnboardingComplete: boolean, +): boolean { + if (!shellReady || shellOnboardingComplete || shellState.host === "web") { + return false; + } + + if (shellState.host === "mobile-shell") { + return !shellState.activeProfileId; + } + + if (shellState.desktopMode === "local") { + return false; + } + + return !shellState.activeProfileId; +} + +export function shouldShowFirstEverBootLoader(projectsLoading: boolean, projectCount: number): boolean { + return projectsLoading && projectCount === 0; +} + +export function isSessionNeedingInputForBanner(session: AiSessionSummary): boolean { + return ( + session.status === "awaiting_input" || + session.status === "error" || + session.status === "waiting_on_input" || + session.status === "needs_attention" + ); +} + +export function getCliActionDisabledReasonForBanner(session: AiSessionSummary, action: CliActionId): string | null { + if ((action === "advance" || action === "relaunch") && !session.cliSessionId) { + return "CLI session id is missing."; + } + return null; +} + +export interface CliActionDeps { + currentProjectId?: string; + retryTask: (id: string) => Promise; + moveTask: (id: string, column: "todo") => Promise; + openAuthenticationSettings: () => void; + addToast: (message: string, type: "success" | "error") => void; + apiClient?: typeof api; + relaunchCliSessionClient?: typeof relaunchCliSession; +} + +export async function executeCliSessionBannerAction( + session: AiSessionSummary, + action: CliActionId, + deps: CliActionDeps, +): Promise { + try { + /* + * FNXC:SessionBanner 2026-06-14-19:32: + * CLI banner verbs must either call an existing dashboard route/flow or be disabled by the banner. `advance` confirms the CLI session, `retry` and `cancel` reuse task operations keyed by the session id until summaries expose a distinct task id, and `reauthenticate` opens the existing authentication settings flow. + * + * FNXC:SessionBanner 2026-06-14-20:16: + * `relaunch` is now a supported route-backed action for resume-exhausted CLI sessions; if `cliSessionId` is absent the handler exits without firing a malformed API call, preserving the no-silent-no-op invariant through the banner disabled reason. + */ + if (action === "advance") { + if (!session.cliSessionId) { + throw new Error("CLI session id is required to advance this session."); + } + await (deps.apiClient ?? api)(`/cli-sessions/${encodeURIComponent(session.cliSessionId)}/confirm-advance`, { + method: "POST", + body: JSON.stringify({ decision: "advance", ...(deps.currentProjectId ? { projectId: deps.currentProjectId } : {}) }), + }); + return; + } + + if (action === "relaunch") { + if (!session.cliSessionId) return; + await (deps.relaunchCliSessionClient ?? relaunchCliSession)(session.cliSessionId, deps.currentProjectId); + deps.addToast("CLI session relaunch requested", "success"); + return; + } + + if (action === "retry") { + await deps.retryTask(session.id); + return; + } + + if (action === "cancel") { + await deps.moveTask(session.id, "todo"); + return; + } + + if (action === "reauthenticate") { + deps.openAuthenticationSettings(); + return; + } + + throw new Error("This CLI action is not supported yet."); + } catch (err) { + const message = err instanceof Error ? err.message : "CLI action failed"; + deps.addToast(message, "error"); + } +} diff --git a/scripts/line-count-baseline.json b/scripts/line-count-baseline.json index 8f81f933be..992b32b3f4 100644 --- a/scripts/line-count-baseline.json +++ b/scripts/line-count-baseline.json @@ -19,7 +19,6 @@ "packages/core/src/mission-store.ts": 4382, "packages/core/src/store.ts": 16939, "packages/core/src/types.ts": 7269, - "packages/dashboard/app/App.tsx": 2729, "packages/dashboard/app/api/legacy.ts": 10742, "packages/dashboard/app/components/AgentDetailView.tsx": 5400, "packages/dashboard/app/components/AgentsView.tsx": 2109,