refactor(dashboard): break App.tsx into smaller modules (#1740)

## Summary

Behavior-preserving decomposition of the dashboard root
`packages/dashboard/app/App.tsx` — a 2,729-line monolith (~2,350-line
single `AppInner` component) — into focused hooks, a pure-helpers util,
and presentational components. **No functional change.**

**`App.tsx`: 2,729 → 1,636 lines**, now under the 2,000-line file-count
ratchet and graduated off its grandfathered baseline (can never regress
above 2,000).

## What was extracted
- **Pure helpers + constants** → `utils/appLifecycle.ts` (re-exported
from `App` so the 7 unit-tested symbols keep their `from "../../App"`
import contract).
- **12 custom hooks** under `app/hooks/`: `useMailboxUnread`,
`useChatUnreadBadge`, `useStashOrphanCount`, `useApprovalBanner`,
`useBranchTaskFilters`, `useDashboardHealth`, `useAuthTokenRecovery`,
`useScopedDismissFlag`, `useCapacityRiskBanner`,
`useMainPanelTaskDetail`, `useBoardScrollRestore`, `usePoppedOutTasks`.
- **2 presentational components** under `app/components/dashboard/`:
`MainContent` (the 647-line view-switch dispatcher) and
`DashboardBanners`, with typed prop interfaces in `types.ts`.

## Key design decisions
- **KTD4 — single `task:updated` subscriber preserved.** The original
one `/api/events` subscriber (mailbox counts + approval banner +
GitHub-star + awaiting-approval refresh) was split: `useApprovalBanner`
owns `task:updated`/`approval:requested` (banner + star +
`onMailboxRefresh` callback); `useMailboxUnread` owns
`message:*`/`approval:*` count refresh. `subscribeSse` multiplexes onto
one shared `EventSource`, so no extra connections.
- **The 18 lazy view declarations stay in `App.tsx`** (the
`lazy-loaded-views-docs.test.ts` guard regex-scans `App.tsx`) and are
threaded to `MainContent` as `LazyExoticComponent` props.
- **Eager `ChatView.css` import stays at the `App.tsx` top level** (FOUC
prevention).

## Verification
- `pnpm lint` (full repo), `typecheck` (both passes), `pnpm build` — all
clean.
- `App.test.tsx` (the full-render behavior contract) **identical**
pre/post: 123 pass / 5 fail, where the 5 are **pre-existing**
experimental-flag failures verified against unmodified `App.tsx`.
- `lazy-loaded-views-docs.test.ts` passes.
- 30+ new `renderHook` tests, incl. a `sseSplitIntegration` test
co-mounting both SSE hooks to pin the split (no double-fire;
awaiting-approval refresh exactly once).
- `App.tsx` graduated off the line-count ratchet baseline (scoped — only
its entry removed).

## Notes
- `useShellOnboarding` was deferred — the shell region is more
intertwined than planned (onboarding + connection-status + local/remote
redirects).
- The browser/visual smoke wasn't run (requires the server stack);
coverage rests on the green gate + fresh build + `App.test.tsx`
rendering the real `<App/>`.

Plan:
`docs/plans/2026-06-24-001-refactor-dashboard-app-tsx-module-breakup-plan.md`
(status: completed).

<!-- stage-review-badge-begin -->

---

<a href="https://stagereview.app/Runfusion/Fusion/pull/1740">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://stagereview.app/assets/gh-open-in-stage-dark.svg">
<img src="https://stagereview.app/assets/gh-open-in-stage-light.svg"
alt="Open in Stage">
  </picture>
</a>

<!-- stage-review-badge-end -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Refactor**
* Split dashboard main content and banner rendering into dedicated
components and presenter-style logic.
* Moved unread counters, approval/notification banners, dashboard health
refresh, scroll restoration, task detail flow, branch filtering, and
per-project dismissals into dedicated hooks.
* Centralized dashboard lifecycle helpers to keep banner/session
behavior consistent.
* **Documentation**
* Added a plan describing the behavior-preserving dashboard module
breakup.
* **Tests**
* Added/expanded hook and integration coverage for unread badges,
approval banners, scroll restore, branch filtering, dismissals, health
refresh, and main-panel task detail behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
gsxdsm
2026-06-24 20:43:41 -07:00
committed by GitHub
33 changed files with 3823 additions and 1362 deletions

View File

@@ -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:<Area>` 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 `<App/>`, 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 `<PageErrorBoundary>`/`<Suspense>` 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 `<App/>`.
- `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.

File diff suppressed because it is too large Load Diff

View File

@@ -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 && (
<>
<TestModeBanner isActive={isTestMode} />
<EngineUnavailableBanner isVisible={dashboardHealth?.engine?.available === false} />
<OAuthReloginBanner
onReLogin={(_providerId) => openSettingsWithNav("authentication" as SectionId)}
/>
</>
)}
{viewMode === "project" && currentProject && taskView !== "missions" && !modalManager.isPlanningOpen && !sessionBannersHidden && (
<SessionNotificationBanner
sessions={sessionsNeedingInput}
onResumeSession={handleOpenBackgroundSession}
onDismissSession={handleDismissNeedingInputSession}
onDismissAll={handleDismissAllNeedingInputSessions}
onCliAction={handleCliAction}
getCliActionDisabledReason={getCliActionDisabledReasonForBanner}
/>
)}
{viewMode === "project" && currentProject && (
<CliBinaryInstallBanner
onOpenSettings={() => openSettingsWithNav("general" as SectionId)}
/>
)}
{viewMode === "project" && currentProject && showOnboardingResumeCard && (
<OnboardingResumeCard onResume={modalManager.openModelOnboarding} />
)}
{viewMode === "project" && currentProject && showPostOnboardingRecommendations && (
<PostOnboardingRecommendations
onOpenModelOnboarding={modalManager.openModelOnboarding}
onOpenSettings={(section) => openSettingsWithNav(section as SectionId)}
/>
)}
{viewMode === "project" && currentProject && updateAvailable && latestVersion && currentVersion && !updateBannerDismissed && (
<UpdateAvailableBanner
latestVersion={latestVersion}
currentVersion={currentVersion}
onDismiss={dismissUpdateBanner}
/>
)}
{viewMode === "project" && currentProject && (
<MergeAdvanceNotice projectId={currentProject.id} />
)}
{viewMode === "project" && currentProject && dashboardHealth?.taskIdIntegrity?.status === "anomaly" && dashboardHealth.taskIdIntegrity.recommendedAction && (
<TaskIdIntegrityBanner
report={dashboardHealth.taskIdIntegrity}
recommendedAction={dashboardHealth.taskIdIntegrity.recommendedAction}
onRefresh={(report, 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 && (
<DbCorruptionBanner
errors={dashboardHealth.database.corruptionErrors}
lastCheckedAt={dashboardHealth.database.lastCheckedAt}
onRefresh={refreshDbCorruptionHealth}
refreshing={dbCorruptionRefreshing}
refreshError={dbCorruptionRefreshError}
/>
)}
{viewMode === "project" && currentProject && !setupReadinessLoading && hasWarnings && !setupWarningDismissed && (
<SetupWarningBanner
hasAiProvider={hasAiProvider}
hasGithub={hasGithub}
onDismiss={handleDismissSetupWarning}
/>
)}
{viewMode === "project" && currentProject && approvalBannerCandidate && (
<ApprovalNotificationBanner
pendingCount={Math.max(mailboxPendingApprovalCount, 1)}
onOpenMailbox={() => 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 && (
<GitHubStarPrompt
onDismiss={() => {
markGitHubStarPromptShown();
setShowGitHubStarPrompt(false);
}}
/>
)}
</>
);
}

View File

@@ -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 <PageErrorBoundary>/<Suspense> 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 (
<BackendConnectionErrorPage
errorMessage={projectsError ?? t("app.backendError.failedFetch", "Failed to fetch projects")}
isRetrying={retryingProjects}
onRetry={handleRetryProjects}
onManageConnection={shellApi ? () => {
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 (
<PageErrorBoundary>
<Suspense fallback={null}>
<_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");
}}
/>
</Suspense>
</PageErrorBoundary>
);
}
if (viewMode === "overview") {
return (
<PageErrorBoundary>
<ProjectOverview
projects={projects}
loading={projectsLoading}
onSelectProject={handleSelectProject}
onAddProject={handleAddProject}
onPauseProject={handlePauseProject}
onResumeProject={handleResumeProject}
onRemoveProject={handleRemoveProject}
nodes={nodes}
/>
</PageErrorBoundary>
);
}
const resolvedPluginTaskView = taskView === "graph" ? graphPluginTaskView : (isPluginViewId(taskView) ? taskView : null);
// Project view
if (resolvedPluginTaskView) {
const pluginTasks = isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks;
return (
<PageErrorBoundary>
<PluginDashboardViewHost
taskView={resolvedPluginTaskView as `plugin:${string}:${string}`}
context={{
projectId: currentProject?.id,
tasks: pluginTasks,
workflowSteps,
subscribePluginEvents,
openTaskDetail: (task: Task | TaskDetail, initialTab?: DetailTaskTab) => openDetailTask(task, initialTab),
openFile: openFileInBrowser,
renderTaskCard: (task: Task | TaskDetail) => (
<TaskCard
task={task}
projectId={currentProject?.id}
onOpenDetail={(value: 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,
}}
/>
</PageErrorBoundary>
);
}
if (taskView === "skills") {
if (!settingsLoaded || !skillsEnabled) {
return null;
}
return (
<PageErrorBoundary>
<Suspense fallback={null}>
<SkillsView
addToast={addToast}
projectId={currentProject?.id}
onClose={() => handleChangeTaskView("board")}
/>
</Suspense>
</PageErrorBoundary>
);
}
if (taskView === "chat") {
return (
<PageErrorBoundary>
<Suspense fallback={null}>
<ChatView
addToast={addToast}
projectId={currentProject?.id}
experimentalFeatures={experimentalFeatures}
onPopOut={() => setQuickChatOpen(true)}
/>
</Suspense>
</PageErrorBoundary>
);
}
if (taskView === "mailbox") {
return (
<PageErrorBoundary>
<MailboxView
projectId={currentProject?.id}
addToast={addToast}
onUnreadCountChange={setMailboxUnreadCount}
/>
</PageErrorBoundary>
);
}
if (taskView === "missions") {
return (
<PageErrorBoundary>
<MissionManager
isInline={true}
isOpen={true}
onClose={() => {
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");
}}
/>
</PageErrorBoundary>
);
}
if (taskView === "agents" && agentsEnabled) {
return (
<PageErrorBoundary>
<Suspense fallback={null}>
<AgentsView
addToast={addToast}
projectId={currentProject?.id}
onOpenTaskLogs={handleOpenTaskLogs}
agentOnboardingEnabled={agentOnboardingEnabled}
/>
</Suspense>
</PageErrorBoundary>
);
}
if (taskView === "documents") {
return (
<PageErrorBoundary>
<Suspense fallback={null}>
<DocumentsView
projectId={currentProject?.id}
addToast={addToast}
onOpenDetail={openDetailTask}
onOpenArtifactTaskDetail={popOutTaskDetail}
onSendSelectionToTask={modalManager.openNewTaskWithDescription}
/>
</Suspense>
</PageErrorBoundary>
);
}
if (taskView === "pull-requests") {
return (
<PageErrorBoundary>
<Suspense fallback={null}>
<PullRequestView pullRequestId={selectedPrId} projectId={currentProject?.id} />
</Suspense>
</PageErrorBoundary>
);
}
if (taskView === "insights") {
if (!settingsLoaded || !insightsEnabled) {
return null;
}
return (
<PageErrorBoundary>
<Suspense fallback={null}>
<InsightsView
projectId={currentProject?.id}
addToast={addToast}
onClose={() => handleChangeTaskView("board")}
onCreateTask={handleInsightTaskCreate}
/>
</Suspense>
</PageErrorBoundary>
);
}
if (taskView === "research") {
if (!settingsLoaded || !researchEnabled) {
return null;
}
return (
<PageErrorBoundary>
<Suspense fallback={null}>
<ResearchView
projectId={currentProject?.id}
addToast={addToast}
onOpenSettings={(section) => openSettingsWithNav(section as SectionId)}
readinessVersion={researchReadinessVersion}
/>
</Suspense>
</PageErrorBoundary>
);
}
if (taskView === "evals") {
if (!settingsLoaded || !evalsEnabled) {
return null;
}
return (
<PageErrorBoundary>
<Suspense fallback={null}>
<EvalsView
projectId={currentProject?.id}
onOpenSettings={(section) => 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"));
}}
/>
</Suspense>
</PageErrorBoundary>
);
}
if (taskView === "memory") {
if (!settingsLoaded || !memoryEnabled) {
return null;
}
return (
<PageErrorBoundary>
<Suspense fallback={null}>
<MemoryView
addToast={addToast}
projectId={currentProject?.id}
onSendSelectionToTask={modalManager.openNewTaskWithDescription}
/>
</Suspense>
</PageErrorBoundary>
);
}
if (taskView === "secrets") {
return (
<PageErrorBoundary>
<Suspense fallback={null}>
<SecretsView addToast={addToast} />
</Suspense>
</PageErrorBoundary>
);
}
if (taskView === "goalsView") {
if (!settingsLoaded || !goalsEnabled) {
return null;
}
return (
<PageErrorBoundary>
<Suspense fallback={null}>
<GoalsView anchorGoalId={goalAnchorId} onNavigateToMission={handleOpenMission} />
</Suspense>
</PageErrorBoundary>
);
}
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 (
<PageErrorBoundary>
<Suspense fallback={null}>
<TodoView projectId={currentProject?.id} addToast={addToast} onPlanningMode={openPlanningWithInitialPlanWithNav} onTaskCreated={(task) => ingestCreatedTasks([task])} />
</Suspense>
</PageErrorBoundary>
);
}
if (taskView === "command-center") {
return (
<PageErrorBoundary>
<Suspense fallback={null}>
<CommandCenter
projectId={currentProject?.id}
colorTheme={colorTheme}
themeMode={themeMode}
shadcnCustomColors={shadcnCustomColors}
resolvedThemeMode={resolvedThemeMode}
onColorThemeChange={setColorTheme}
onThemeModeChange={setThemeMode}
onShadcnCustomColorsChange={setShadcnCustomColors}
addToast={addToast}
nodesEnabled={nodesEnabled}
onChangeView={handleChangeTaskView}
/>
</Suspense>
</PageErrorBoundary>
);
}
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 (
<PageErrorBoundary>
{/*
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.
*/}
<PlanningWorkflowSwitcherSlot projectId={currentProject?.id} onOpenWorkflowEditor={openWorkflowEditorWithNav} />
<PlanningModeModal
isOpen={true}
onClose={closePlanningView}
onTaskCreated={handlePlanningTaskCreated}
onTasksCreated={handlePlanningTasksCreated}
tasks={tasks}
initialPlan={modalManager.planningInitialPlan ?? undefined}
projectId={currentProject?.id}
workflowId={modalManager.planningWorkflowId}
resumeSessionId={modalManager.planningResumeSessionId}
presentation="embedded"
/>
</PageErrorBoundary>
);
}
/*
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 (
<PageErrorBoundary>
<Suspense fallback={null}>
<_WorkflowEditorView
isOpen={true}
onClose={() => handleChangeTaskView("board")}
addToast={addToast}
projectId={currentProject?.id}
presentation="embedded"
/>
</Suspense>
</PageErrorBoundary>
);
}
if (taskView === "import-tasks") {
return (
<PageErrorBoundary>
<Suspense fallback={null}>
<_ImportTasksView
isOpen={true}
onClose={() => handleChangeTaskView("board")}
onImport={handleGitHubImport}
tasks={tasks}
projectId={currentProject?.id}
presentation="embedded"
/>
</Suspense>
</PageErrorBoundary>
);
}
if (taskView === "automations") {
return (
<PageErrorBoundary>
<Suspense fallback={null}>
<_AutomationsView
onClose={() => handleChangeTaskView("board")}
addToast={addToast}
projectId={currentProject?.id}
presentation="embedded"
/>
</Suspense>
</PageErrorBoundary>
);
}
if (taskView === "devserver" || taskView === "dev-server") {
if (!settingsLoaded || !devServerEnabled) {
return null;
}
return (
<PageErrorBoundary>
<Suspense fallback={null}>
<DevServerView tasks={tasks} addToast={addToast} projectId={currentProject?.id} />
</Suspense>
</PageErrorBoundary>
);
}
/*
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 (
<PageErrorBoundary>
<Board
tasks={filteredBoardTasks}
projectId={currentProject?.id}
maxConcurrent={maxConcurrent}
onMoveTask={moveTask}
onPauseTask={pauseTask}
onOpenDetail={openTaskDetailInMainPanel}
onOpenGroupModal={openGroupModalWithNav}
addToast={addToast}
onQuickCreate={handleBoardQuickCreate}
onNewTask={openNewTaskWithNav}
onPlanningMode={openPlanningWithInitialPlanWithNav}
onSubtaskBreakdown={subtaskBreakdownEnabled ? openSubtaskBreakdownWithNav : undefined}
autoMerge={autoMerge}
onToggleAutoMerge={toggleAutoMerge}
globalPaused={globalPaused}
onUpdateTask={updateTask}
onRetryTask={retryTask}
onArchiveTask={archiveTask}
onUnarchiveTask={unarchiveTask}
onDeleteTask={deleteTask}
onArchiveAllDone={archiveAllDone}
onLoadArchivedTasks={loadArchivedTasks}
searchQuery={searchQuery}
availableModels={availableModels}
onOpenDetailWithTab={handleOpenDetailWithTab}
favoriteProviders={favoriteProviders}
favoriteModels={favoriteModels}
onToggleFavorite={handleToggleFavorite}
onToggleModelFavorite={handleToggleModelFavorite}
taskStuckTimeoutMs={taskStuckTimeoutMs}
staleHighFanoutBlockerAgeThresholdMs={staleHighFanoutBlockerAgeThresholdMs}
onOpenMission={handleOpenMission}
lastFetchTimeMs={lastFetchTimeMs}
prAuthAvailable={prAuthAvailable}
onOpenWorkflowEditor={openWorkflowEditorWithNav}
onCreateWorkflow={openCreateWorkflowWithNav}
workflowColumnsEnabled
settingsLoaded={settingsLoaded}
workflowControlsInHeader={sidebarActive || isMobile}
/>
</PageErrorBoundary>
);
}
return (
<PageErrorBoundary>
<div className="task-detail-main-panel">
<div className="task-detail-main-panel-body">
<TaskDetailContent
task={liveDetailTask}
projectId={currentProject?.id}
tasks={tasks}
embedded
initialTab={mainPanelDetailInitialTab}
/*
FNXC:TaskDetail 2026-06-22-18:40:
Board-card detail (full main panel) renders its "Back to board" affordance inside TaskDetailContent's gray header (far right, across from the task id) instead of a separate back-row above the content. The prop only renders the header back button when both embedded and onBackToBoard are present, so ListView split-pane and modal usages stay unaffected.
*/
onBackToBoard={closeTaskDetailMainPanel}
/* FNXC:FloatingWindow 2026-06-22-21:10: Popping out from the board's full-panel detail also returns the main panel to the board, so the board (not the emptied detail) sits behind the floating window. */
onPopOut={(task) => { 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}
/>
</div>
</div>
</PageErrorBoundary>
);
}
if (taskView === "board") {
return (
<PageErrorBoundary>
{capacityRiskBannerEnabled && !capacityRiskDismissed ? (
<CapacityRiskBanner signal={capacityRiskSignal} onDismiss={handleDismissCapacityRisk} />
) : null}
<Board
tasks={filteredBoardTasks}
projectId={currentProject?.id}
maxConcurrent={maxConcurrent}
onMoveTask={moveTask}
onPauseTask={pauseTask}
onOpenDetail={openTaskDetailInMainPanel}
onOpenGroupModal={openGroupModalWithNav}
addToast={addToast}
onQuickCreate={handleBoardQuickCreate}
onNewTask={openNewTaskWithNav}
onPlanningMode={openPlanningWithInitialPlanWithNav}
onSubtaskBreakdown={subtaskBreakdownEnabled ? openSubtaskBreakdownWithNav : undefined}
autoMerge={autoMerge}
onToggleAutoMerge={toggleAutoMerge}
globalPaused={globalPaused}
onUpdateTask={updateTask}
onRetryTask={retryTask}
onArchiveTask={archiveTask}
onUnarchiveTask={unarchiveTask}
onDeleteTask={deleteTask}
onArchiveAllDone={archiveAllDone}
onLoadArchivedTasks={loadArchivedTasks}
searchQuery={searchQuery}
availableModels={availableModels}
onOpenDetailWithTab={handleOpenDetailWithTab}
favoriteProviders={favoriteProviders}
favoriteModels={favoriteModels}
onToggleFavorite={handleToggleFavorite}
onToggleModelFavorite={handleToggleModelFavorite}
taskStuckTimeoutMs={taskStuckTimeoutMs}
staleHighFanoutBlockerAgeThresholdMs={staleHighFanoutBlockerAgeThresholdMs}
onOpenMission={handleOpenMission}
lastFetchTimeMs={lastFetchTimeMs}
prAuthAvailable={prAuthAvailable}
onOpenWorkflowEditor={openWorkflowEditorWithNav}
onCreateWorkflow={openCreateWorkflowWithNav}
workflowColumnsEnabled
settingsLoaded={settingsLoaded}
workflowControlsInHeader={sidebarActive || isMobile}
/>
</PageErrorBoundary>
);
}
// List view
return (
<PageErrorBoundary>
<ListView
tasks={isRemote && remoteData.tasks.length > 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}
/>
</PageErrorBoundary>
);
}

View File

@@ -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<void>;
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<string, string>;
setShadcnCustomColors: (colors: Record<string, string>) => 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<void>;
handleResumeProject: (project: ProjectInfo) => Promise<void>;
handleRemoveProject: (project: ProjectInfo) => Promise<void>;
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<string, string>;
prAuthAvailable: boolean;
autoMerge: boolean;
settingsLoaded: boolean;
skillsEnabled: boolean;
experimentalFeatures: Record<string, boolean>;
setQuickChatOpen: Dispatch<SetStateAction<boolean>>;
setMailboxUnreadCount: (count: number) => void;
setMissionTargetId: Dispatch<SetStateAction<string | undefined>>;
setMissionResumeSessionId: Dispatch<SetStateAction<string | undefined>>;
setMilestoneSliceResumeSessionId: Dispatch<SetStateAction<string | undefined>>;
missionResumeSessionId: string | undefined;
missionTargetId: string | undefined;
milestoneSliceResumeSessionId: string | undefined;
setGoalAnchorId: Dispatch<SetStateAction<string | undefined>>;
goalAnchorId: string | undefined;
agentsEnabled: boolean;
agentOnboardingEnabled: boolean;
handleOpenTaskLogs: (taskId: string) => Promise<void>;
popOutTaskDetail: (task: Task | TaskDetail) => void;
selectedPrId: string | undefined;
insightsEnabled: boolean;
handleInsightTaskCreate: (input: { insightId: string; title: string; description: string }) => Promise<void>;
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<Task>;
pauseTask: (id: string) => Promise<Task>;
openTaskDetailInMainPanel: (task: Task | TaskDetail, initialTab?: DetailTaskTab) => void;
openGroupModalWithNav: (groupId: string) => void;
handleBoardQuickCreate: (input: TaskCreateInput) => Promise<Task>;
openNewTaskWithNav: () => void;
subtaskBreakdownEnabled: boolean;
openSubtaskBreakdownWithNav: (description: string, workflowId?: string | null) => void;
toggleAutoMerge: () => Promise<void>;
globalPaused: boolean;
updateTask: (
id: string,
updates: { title?: string; description?: string; dependencies?: string[]; dismissNearDuplicate?: boolean },
) => Promise<Task>;
retryTask: (id: string) => Promise<Task>;
archiveTask: (id: string, options?: { removeLineageReferences?: boolean }) => Promise<Task>;
unarchiveTask: (id: string) => Promise<Task>;
deleteTask: (
id: string,
options?: {
removeDependencyReferences?: boolean;
removeLineageReferences?: boolean;
githubIssueAction?: GithubIssueAction;
allowResurrection?: boolean;
},
) => Promise<Task>;
archiveAllDone: () => Promise<Task[]>;
loadArchivedTasks: () => Promise<void>;
searchQuery: string;
availableModels: ModelInfo[];
favoriteProviders: string[];
favoriteModels: string[];
handleOpenDetailWithTab: (task: Task | TaskDetail, initialTab: "changes" | "retries" | "workflow") => void;
handleToggleFavorite: (provider: string) => Promise<void>;
handleToggleModelFavorite: (modelId: string) => Promise<void>;
taskStuckTimeoutMs: number | undefined;
staleHighFanoutBlockerAgeThresholdMs: number;
lastFetchTimeMs: number | undefined;
openCreateWorkflowWithNav: () => void;
sidebarActive: boolean;
isMobile: boolean;
mainPanelDetailInitialTab: DetailTaskTab;
closeTaskDetailMainPanel: () => void;
setMainPanelDetailTask: Dispatch<SetStateAction<Task | TaskDetail | null>>;
setMainPanelDetailInitialTab: (tab: DetailTaskTab) => void;
mergeTask: (id: string) => Promise<MergeResult>;
resetTask: (id: string) => Promise<Task>;
duplicateTask: (id: string) => Promise<Task>;
unpauseTask: (id: string) => Promise<Task>;
capacityRiskBannerEnabled: boolean;
capacityRiskDismissed: boolean;
capacityRiskSignal: CapacityRiskSignal;
handleDismissCapacityRisk: () => void;
// App-level lazy view chunks (declared in App.tsx, threaded in as props).
AgentsView: LazyExoticComponent<typeof AgentsView>;
ChatView: LazyExoticComponent<typeof ChatView>;
CommandCenter: LazyExoticComponent<typeof CommandCenter>;
DevServerView: LazyExoticComponent<typeof DevServerView>;
DocumentsView: LazyExoticComponent<typeof DocumentsView>;
EvalsView: LazyExoticComponent<typeof EvalsView>;
GoalsView: LazyExoticComponent<typeof GoalsView>;
InsightsView: LazyExoticComponent<typeof InsightsView>;
MemoryView: LazyExoticComponent<typeof MemoryView>;
PullRequestView: LazyExoticComponent<typeof PullRequestView>;
ResearchView: LazyExoticComponent<typeof ResearchView>;
SecretsView: LazyExoticComponent<typeof SecretsView>;
SkillsView: LazyExoticComponent<typeof SkillsView>;
TodoView: LazyExoticComponent<typeof TodoView>;
_AutomationsView: LazyExoticComponent<typeof ScheduledTasksModal>;
_ImportTasksView: LazyExoticComponent<typeof GitHubImportModal>;
_SettingsView: LazyExoticComponent<typeof SettingsView>;
_WorkflowEditorView: LazyExoticComponent<typeof WorkflowNodeEditor>;
}
/**
* 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<SetStateAction<DashboardHealthResponse | null>>;
taskView: TaskView;
modalManager: ModalManager;
sessionBannersHidden: boolean;
sessionsNeedingInput: AiSessionSummary[];
handleOpenBackgroundSession: (session: AiSessionSummary) => void;
handleDismissNeedingInputSession: () => void;
handleDismissAllNeedingInputSessions: () => void;
handleCliAction: (session: AiSessionSummary, action: CliActionId) => Promise<void>;
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<void>;
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<SetStateAction<boolean>>;
}

View File

@@ -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<string, (e: MessageEvent) => void>;
}
const { subscriptions } = vi.hoisted(() => ({
subscriptions: [] as CapturedSubscription[],
}));
vi.mock("../../sse-bus", () => ({
subscribeSse: vi.fn(
(
url: string,
sub: { onReconnect?: () => void; events: Record<string, (e: MessageEvent) => 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);
});
});

View File

@@ -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;

View File

@@ -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<string, (e: MessageEvent) => void>,
}));
vi.mock("../../sse-bus", () => ({
subscribeSse: vi.fn((_url: string, opts: { events: Record<string, (e: MessageEvent) => 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);
});
});

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View File

@@ -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");
});
});

View File

@@ -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);
});
});

View File

@@ -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<string, (e: MessageEvent) => void>,
}));
vi.mock("../../sse-bus", () => ({
subscribeSse: vi.fn((_url: string, opts: { events: Record<string, (e: MessageEvent) => 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);
});
});

View File

@@ -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.
});
});

View File

@@ -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<string, (e: MessageEvent) => void> & { onReconnect?: () => void },
}));
vi.mock("../../sse-bus", () => ({
subscribeSse: vi.fn((_url: string, opts: { onReconnect?: () => void; events: Record<string, (e: MessageEvent) => 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<string, unknown>)[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);
});
});

View File

@@ -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");
});
});

View File

@@ -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"]);
});
});

View File

@@ -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");
});
});

View File

@@ -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);
});
});

View File

@@ -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<ApprovalBannerCandidate | null>(null);
const taskStatusByIdRef = useRef<Map<string, string | undefined>>(new Map());
const seenApprovalKeysRef = useRef<Set<string>>(new Set());
const approvalDismissalsRef = useRef<Map<string, number>>(loadApprovalBannerDismissals());
useEffect(() => {
const next = new Map<string, string | undefined>();
const nextSeen = new Set<string>();
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 };
}

View File

@@ -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 };
}

View File

@@ -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<BoardScrollSnapshot | null>(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 };
}

View File

@@ -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,
};
}

View File

@@ -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 };
}

View File

@@ -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 };
}

View File

@@ -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<SetStateAction<DashboardHealthResponse | null>>;
refreshing: boolean;
refreshError: string | null;
refresh: () => Promise<void>;
}
export function useDashboardHealth(): UseDashboardHealthResult {
const [health, setHealth] = useState<DashboardHealthResponse | null>(null);
const [refreshing, setRefreshing] = useState(false);
const [refreshError, setRefreshError] = useState<string | null>(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 };
}

View File

@@ -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 };
}

View File

@@ -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<SetStateAction<Task | TaskDetail | null>>;
setInitialTab: (tab: DetailTaskTab) => void;
}
export function useMainPanelTaskDetail(): UseMainPanelTaskDetailResult {
const [task, setTask] = useState<Task | TaskDetail | null>(null);
const [initialTab, setInitialTab] = useState<DetailTaskTab>("chat");
return { task, initialTab, setTask, setInitialTab };
}

View File

@@ -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<Task | TaskDetail>;
popOut: (task: Task | TaskDetail) => void;
close: (taskId: string) => void;
}
export function usePoppedOutTasks(): UsePoppedOutTasksResult {
const [tasks, setTasks] = useState<Array<Task | TaskDetail>>([]);
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 };
}

View File

@@ -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 };
}

View File

@@ -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 };
}

View File

@@ -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<string, number> {
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<string, number>;
const map = new Map<string, number>();
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<string, number>): void {
if (typeof window === "undefined") return;
try {
const data: Record<string, number> = {};
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<unknown>;
moveTask: (id: string, column: "todo") => Promise<unknown>;
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<void> {
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");
}
}

View File

@@ -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,