fix(dashboard): stop over-aggressive component unmounts (keep-alive for planning, terminals, popups) (#2420)

Implements
docs/plans/2026-07-22-001-fix-dashboard-remount-churn-plan.md: every
confirmed source of unnecessary unmount/remount churn in the dashboard,
plus a keep-alive layer for conversation- and terminal-bearing surfaces.

## What changed

**Keying / component identity (U1–U3)**
- Streaming chat segment key no longer embeds `entries.length` — an
expanded thinking block stays expanded while entries stream into it
(R1).
- Dock task list keys `TaskCard` rows by `task.id` (occurrence suffix
only for the duplicate-id anomaly) instead of `id-index` — no remount on
reorder/filter/status change (R2).
- `ProviderStatusBadge` / `GitHubStatusBadge` hoisted out of
ModelOnboardingModal's render body (R3); MCP server rows key by
`server.name` alone (R4).

**Keep-alive layer (U4–U6)**
- New shared `KeepAliveView` wrapper: visible = in-flow flex child;
hidden = out-of-flow `position:absolute; inset:0` with
`visibility:hidden; pointer-events:none` + `aria-hidden` (never
`display:none`, so xterm geometry never collapses).
- Planning Mode renders as a kept-alive sibling of the MainContent
switch after first open (per-project latch mirroring Quick Chat). While
hidden, the session-list SSE, recovery poll, and elapsed ticker suspend
via a new `active` prop; reveal re-subscribes and refreshes the sessions
list once. Payload-carrying entry points (initial-plan handoff, resume)
and project switches remount via a new
`modalManager.planningEntryGeneration` key, preserving pre-keep-alive
fresh-open semantics. `recordResumeEvent` instrumentation records
`remount` on first activation and `route-active` on reveal.
- Task-detail Terminal / Worktree-terminal / Planner-chat tabs stay
mounted-but-hidden after first open (per-task latches; task switch/close
still disposes fully). `SessionTerminal` gains `active`: reveal refits +
forces a font remeasure, and if the WS died while hidden it re-runs the
full attach lifecycle (dead-socket recovery).
- Popped-out task windows hide via FloatingWindow `hidden` instead of
leaving the render array; `TaskDetailContent` gains `active` so hidden
popups close their SSE/EventSource channels while the terminal WS stays
open. `visiblePoppedOutTaskEntries` remains the Escape-shortcut
consumer.

**Planning Mode internal-transition audit (U7)**
- Audit findings: session-list mode and mobile list/detail flips are
CSS-class transitions over one always-mounted detail pane (no
state-discarding unmounts); re-selecting the active session is an
early-return visibility restore; session switching intentionally reloads
from the session row (stream re-attach for generating sessions);
remaining index keys are on stateless lists. No product-code defects
found; regression tests now lock the always-mounted invariant on desktop
+ mobile.

**Cheap-view state (U8)**
- CommandCenter (active sub-tab + date range) and DevServerView
(selected script/task + typed-but-unsent command) persist per project
via `modalPersistence` and restore after their (intentional) unmount
round-trips. Also fixed the candidate auto-fill effect clobbering a
customized non-empty command.

## Symptom Verification
- **Original symptom:** streaming thinking blocks collapsed mid-stream;
terminals reconnected and lost scroll/input on tab flips; Planning Mode
lost in-flight interviews on navigation; popped-out windows vanished
off-view; dock cards remounted on reorder.
- **Exact reproduction:** (1) expand a thinking block during a stream;
(2) run a command in the Terminal tab, flip to Plan and back; (3) start
a planning interview, navigate Board and back; (4) pop out a task with
board/list-only scoping and switch views; (5) change a dock task's
status.
- **Assertion it is gone:** component-identity/instrumentation tests in
TaskChatTab, SessionTerminal, TaskDetailModal
(worktree/planner-chat/tabs), PlanningModeModal keep-alive +
internal-transitions, App keep-alive round-trip, and
App.taskPopupViewGating assert no remount and preserved state for each
repro, across desktop and mobile breakpoints.

## Verification
- File-scoped vitest: 23 files / 1091 tests green (all touched suites
plus FloatingWindow, TerminalModal, TaskPlannerChatTab,
lazy-loaded-views guard, App suites).
- `pnpm verify:fast`: PASS (13 steps — scoped typecheck/build, CLI
build, boot smoke).
- `pnpm check:changesets`: passes; changeset
`fix-dashboard-remount-churn` (`@runfusion/fusion` patch, labeled
format).
- Known pre-existing failures NOT caused by this branch (verified
failing at base a224c1111 in a clean worktree): 7 tests in
`TaskDetailModal.oversight-controls/oversight-mobile/models-progress-workflow`.
- jsdom cannot prove rendered-grid correctness for xterm reveal; per the
plan's risk note, manual browser verification of terminal reveal remains
recommended.

🤖 Generated with [Claude Code](https://claude.com/claude-code)


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

* **Bug Fixes**
* Switching views or tabs no longer resets Planning Mode, task details,
terminals, planner chats, or popped-out task windows.
  * Streaming content remains expanded and stable as new entries arrive.
* Hidden views suspend background activity and resume correctly when
shown.
  * Terminal sessions reconnect automatically when needed.

* **Improvements**
  * Command Center and Dev Server selections persist per project.
  * Custom Dev Server commands are preserved while browsing suggestions.
* Improved stability when reordering task lists and updating server
states.

* **Documentation**
* Updated dashboard guidance for hidden, retained task pop-ups and view
transitions.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-30 21:50:47 -07:00
committed by GitHub
parent 189f237a07
commit 4f929acc10
37 changed files with 1849 additions and 185 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Terminals, planning sessions, and popped-out task windows no longer reset when switching views or tabs.
category: fix
dev: Keep-alive layer (KeepAliveView, visibility-based out-of-flow hiding) for Planning Mode, task-detail terminal/planner-chat tabs, and popped-out task FloatingWindows; hidden surfaces suspend SSE/EventSource work via `active` props. Stable keys for streaming chat segments, dock task cards, and MCP server rows. CommandCenter/DevServerView selections persist per project via modalPersistence.

View File

@@ -196,7 +196,8 @@ If **Settings → Appearance → Open tasks in the right sidebar** is enabled, b
FNXC:TaskPopupGeometry 2026-07-03-00:00: Desktop/tablet task popups share a persisted geometry key across task IDs so operators can size and place the popup once, then open other tasks without repeating that setup. Mobile remains a full-screen sheet regardless of saved desktop geometry. FNXC:TaskPopupGeometry 2026-07-03-00:00: Desktop/tablet task popups share a persisted geometry key across task IDs so operators can size and place the popup once, then open other tasks without repeating that setup. Mobile remains a full-screen sheet regardless of saved desktop geometry.
FNXC:RightDockTaskPopup 2026-07-03-00:00: The same task-popup preference also applies to ordinary clicks in the right-dock Tasks list so that opted-in operators never lose the list to embedded detail by clicking a dock task row. FNXC:RightDockTaskPopup 2026-07-03-00:00: The same task-popup preference also applies to ordinary clicks in the right-dock Tasks list so that opted-in operators never lose the list to embedded detail by clicking a dock task row.
FNXC:TaskPopupLayer 2026-07-17-15:55: Task popups and Quick Chat share the board/task-detail interaction stack, so the most recently clicked or focused overlapping surface appears on top. Terminal, Files, New Task, workflow editor, and other utility windows retain their separate higher utility stacking. FNXC:TaskPopupLayer 2026-07-17-15:55: Task popups and Quick Chat share the board/task-detail interaction stack, so the most recently clicked or focused overlapping surface appears on top. Terminal, Files, New Task, workflow editor, and other utility windows retain their separate higher utility stacking.
FNXC:TaskPopupViewGating 2026-07-15-15:20: FN-8016 makes popup view scoping default-on for every dashboard view. Hidden popups are render-only hidden, not closed, so snapshots and shared geometry survive navigation; same-task popups remain independently addressable by origin view. --> FNXC:TaskPopupViewGating 2026-07-15-15:20: FN-8016 makes popup view scoping default-on for every dashboard view. Same-task popups remain independently addressable by origin view.
FNXC:TaskPopupViewGating 2026-07-22-13:20: FN remount-churn fix R7 — off-origin-view popups are now render-only hidden (FloatingWindow `hidden`: visibility-based, aria-hidden, effects suspended), never unmounted. The embedded task detail, including an open terminal WebSocket, stays live while hidden; the detail's SSE/EventSource channels close via `active={false}` and reopen on reveal. Returning to the origin view is an instant reveal, not a remount. -->
**Settings → Appearance → Open tasks as popups** changes ordinary board task-card clicks, List row/card opens, and right-dock Tasks-list clicks across desktop, tablet, and mobile viewports. When enabled, those clicks use the existing task popup/FloatingWindow surface on the board/task-detail layer instead of the full-panel task detail, List split-detail/docked detail, or right-dock task detail, keeping the board, List view, or dock list visible in the background. Overlapping Quick Chat and task popups interleave by the most-recent pointer or focus interaction; other utility windows retain their higher global stacking. On desktop and tablet, task popups restore the last saved popup size and position between tasks; on mobile, task popups stay full-screen sheets. Board task-card `changes`/`retries`/`workflow` chips open the popup with the requested tab. List context-menu/refine actions, task-detail links, plugin/graph opens, and explicit pop-out actions keep their existing paths. **Settings → Appearance → Open tasks as popups** changes ordinary board task-card clicks, List row/card opens, and right-dock Tasks-list clicks across desktop, tablet, and mobile viewports. When enabled, those clicks use the existing task popup/FloatingWindow surface on the board/task-detail layer instead of the full-panel task detail, List split-detail/docked detail, or right-dock task detail, keeping the board, List view, or dock list visible in the background. Overlapping Quick Chat and task popups interleave by the most-recent pointer or focus interaction; other utility windows retain their higher global stacking. On desktop and tablet, task popups restore the last saved popup size and position between tasks; on mobile, task popups stay full-screen sheets. Board task-card `changes`/`retries`/`workflow` chips open the popup with the requested tab. List context-menu/refine actions, task-detail links, plugin/graph opens, and explicit pop-out actions keep their existing paths.
**Settings → Appearance → Keep task popups on the view where they were opened** is enabled by default. Every task-detail popup is attached to its exact originating view, including Planning, Agents, Command Center, Documents, Missions, and plugin views: navigating elsewhere hides it without closing it, and returning re-shows it in the same saved position. You can open the same task independently in more than one view; closing or pressing Escape on one popup does not affect the other. Disable this setting only to restore legacy globally shared popups. Legacy saved popups without an origin remain visible everywhere for compatibility. **Settings → Appearance → Keep task popups on the view where they were opened** is enabled by default. Every task-detail popup is attached to its exact originating view, including Planning, Agents, Command Center, Documents, Missions, and plugin views: navigating elsewhere hides it without closing it, and returning re-shows it in the same saved position. You can open the same task independently in more than one view; closing or pressing Escape on one popup does not affect the other. Disable this setting only to restore legacy globally shared popups. Legacy saved popups without an origin remain visible everywhere for compatibility.

View File

@@ -0,0 +1,292 @@
---
title: "fix: Stop over-aggressive component unmounts across the dashboard"
type: fix
status: completed
date: 2026-07-22
---
# fix: Stop over-aggressive component unmounts across the dashboard
## Summary
Fix every confirmed source of unnecessary unmount/remount churn in the dashboard: two volatile-key bugs, two mechanical anti-patterns, and — the substantive part — a keep-alive layer so conversation- and terminal-bearing surfaces (Planning Mode, task-detail terminal and planner-chat tabs, popped-out task windows) survive navigation and tab switches mounted-but-hidden, mirroring the existing Quick Chat hidden-FloatingWindow pattern. Includes a deep audit of Planning Mode's internal session/view transitions and lightweight state preservation for the cheaper views.
---
## Problem Frame
A remount audit (2026-07-22) found the dashboard unmounts components far more often than intended. The main-content area is a pure `if (taskView === ...) return` switch (`packages/dashboard/app/components/dashboard/MainContent.tsx`), so every navigation destroys the previous view entirely; TaskDetail's tab body is one mutually-exclusive ternary, so every tab switch tears down terminals (WebSocket close + xterm dispose) and the planner chat; popped-out task windows are filtered out of the render array when the user leaves their origin view. On top of the architectural cause, two keys are more volatile than the identity they represent: the streaming chat segment key embeds `entries.length` (remounts the actively streaming thinking block on every entry), and the dock task list keys `TaskCard` by `id-index` (remounts on any reorder).
User-visible symptoms: an expanded thinking block snapping shut on every streamed entry, terminals reconnecting and losing scroll/unsent input on every tab flip, Planning Mode losing in-flight interview UI (view state, streaming output, retry counters, unsaved summary edits) on any sidebar navigation, and floating task windows vanishing and remounting when switching views.
The codebase already contains the correct pattern twice: Quick Chat stays mounted via `<FloatingWindow hidden={!quickChatOpen}>` (`packages/dashboard/app/App.tsx`, Quick Chat render block), and `useBoardScrollRestore` papers over board remounts. The heavy surfaces never got either treatment.
---
## Requirements
Keying and component identity:
- R1. The actively streaming chat segment keeps its React identity while entries stream into it; an expanded thinking block stays expanded for the duration of a stream.
- R2. `TaskCard` rows in the dock task list keep their identity across list reorders, filter toggles, and status changes.
- R3. No component is defined inside another component's render body and rendered as a JSX tag (`ProviderStatusBadge`, `GitHubStatusBadge` hoisted to module scope).
- R4. MCP server rows keep their identity across server state transitions (key is `server.name` alone).
Keep-alive for heavy surfaces:
- R5. Planning Mode (`taskView === "planning"`, embedded `PlanningModeModal`) survives main-view navigation mounted-but-hidden after its first open: returning is an instant visibility restore with no reload, reflow, or loading flash, preserving ViewState, conversation history, streaming output, draft edits, and scroll.
- R6. The task-detail Terminal, Worktree-terminal, and Planner-chat tabs survive tab switches mounted-but-hidden after first open: no WebSocket teardown, no xterm dispose, no planner composer/scroll loss.
- R7. Popped-out task windows under `taskPopupsBoardListOnly` are hidden (not unmounted) when the user leaves their origin view; their embedded task detail, including any open terminal, stays live.
- R8. Hidden kept-alive surfaces suspend their background work: SSE/EventSource subscriptions gated by the existing `active`/`enabled` prop convention are closed while hidden and reopened on reveal; no hidden surface polls.
- R9. Terminals revealed from a hidden state render a correct grid: refit fires on reveal, and the hide mechanism never lets the xterm container reach zero geometry (`visibility: hidden`, never `display: none`).
- R10. Behaviors currently coupled to unmount still fire when unmount becomes hide: nav-stack entries are removed when a hidden-not-closed surface is dismissed, unconfirmed-edit reverts still run, and no stale per-card authorization state survives where a remount previously cleared it.
Planning Mode internal transitions:
- R11. A deep audit of `PlanningModeModal`'s internal transitions (session switching, session-list mode, ViewState changes, mobile detail flips) identifies and fixes any transition that discards state it should preserve, with the same invariant standard as the top-level fixes.
Cheap-view state preservation:
- R12. CommandCenter (active sub-tab, date range) and DevServerView (selected script, log pagination, command input) restore their cheap UI state after an unmount round-trip via lifted/persisted state — these views are not kept alive.
Verification:
- R13. Regression tests assert the general invariant across all enumerated surfaces (desktop and mobile breakpoints) per the Fix-the-Invariant rule, not just the reported repros.
---
## Key Technical Decisions
- **Keep-alive hides with `visibility: hidden` + `pointer-events: none`, never `display: none`.** `FitAddon.proposeDimensions()` floors a zero-box container to a degenerate 2×1 grid permanently (`docs/solutions/ui-bugs/mobile-terminal-blank-render-zero-geometry-container.md`); `display: none` collapses the box to zero. `FloatingWindow` already implements and regression-tests exactly this (`packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx`, "keeps hidden children mounted" test) — new keep-alive wrappers copy that contract.
- **Lazy first-mount, then persist.** Surfaces mount only when first opened (Quick Chat's `quickChatEverOpenedProjectId` gate is the precedent) and stay mounted afterward. No view is mounted at boot that the user hasn't visited; memory cost is bounded to surfaces actually used this session.
- **Keep-alive breadth: conversation- and terminal-bearing surfaces only.** Planning Mode, task-detail terminal/worktree-terminal/planner-chat tabs, and popped-out task windows get keep-alive. CommandCenter and DevServerView get lifted-state preservation instead (R12) — their state is cheap to externalize and keeping them mounted buys little. ChatView keeps its existing SWR-cache + server stream re-attach cushioning; scroll/session-selection preservation for it is deferred follow-up.
- **Background work is gated by the existing `active`/`enabled` prop convention, not new infrastructure.** `TaskChatTab active` → `useAgentLogs(taskId, active, …)` already opens/closes its EventSource on `enabled`; `taskSseEnabled = taskView === "board" || taskView === "list"` already gates board SSE by view. Kept-alive hidden surfaces pass `active={false}` (or equivalent) while hidden. Note the existing Quick Chat precedent does NOT gate ChatView's effects while hidden — the new wrappers must do better than the precedent here, and the ChatView gap is explicitly out of scope.
- **Popup gating flips from array filter to `hidden` prop.** `App.tsx` renders all `poppedOutTaskEntries` and passes `hidden={!isTaskPopupVisibleForView(...)}` instead of filtering the render list. The exported predicate and its semantics are unchanged; only the render consequence changes. The documented `FNXC:TaskPopupViewGating` behavior changes from "state keeps hidden snapshots so returning remounts them" to render-only hiding — the FNXC comment and `docs/dashboard-guide.md` line for it are updated in the same change.
- **Unmount-coupled behaviors are audited per converted surface, not assumed safe.** Known couplings from research: `ExecutorStatusBar` reverts unconfirmed edits on unmount (`docs/dashboard-guide.md`, `FNXC:ExecutorStatusBar`); planner-oversight completed/effective values must not be reused across what used to be a TaskCard remount (`FNXC:PlannerOversight`); modals opened with `pushNav` must still `removeNav` on hide-dismiss (`docs/solutions/ui-bugs/navigation-history-stale-modal-stack.md`). Each conversion carries an explicit check for these.
- **MainContent keeps its pure-switch contract; keep-alive sits beside it, not inside every branch.** Kept-alive views render as always-mounted (after first open) hidden-gated siblings of the switch output rather than rewriting all ~25 branches. The switch continues to own everything not kept alive.
- **No new top-level `React.lazy` symbols without updating the inventory contract.** The lazy-loaded-views guard (`packages/dashboard/app/__tests__/lazy-loaded-views-docs.test.ts`) pins App-level lazy declarations against AGENTS.md. Keep-alive reuses existing lazy declarations inside their existing `<Suspense>` wrappers; if a wrapper component must be added, it is not a new lazy view.
---
## High-Level Technical Design
Current vs. target mounting topology for the main content area:
```mermaid
flowchart TB
subgraph current["Current: pure switch (one subtree alive)"]
A1[App taskView] --> S1{MainContent switch}
S1 -->|"taskView=planning"| P1[PlanningModeModal]
S1 -->|"taskView=board"| B1[Board]
S1 -->|other| V1[...other view]
P1 -.->|"navigate away = unmount"| X1[state destroyed]
end
subgraph target["Target: switch + keep-alive siblings"]
A2[App taskView] --> S2{MainContent switch<br/>non-kept-alive views}
A2 --> K[Keep-alive layer<br/>mounted after first open]
K -->|"hidden = taskView !== planning"| P2[PlanningModeModal<br/>visibility:hidden when inactive<br/>active=false gates SSE]
A2 --> W[Popped-out FloatingWindows<br/>hidden = off-origin-view]
end
```
TaskDetail tab bodies follow the same shape one level down: the `activeTab` ternary keeps owning cheap tabs, while terminal/worktree-terminal/planner-chat convert to persistent hidden-gated mounts (after first open) with `active={activeTab === X}` gating their SSE/WebSocket-adjacent effects.
Reveal sequence for a hidden terminal (directional guidance, not implementation specification):
```mermaid
sequenceDiagram
participant User
participant Tab as Tab container (visibility:hidden -> visible)
participant RO as ResizeObserver (on xterm container)
participant Term as xterm + FitAddon
User->>Tab: switch back to Terminal tab
Tab->>Tab: remove hidden class (box never collapsed)
Tab->>Term: refit on reveal (+ forced font remeasure if needed)
RO-->>Term: fires only if box actually changed
Note over Term: WebSocket was never closed - no reconnect, scrollback intact
```
---
## Implementation Units
### U1. Stable key for streaming chat segments
- **Goal:** Stop the actively streaming thinking segment from remounting on every streamed entry.
- **Requirements:** R1
- **Dependencies:** none
- **Files:** `packages/dashboard/app/components/TaskChatTab.tsx` (segment key near line 1045), `packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx` (or the existing TaskChatTab test file)
- **Approach:** Drop `segment.entries.length` from the segment key; identity is `kind + startIndex`. Verify no downstream logic relies on the length-remount to reset `TaskChatThinking`'s `open` state (its `defaultOpen` prop still controls the initial state on genuine segment changes).
- **Patterns to follow:** The semantic-key rule from `docs/solutions/ui-bugs/skill-autocomplete-highlight-reset-on-swr-revalidation.md` — derive keys/deps from stable semantic identity, never counts or references.
- **Test scenarios:**
- Happy path: render a thinking segment, expand it, append entries to the same segment, rerender — the expanded state persists (component instance not remounted).
- Edge: a genuinely new segment (different `startIndex`) still gets a fresh instance with `defaultOpen` applied.
- Edge: two segments of the same kind at different offsets keep distinct identities.
- **Verification:** New test fails against the old key (remount observed), passes with the fix; existing TaskChatTab tests stay green.
### U2. Stable key for dock task list cards
- **Goal:** Stop `TaskCard` remounts on dock-list reorder.
- **Requirements:** R2, R10 (stale-state check)
- **Dependencies:** none
- **Files:** `packages/dashboard/app/components/DockTaskList.tsx` (key near line 75), sibling test file in `packages/dashboard/app/components/__tests__/`
- **Approach:** `key={task.id}`. Per the `FNXC:PlannerOversight` caution in `docs/dashboard-guide.md`, check whether any per-card state (planner-oversight effective values, menus) relied on the reorder-remount to reset; if so, reset it explicitly on the relevant prop change instead.
- **Patterns to follow:** Every other task list in the app already keys by `task.id` alone.
- **Test scenarios:**
- Happy path: reorder `visibleTasks` (e.g. status change moves a task), rerender — card instances persist (local state such as an open menu survives, or an instance-identity probe confirms no remount).
- Edge: toggling the `showDone` filter changes list membership without remounting surviving cards.
- Stale-state check: a card whose task's oversight/authorization-relevant props change does not retain stale derived state.
- **Verification:** Reorder test proves identity stability; planner-oversight behavior unchanged.
### U3. Mechanical identity cleanups
- **Goal:** Remove the inline component definitions and the state-in-key row.
- **Requirements:** R3, R4
- **Dependencies:** none
- **Files:** `packages/dashboard/app/components/ModelOnboardingModal.tsx` (hoist `ProviderStatusBadge` ~1055 and `GitHubStatusBadge` ~1092 to module scope), `packages/dashboard/app/components/settings/sections/McpServersCard.tsx` (key ~612 becomes `server.name`)
- **Approach:** Hoist both badges to module scope, passing the translation function as a prop or capturing it via the existing hook at module-component level. McpServersCard: verify nothing depends on the state-transition remount (validation state is external in `validateStates`, so nothing should).
- **Test scenarios:** Test expectation: minimal — one assertion per hoisted badge that it renders identically for each status value (snapshot or role/text query); these are stateless presentation components and the change is behavior-preserving. If existing ModelOnboardingModal/McpServersCard tests cover the rendered output, extending them suffices.
- **Verification:** Typecheck + existing tests green; no badge is declared inside a component body (grep-level check).
### U4. Keep-alive layer for Planning Mode
- **Goal:** Planning Mode survives main-view navigation mounted-but-hidden after first open.
- **Requirements:** R5, R8, R10
- **Dependencies:** none (first structural unit; U5–U6 follow its pattern)
- **Files:** `packages/dashboard/app/components/dashboard/MainContent.tsx` (planning branch ~696 moves to the keep-alive layer), `packages/dashboard/app/App.tsx` (or a new small wrapper component in `packages/dashboard/app/components/dashboard/`), a component CSS file for the hidden wrapper if a new class is needed, `packages/dashboard/app/components/PlanningModeModal.tsx` (accept an `active`-style prop to gate any subscriptions), tests in `packages/dashboard/app/__tests__/` and `packages/dashboard/app/components/__tests__/`
- **Approach:** After the user first opens Planning (`everOpened` latch, mirroring Quick Chat's `quickChatEverOpenedProjectId`), render the planning subtree persistently as a hidden-gated sibling of the MainContent switch; the switch's planning branch stops returning its own instance. Hide with the `visibility: hidden; pointer-events: none` contract plus `aria-hidden`. Pass `active={taskView === "planning"}` down so `PlanningModeModal` gates anything subscription-like; the session data layer (`useBackgroundSessions`) is SSE-driven at App level and stays untouched. Reset/unmount the kept-alive instance on project switch (key by project id, as Quick Chat does). The `PlanningWorkflowSwitcherSlot` header portal must only portal while planning is the active view.
- Nav-stack check (R10): the embedded planning view participates in `handleChangeTaskView` navigation, not `pushNav` modal history — confirm and note; `modalManager.closePlanning()` semantics (clearing entry-point payload) must still fire on explicit close even though the tree stays mounted.
- **Patterns to follow:** Quick Chat block in `App.tsx`; `FloatingWindow` hidden contract and its CSS; `taskSseEnabled` view-gating precedent.
- **Test scenarios:**
- Happy path: open Planning, enter interview state (mock a session with view state / streaming text), navigate to Board, navigate back — same component instance, ViewState and streaming output intact, no loading flash (assert via the `recordResumeEvent` instrumentation: `route-active` rather than `remount` trigger).
- Hidden-work gating: while hidden, the wrapper carries `visibility: hidden` + `aria-hidden="true"` and any `active`-gated subscription inside PlanningModeModal is closed (spy on the gated hook/effect).
- First-mount laziness: before Planning is ever opened, no PlanningModeModal instance exists.
- Project switch: switching projects tears down and replaces the kept-alive instance.
- Explicit close: `closePlanning` still clears modalManager payload and returns to board.
- Mobile breakpoint: the hidden wrapper does not affect mobile layout (no stray scroll containers or fixed footers while hidden).
- **Verification:** New tests green; `lazy-loaded-views-docs.test.ts` untouched and green; manual smoke — mid-interview navigation round-trip preserves the conversation.
### U5. Keep-alive for task-detail terminal and planner-chat tabs
- **Goal:** Tab switches inside TaskDetail stop destroying terminals and the planner chat.
- **Requirements:** R6, R8, R9, R10
- **Dependencies:** U4 (establishes the wrapper/CSS pattern)
- **Files:** `packages/dashboard/app/components/TaskDetailModal.tsx` (terminal ~5412, worktree-terminal ~5433, planner-chat ~5092 branches of the `activeTab` ternary), `packages/dashboard/app/components/SessionTerminal.tsx` (reveal refit hook-in if needed), tests in `packages/dashboard/app/components/__tests__/`
- **Approach:** Convert the three heavy tab bodies to persistent hidden-gated mounts (after first open of each tab, per-tab `everOpened` latch) while cheap tabs stay in the ternary. Keep the existing `active` props (`TaskPlannerChatTab active`, and thread an equivalent into the terminal components) so `useAgentLogs`-style EventSources close while hidden — the terminal WebSocket itself intentionally stays open (that is the point of keep-alive); confirm the server side tolerates a long-lived idle terminal socket per existing TerminalModal session persistence. On reveal, trigger a refit; per `docs/solutions/ui-bugs/xterm-options-noop-remeasure-after-font-settle.md`, a forced font remeasure may be needed if the no-op OptionsService guard swallows the refit. `visibility: hidden` keeps the container box non-zero so the ResizeObserver/FitAddon never sees a 0×0 box.
- Unmount-coupled check (R10): task switch (different `task.id`) and modal close must still fully unmount and dispose terminals — keep-alive is scoped to tab switching within one open task detail.
- **Patterns to follow:** `SessionTerminal`'s existing single-effect lifecycle (do not split its teardown semantics); `FloatingWindow` hidden CSS contract; `mobile-terminal-blank-render-zero-geometry-container.md` observer rules.
- **Test scenarios:**
- Happy path: open Terminal tab, switch to Definition, switch back — WebSocket not closed (spy), xterm not disposed, same instance.
- Planner chat: type a draft, switch tabs, return — draft and scroll state intact; `active=false` closed its EventSource while hidden (spy on `useAgentLogs` enabled transitions).
- Reveal geometry: on reveal, refit fires; container never reports zero size while hidden (assert the hidden style is `visibility`, not `display`).
- Teardown still works: closing the modal or switching to a different task disposes terminals and closes sockets exactly as today.
- Mobile breakpoint: hidden tab bodies don't stack scrollable regions on mobile.
- **Verification:** New tests green plus existing `SessionTerminal.test.tsx` / `TerminalModal.test.tsx` green; manual smoke — run a command, flip tabs, confirm no reconnect banner and scrollback/scroll position intact.
### U6. Popped-out task windows hide instead of unmount
- **Goal:** Off-origin-view popped-out windows stay alive.
- **Requirements:** R7, R8, R10
- **Dependencies:** U4 (pattern), U5 (embedded terminals inside popups benefit)
- **Files:** `packages/dashboard/app/App.tsx` (replace the `visiblePoppedOutTaskEntries` filter ~826-828 at the render site ~2039 with `hidden={...}`), `packages/dashboard/app/__tests__/App.taskPopupViewGating.test.tsx` (update assertions), `docs/dashboard-guide.md` (`FNXC:TaskPopupViewGating` line)
- **Approach:** Render all `poppedOutTaskEntries`; pass `hidden={!isTaskPopupVisibleForView({ taskPopupsBoardListOnly, taskView, originTaskView })}` to each `FloatingWindow`. The predicate, setting semantics, and per-origin-view addressability are unchanged. Update the FNXC comment and dashboard-guide text from "returning remounts them" to render-only hiding. Nav-stack check (R10): explicitly closing a popup (visible or hidden) must still remove its nav entry with the same stable callback.
- **Patterns to follow:** Quick Chat `hidden={!quickChatOpen}`; existing `FloatingWindow` hidden test assertions.
- **Test scenarios:**
- Happy path: with `taskPopupsBoardListOnly` on, pop out a task from Board, switch to Chat — the FloatingWindow is present with `aria-hidden="true"` and `visibility: hidden` (updated `expectNoTaskPopupShell` becomes "hidden, not absent"); switch back — same instance, visible.
- Setting off: popups visible on all views, unchanged.
- Close while hidden: programmatic/close paths remove the entry and its nav registration.
- Geometry: FloatingWindow suspends geometry persistence and outside-dismiss listeners while hidden (already covered by FloatingWindow tests; assert no regression at the App level).
- **Verification:** Updated gating tests green; `App.taskDetailFloatingGeometry.test.tsx` green.
### U7. Deep audit: Planning Mode internal transitions
- **Goal:** Find and fix state-discarding transitions inside `PlanningModeModal` itself (session switching, session-list mode, ViewState transitions, mobile detail flips) so internal navigation meets the same bar as U4.
- **Requirements:** R11
- **Dependencies:** U4 (so audit findings are measured against the kept-alive baseline)
- **Files:** `packages/dashboard/app/components/PlanningModeModal.tsx`, existing `PlanningModeModal.*.test.tsx` suites and `PlanningModeModal.test-helpers.ts` in `packages/dashboard/app/components/__tests__/`
- **Approach:** Audit pass over the internal transitions with the same lenses as the top-level audit: (a) conditional rendering that unmounts stateful panes when `showSessionList` / `isSessionListMode` / `mobileShowDetail` flip; (b) keys on session panes or history entries that embed volatile parts; (c) state resets keyed on cache-array identity (the SWR-churn class); (d) switching `selectedSessionId` — what in-flight state of the previous session is lost vs. reconstructable from the session row, and whether switching back re-attaches to a live stream. Fix what the audit confirms; record intentional resets (e.g. model change resetting the session scope) as documented behavior. Findings that require product decisions get listed in the PR rather than silently decided.
- **Execution note:** Audit-first — enumerate and classify transitions before changing any; land fixes as separate commits per finding.
- **Test scenarios:** (per confirmed finding, at minimum:)
- Toggling session-list mode and back preserves the active session's interview pane state.
- Switching between two sessions and back re-attaches/restores the first session's view without a full reload flash, to the extent the session row supports it.
- Mobile `mobileShowDetail` flip round-trip preserves detail pane state.
- **Verification:** Audit findings documented in the PR description; each fix carries a failing-then-passing test; existing PlanningModeModal suites green.
### U8. Lifted-state preservation for CommandCenter and DevServerView
- **Goal:** Cheap UI state survives the unmount round-trip for the two remaining frequently-used heavy views without keeping them mounted.
- **Requirements:** R12
- **Dependencies:** none (parallel to U4–U7)
- **Files:** `packages/dashboard/app/components/command-center/CommandCenter.tsx` (or its state hooks), `packages/dashboard/app/components/DevServerView.tsx`, a small persistence helper if none fits (check for an existing localStorage-backed hook first per the reuse rule), sibling tests
- **Approach:** Lift `activeTab` + date range (CommandCenter) and selected script/task + command input (DevServerView) into persisted state (module-level ref or localStorage-backed hook, following the `useBoardScrollRestore` externalize-don't-keep-mounted approach and the planning-description `getPlanningDescription` localStorage precedent). Log pagination/scroll may use the boardScrollSnapshot-style ref pattern if cheap; otherwise defer.
- **Test scenarios:**
- CommandCenter: set sub-tab + range, unmount, remount — both restored; a fresh project/session gets defaults.
- DevServerView: typed-but-unsent command survives an unmount round-trip; selected script restored.
- **Verification:** New tests green; no new polling while unmounted (nothing mounted — trivially true; assert no module-level timers introduced).
### U9. Invariant regression suite, symptom verification, and release notes
- **Goal:** Encode the "no unnecessary remounts" invariant across all enumerated surfaces and finish the bug-class paperwork.
- **Requirements:** R13 (and closes the loop on R1–R12)
- **Dependencies:** U1–U8
- **Files:** test files added in U1–U8 (this unit fills coverage gaps found against the Surface Enumeration below), `.changeset/fix-dashboard-remount-churn.md`
- **Approach:** Sweep the Surface Enumeration checklist (below) against the tests landed in U1–U8; add missing breakpoint/data-state cases. Use `recordResumeEvent`'s `remount` vs `route-active` triggers as the instrumentation seam for "did this surface remount" assertions where component-identity probes are awkward. Add the changeset: `"@runfusion/fusion": patch`, `category: fix`, summary phrased for operators (terminals, planning sessions, and popped-out tasks no longer reset when switching views/tabs). No slow tests: all remount assertions run on jsdom with fake streams/spies — no real sockets, no polling waits.
- **Test scenarios:** Gap-fill only; the invariant cases live in their units. Explicitly confirm: desktop + mobile breakpoints for U4–U6 surfaces; empty/populated data states for the keyed lists (U1, U2).
- **Verification:** `pnpm verify:fast` green; file-scoped vitest runs for every touched test file green; changeset passes `pnpm check:changesets`.
---
## Symptom Verification
- **Original symptom:** Components across the dashboard unmount too often — streaming thinking blocks collapse mid-stream, terminals reconnect on tab switches, Planning Mode loses in-flight interview state on navigation, popped-out task windows vanish when leaving their origin view, task cards remount on reorder.
- **Exact reproduction:** (1) Expand a thinking block while an agent streams into it — it collapses on the next entry. (2) Open a task's Terminal tab, run a command, switch to Definition and back — WebSocket reconnects, scroll/unsent input lost. (3) Start a planning interview, navigate to Board and back — interview UI resets to a reloaded state. (4) With `taskPopupsBoardListOnly` on, pop out a task from Board and switch to Chat — the window unmounts. (5) Change a dock-listed task's status — its card remounts.
- **Assertion it is gone:** Automated tests in U1, U4, U5, U6, U2 respectively assert component-identity/instrumentation-level persistence for each reproduction (remount not observed, state preserved), plus the broader invariant coverage in U9.
## Surface Enumeration
- Keyed-list identity: `TaskChatTab` segments (task detail Activity chat), `DockTaskList` cards; checked that `TaskPlannerChatTab`'s internal keys (`toolName-index`) and other audited keys are stable — no change needed there.
- Keep-alive surfaces: Planning Mode (embedded main view), task-detail Terminal / Worktree-terminal / Planner-chat tabs (desktop and mobile tab bars both route through the same ternary), popped-out task FloatingWindows (board-list-only setting on and off).
- Shared components that must not regress: `FloatingWindow` (Quick Chat and task popups share it), `SessionTerminal` (task detail + anywhere else it mounts), `TaskChatSegmentView`/`TaskChatThinking`, `TaskCard` (board, list, dock, popup contexts — only the dock keying changes).
- Breakpoints: mobile (`max-width: 768px` or `max-height: 480px` — landscape phones included) and desktop for every keep-alive surface; hidden wrappers must not create stray scrollable/fixed elements on mobile.
- Data states: empty (no sessions/tasks/entries), populated, actively streaming, and duplicate-id defense where lists dedupe (`dedupeSessionsById`).
- Unmount-coupled behaviors re-checked per converted surface: nav-stack removal, `ExecutorStatusBar`-style revert-on-unmount, planner-oversight stale-value hazard, `modalManager` payload clearing.
---
## Scope Boundaries
- Re-render churn (context `value={{...}}` identity, memoization) is out of scope — different problem class from unmounting.
- CSS/styling work beyond the hidden-wrapper class is out of scope.
- Intentional remount-to-reset keys stay as-is: `MissionInterviewModal` counter key, `PreviewIframe` retry key, `TerminalModal` per-session container key (documented zero-geometry fix), task pop-out identity key, `BranchGroupCard` collapse-on-switch key.
### Deferred to Follow-Up Work
- ChatView keep-alive or scroll/session-selection preservation for the main Chat view (currently cushioned by SWR cache + server stream re-attach; also the known gap that hidden Quick Chat does not gate ChatView's effects).
- Gating ChatView SSE/read-marking effects while Quick Chat is hidden (pre-existing gap, documented in research).
- Keep-alive for remaining cheap views (Agents, Documents, Insights, etc.) — they lose only filter/scroll state and remount cheaply.
- DevServerView log scroll-position restore if it turns out to need more than the snapshot-ref pattern.
---
## Risks & Dependencies
- **Memory growth from persistent subtrees.** Bounded by lazy-first-mount (only visited surfaces stay alive) and project-switch teardown. Watch: PlanningModeModal is large; if a session accumulates huge streaming buffers, hidden retention keeps them — acceptable for a single session, revisit if telemetry disagrees.
- **Hidden-surface background work.** The Quick Chat precedent shows hidden ≠ idle by default; every kept-alive surface must be explicitly `active`-gated (R8) and tests must spy on the gates, or we trade remount churn for permanent background load.
- **xterm reveal geometry.** `visibility: hidden` preserves the box, but ancestors introduced by the wrapper must not apply `display: none` or width caps (`mobile-terminal-collapsed-screen-zero-width-cell.md`); jsdom cannot prove rendered-grid correctness — manual verification on a real browser is required, and unavailable-device gaps are recorded per `docs/ios-acceptance.md` rather than claimed verified.
- **Behavior changes hiding behind "it stopped remounting".** Any state that a remount previously reset (planner oversight, unconfirmed edits, nav entries) now persists; U2/U4–U6 carry explicit checks, and the FNXC comments at each converted site are updated so the new lifecycle is encoded in-code.
- **SSE wholesale-replace staleness.** Mounted-but-hidden conversation surfaces keep receiving `chat:session:updated`-style events that degrade enrichment fields (`isGenerating`); per `docs/solutions/logic-errors/queued-chat-message-flush-trusts-stale-isgenerating.md`, no hidden surface may gate destructive actions on cached enrichment state on reveal.
- **Test updates that invert assertions.** `App.taskPopupViewGating.test.tsx` currently asserts absence; flipping to hidden-presence must preserve the setting's intent (popups don't clutter other views) — the assertion moves from "not in DOM" to "not visible/interactive".
---
## Sources & Research
- Audit findings (this session): confirmed sites `TaskChatTab.tsx:1045`, `DockTaskList.tsx:75`, `TaskDetailModal.tsx:5412/5433/5092`, `MainContent.tsx:696/811/674/791`, `App.tsx:826-828/2039`, `ModelOnboardingModal.tsx:1055/1092`, `McpServersCard.tsx:612`.
- Keep-alive primitive: `packages/dashboard/app/components/FloatingWindow.tsx` (`hidden` prop, visibility-not-display contract, effect suspension while hidden) and its regression test `packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx` ("keeps hidden children mounted…").
- Acceptance bar for keep-alive UX: `docs/dashboard-guide.md` Quick Chat section ("instant visibility restore with no conversation reload, layout reflow, or loading flash") and `FNXC:TaskPopupViewGating` (FN-8016).
- Polling gates: `packages/dashboard/app/hooks/useAgentLogs.ts` (EventSource on `enabled`), `taskSseEnabled` in `App.tsx`, `useBackgroundSessions` (SSE-driven, mount-independent).
- Terminal lifecycle constraints: `docs/solutions/ui-bugs/mobile-terminal-blank-render-zero-geometry-container.md`, `docs/solutions/ui-bugs/xterm-options-noop-remeasure-after-font-settle.md`, `docs/solutions/ui-bugs/mobile-terminal-collapsed-screen-zero-width-cell.md`.
- State-reset identity class: `docs/solutions/ui-bugs/skill-autocomplete-highlight-reset-on-swr-revalidation.md`.
- Nav/modal lifecycle: `docs/solutions/ui-bugs/navigation-history-stale-modal-stack.md`.
- Remount instrumentation: `packages/dashboard/app/utils/resumeInstrumentation.ts` (`remount` / `route-active` / `route-inactive` triggers) and its tests.
- Structural guard: `packages/dashboard/app/__tests__/lazy-loaded-views-docs.test.ts` (lazy-view inventory contract with AGENTS.md).

View File

@@ -115,6 +115,7 @@ export {
import { subscribeSse } from "./sse-bus"; import { subscribeSse } from "./sse-bus";
import { AuthTokenRecoveryDialog } from "./components/AuthTokenRecoveryDialog"; import { AuthTokenRecoveryDialog } from "./components/AuthTokenRecoveryDialog";
import { MainContent } from "./components/dashboard/MainContent"; import { MainContent } from "./components/dashboard/MainContent";
import { PlanningKeepAlive } from "./components/dashboard/PlanningKeepAlive";
import { NATIVE_STRUCTURE_OPEN_EVENT, type NativeStructureOpenEventDetail } from "./components/nativeStructureNavigation"; import { NATIVE_STRUCTURE_OPEN_EVENT, type NativeStructureOpenEventDetail } from "./components/nativeStructureNavigation";
import { DashboardBanners } from "./components/dashboard/DashboardBanners"; import { DashboardBanners } from "./components/dashboard/DashboardBanners";
import type { DashboardBannersProps, MainContentProps } from "./components/dashboard/types"; import type { DashboardBannersProps, MainContentProps } from "./components/dashboard/types";
@@ -688,6 +689,25 @@ function AppInner() {
} }
}, [currentProject?.id, quickChatOpen]); }, [currentProject?.id, quickChatOpen]);
/*
FNXC:PlanningKeepAlive 2026-07-22-12:30:
Planning Mode mounts only after its first open for the current project, then stays mounted-but-hidden across sidebar navigation so the interview survives round-trips (FN remount-churn fix R5). This mirrors Quick Chat's quickChatEverOpenedProjectId latch above: reset on project change so one project's kept-alive interview can never leak into another; the PlanningKeepAlive key supplies the matching React identity boundary.
*/
const [planningEverOpenedProjectId, setPlanningEverOpenedProjectId] = useState<string | null>(null);
const planningLatchProjectIdRef = useRef<string | undefined>(undefined);
const planningViewActive = taskView === "planning";
useEffect(() => {
const projectId = currentProject?.id;
if (planningLatchProjectIdRef.current !== projectId) {
planningLatchProjectIdRef.current = projectId;
setPlanningEverOpenedProjectId(planningViewActive && projectId ? projectId : null);
return;
}
if (planningViewActive && projectId) {
setPlanningEverOpenedProjectId(projectId);
}
}, [currentProject?.id, planningViewActive]);
/* /*
FNXC:GitHubImportChat 2026-07-30-12:00: FNXC:GitHubImportChat 2026-07-30-12:00:
Import Tasks can hand a selected GitHub link to Quick Chat without creating a task. Keep the Import Tasks can hand a selected GitHub link to Quick Chat without creating a task. Keep the
@@ -837,7 +857,10 @@ function AppInner() {
}), [taskPopupsBoardListOnly, taskView]); }), [taskPopupsBoardListOnly, taskView]);
/* /*
FNXC:TaskPopupViewGating 2026-07-15-15:20: FNXC:TaskPopupViewGating 2026-07-15-15:20:
FN-8016 defaults popup rendering to the originating view for every dashboard surface. Entries without an origin are legacy snapshots and intentionally remain globally visible; navigating away unmounts scoped FloatingWindow shells without deleting their state. FN-8016 defaults popup rendering to the originating view for every dashboard surface. Entries without an origin are legacy snapshots and intentionally remain globally visible.
FNXC:TaskPopupViewGating 2026-07-22-13:15:
FN remount-churn fix R7: this memo no longer drives the render list (all entries render; off-view windows hide via FloatingWindow `hidden`). It remains the "currently visible" set consumed by the Escape/back-navigation shortcut handler below, which must only dismiss windows the user can actually see.
*/ */
const visiblePoppedOutTaskEntries = useMemo( const visiblePoppedOutTaskEntries = useMemo(
() => poppedOutTaskEntries.filter((entry) => taskPopupsVisibleOnCurrentView(entry.originTaskView)), () => poppedOutTaskEntries.filter((entry) => taskPopupsVisibleOnCurrentView(entry.originTaskView)),
@@ -1623,7 +1646,6 @@ function AppInner() {
isRemote, isRemote,
remoteData, remoteData,
tasks, tasks,
bgPlanningSessions,
workflowSteps, workflowSteps,
subscribePluginEvents, subscribePluginEvents,
openDetailTask, openDetailTask,
@@ -1669,8 +1691,6 @@ function AppInner() {
ingestCreatedTasks, ingestCreatedTasks,
nodesEnabled, nodesEnabled,
openWorkflowEditorWithNav, openWorkflowEditorWithNav,
handlePlanningTaskCreated,
handlePlanningTasksCreated,
handleGitHubImport, handleGitHubImport,
devServerEnabled, devServerEnabled,
mainPanelDetailTask, mainPanelDetailTask,
@@ -1927,6 +1947,25 @@ function AppInner() {
className={`project-content${executorFooterVisible && (!isMobile || !mobileKeyboardOpen) ? " project-content--with-footer" : ""}${isMobile && mobileNavVisible && !mobileKeyboardOpen ? " project-content--with-mobile-nav" : ""}`} className={`project-content${executorFooterVisible && (!isMobile || !mobileKeyboardOpen) ? " project-content--with-footer" : ""}${isMobile && mobileNavVisible && !mobileKeyboardOpen ? " project-content--with-mobile-nav" : ""}`}
> >
<MainContent {...mainContentProps} /> <MainContent {...mainContentProps} />
{/*
FNXC:PlanningKeepAlive 2026-07-22-12:30:
Kept-alive Planning Mode renders as a sibling of the MainContent switch inside .project-content (which is position:relative for the hidden out-of-flow overlay state). Keyed by project id + planningEntryGeneration so project switches and payload-carrying planning entry points remount with fresh-open semantics while plain navigation restores the live instance.
*/}
{viewMode === "project" && currentProject && planningEverOpenedProjectId === currentProject.id && (
<PlanningKeepAlive
key={`${currentProject.id}:${modalManager.planningEntryGeneration}`}
active={planningViewActive}
projectId={currentProject.id}
tasks={tasks}
bgPlanningSessions={bgPlanningSessions}
modalManager={modalManager}
handleChangeTaskView={handleTaskViewChange}
handlePlanningTaskCreated={handlePlanningTaskCreated}
handlePlanningTasksCreated={handlePlanningTasksCreated}
openBoardTaskDetail={openBoardTaskDetail}
openWorkflowEditorWithNav={openWorkflowEditorWithNav}
/>
)}
</div> </div>
{rightDock.dock} {rightDock.dock}
</div> </div>
@@ -2110,18 +2149,20 @@ function AppInner() {
moves either overlapping surface above the other. Other utility windows retain their separate, moves either overlapping surface above the other. Other utility windows retain their separate,
higher utility band and cannot be reordered by task-popup interaction. higher utility band and cannot be reordered by task-popup interaction.
FNXC:TaskPopupViewGating 2026-07-15-15:20: FNXC:TaskPopupViewGating 2026-07-22-13:15:
Rendering uses only the active view's scoped entries; state keeps hidden snapshots so returning remounts them with shared geometry. Each FloatingWindow key includes its origin so identical task ids never collide across views. FN remount-churn fix R7 supersedes the FN-8016 remount behavior: ALL popped-out entries render, and off-origin-view windows are hidden via FloatingWindow's hidden contract (visibility-based, aria-hidden, effects suspended) instead of being filtered out of the render array. Returning to the origin view is an instant reveal of the live window — the embedded task detail, including an open terminal WebSocket, stays mounted. The `isTaskPopupVisibleForView` predicate and per-origin-view addressability are unchanged; `visiblePoppedOutTaskEntries` still feeds the Escape/nav-shortcut consumer. While hidden, `active={false}` closes the detail's SSE/EventSource channels (R8). Each FloatingWindow key includes its origin so identical task ids never collide across views.
*/} */}
{visiblePoppedOutTaskEntries.map(({ task: snapshot, originTaskView, initialTab }) => { {poppedOutTaskEntries.map(({ task: snapshot, originTaskView, initialTab }) => {
const liveTask = tasks.find((candidate) => candidate.id === snapshot.id) ?? snapshot; const liveTask = tasks.find((candidate) => candidate.id === snapshot.id) ?? snapshot;
const popupKey = taskPopupIdentityKey(snapshot.id, originTaskView); const popupKey = taskPopupIdentityKey(snapshot.id, originTaskView);
const close = () => closePoppedOutTaskWithNav(snapshot.id, originTaskView); const close = () => closePoppedOutTaskWithNav(snapshot.id, originTaskView);
const popupVisible = taskPopupsVisibleOnCurrentView(originTaskView);
return ( return (
<FloatingWindow <FloatingWindow
key={popupKey} key={popupKey}
windowKey={`task-detail-${snapshot.id}-${originTaskView ?? "global"}`} windowKey={`task-detail-${snapshot.id}-${originTaskView ?? "global"}`}
title={liveTask.id} title={liveTask.id}
hidden={!popupVisible}
onClose={close} onClose={close}
hideHeader hideHeader
dragHandleSelector=".task-detail-content--embedded > .modal-header" dragHandleSelector=".task-detail-content--embedded > .modal-header"
@@ -2136,6 +2177,7 @@ function AppInner() {
initialTab={initialTab} initialTab={initialTab}
projectId={currentProject?.id} projectId={currentProject?.id}
tasks={tasks} tasks={tasks}
active={popupVisible}
embedded embedded
onOpenDetail={popOutTaskDetailForCurrentView} onOpenDetail={popOutTaskDetailForCurrentView}
onMoveTask={moveTask} onMoveTask={moveTask}

View File

@@ -14,35 +14,52 @@ function popupTestId(taskId: string, originTaskView?: TaskView) {
return `floating-window-task-detail-${taskId}-${originTaskView ?? "global"}`; return `floating-window-task-detail-${taskId}-${originTaskView ?? "global"}`;
} }
/*
FNXC:TaskPopupViewGating 2026-07-22-13:20:
FN remount-churn fix R7 changed the render contract this harness mirrors: App renders ALL popped-out entries and hides off-origin-view windows via FloatingWindow `hidden` (visibility-based, aria-hidden) instead of filtering the render array. The `isTaskPopupVisibleForView` predicate is unchanged; the assertion for an off-view popup moved from "not in DOM" to "hidden and inert" so the setting's intent (popups do not clutter other views) still holds while the embedded task detail stays mounted.
*/
function PopupGateHarness({ entries, taskView, taskPopupsBoardListOnly }: { function PopupGateHarness({ entries, taskView, taskPopupsBoardListOnly }: {
entries: PoppedOutTaskEntry[]; entries: PoppedOutTaskEntry[];
taskView: TaskView; taskView: TaskView;
taskPopupsBoardListOnly: boolean; taskPopupsBoardListOnly: boolean;
}) { }) {
return <>{entries.filter((entry) => isTaskPopupVisibleForView({ taskPopupsBoardListOnly, taskView, originTaskView: entry.originTaskView })).map(({ task: snapshot, originTaskView }) => { return <>{entries.map(({ task: snapshot, originTaskView }) => {
const windowKey = `task-detail-${snapshot.id}-${originTaskView ?? "global"}`; const windowKey = `task-detail-${snapshot.id}-${originTaskView ?? "global"}`;
return <FloatingWindow key={windowKey} windowKey={windowKey} title={snapshot.id} onClose={() => {}} hideHeader dragHandleSelector=".task-detail-content--embedded > .modal-header" className="floating-window--task-detail" persistGeometryKey={TASK_DETAIL_FLOATING_GEOMETRY_KEY} layer="task-detail"> const hidden = !isTaskPopupVisibleForView({ taskPopupsBoardListOnly, taskView, originTaskView });
return <FloatingWindow key={windowKey} windowKey={windowKey} title={snapshot.id} hidden={hidden} onClose={() => {}} hideHeader dragHandleSelector=".task-detail-content--embedded > .modal-header" className="floating-window--task-detail" persistGeometryKey={TASK_DETAIL_FLOATING_GEOMETRY_KEY} layer="task-detail">
<div className="task-detail-content--embedded"><div className="modal-header">{snapshot.id}</div></div> <div className="task-detail-content--embedded"><div className="modal-header">{snapshot.id}</div></div>
</FloatingWindow>; </FloatingWindow>;
})}</>; })}</>;
} }
function expectNoTaskPopupShell(taskId: string, originTaskView?: TaskView) { function expectHiddenTaskPopupShell(taskId: string, originTaskView?: TaskView) {
const id = popupTestId(taskId, originTaskView); const id = popupTestId(taskId, originTaskView);
expect(screen.queryByTestId(id)).not.toBeInTheDocument(); // Hidden, not absent: the window stays mounted (terminal/detail state survives) but is invisible and inert.
expect(screen.queryByTestId(id.replace("floating-window-", "floating-window-overlay-"))).not.toBeInTheDocument(); const window = screen.getByTestId(id);
expect(window).toBeInTheDocument();
const overlay = screen.getByTestId(id.replace("floating-window-", "floating-window-overlay-"));
expect(overlay).toHaveAttribute("aria-hidden", "true");
expect(overlay.className).toContain("floating-window-overlay--hidden");
}
function expectVisibleTaskPopupShell(taskId: string, originTaskView?: TaskView) {
const id = popupTestId(taskId, originTaskView);
expect(screen.getByTestId(id)).toBeInTheDocument();
const overlay = screen.getByTestId(id.replace("floating-window-", "floating-window-overlay-"));
expect(overlay).not.toHaveAttribute("aria-hidden");
expect(overlay.className).not.toContain("floating-window-overlay--hidden");
} }
const origins: TaskView[] = ["board", "list", "planning", "agents", "command-center", "missions", "documents", "plugin:sample"]; const origins: TaskView[] = ["board", "list", "planning", "agents", "command-center", "missions", "documents", "plugin:sample"];
describe("App task popup view gating", () => { describe("App task popup view gating", () => {
it.each(origins)("renders a %s-origin popup only on its origin when scoping is enabled", (originTaskView) => { it.each(origins)("shows a %s-origin popup only on its origin when scoping is enabled, hiding it elsewhere", (originTaskView) => {
const entry = { task: task(`FN-8016-${originTaskView}`), originTaskView }; const entry = { task: task(`FN-8016-${originTaskView}`), originTaskView };
const { rerender } = render(<PopupGateHarness taskView={originTaskView} taskPopupsBoardListOnly entries={[entry]} />); const { rerender } = render(<PopupGateHarness taskView={originTaskView} taskPopupsBoardListOnly entries={[entry]} />);
expect(screen.getByTestId(popupTestId(entry.task.id, originTaskView))).toBeInTheDocument(); expectVisibleTaskPopupShell(entry.task.id, originTaskView);
rerender(<PopupGateHarness taskView="settings" taskPopupsBoardListOnly entries={[entry]} />); rerender(<PopupGateHarness taskView="settings" taskPopupsBoardListOnly entries={[entry]} />);
expectNoTaskPopupShell(entry.task.id, originTaskView); expectHiddenTaskPopupShell(entry.task.id, originTaskView);
}); });
it("reproduces the planning-origin symptom and keeps another non-board/list view scoped", () => { it("reproduces the planning-origin symptom and keeps another non-board/list view scoped", () => {
@@ -53,16 +70,22 @@ describe("App task popup view gating", () => {
it("treats legacy undefined-origin snapshots as globally visible", () => { it("treats legacy undefined-origin snapshots as globally visible", () => {
render(<PopupGateHarness taskView="planning" taskPopupsBoardListOnly entries={[{ task: task("FN-8016-legacy") }]} />); render(<PopupGateHarness taskView="planning" taskPopupsBoardListOnly entries={[{ task: task("FN-8016-legacy") }]} />);
expect(screen.getByTestId(popupTestId("FN-8016-legacy"))).toBeInTheDocument(); expectVisibleTaskPopupShell("FN-8016-legacy");
}); });
it("unmounts on navigation and remounts the original scoped entry", () => { it("keeps the same window instance mounted across navigation away and back", () => {
const entry = { task: task("FN-8016-remount"), originTaskView: "planning" as const }; const entry = { task: task("FN-8016-keepalive"), originTaskView: "planning" as const };
const { rerender } = render(<PopupGateHarness taskView="planning" taskPopupsBoardListOnly entries={[entry]} />); const { rerender } = render(<PopupGateHarness taskView="planning" taskPopupsBoardListOnly entries={[entry]} />);
expect(screen.getByTestId(popupTestId(entry.task.id, entry.originTaskView))).toBeInTheDocument(); expectVisibleTaskPopupShell(entry.task.id, entry.originTaskView);
const windowNode = screen.getByTestId(popupTestId(entry.task.id, entry.originTaskView));
rerender(<PopupGateHarness taskView="agents" taskPopupsBoardListOnly entries={[entry]} />); rerender(<PopupGateHarness taskView="agents" taskPopupsBoardListOnly entries={[entry]} />);
expectNoTaskPopupShell(entry.task.id, entry.originTaskView); expectHiddenTaskPopupShell(entry.task.id, entry.originTaskView);
// Same DOM node — the window was hidden, never unmounted.
expect(screen.getByTestId(popupTestId(entry.task.id, entry.originTaskView))).toBe(windowNode);
rerender(<PopupGateHarness taskView="planning" taskPopupsBoardListOnly entries={[entry]} />); rerender(<PopupGateHarness taskView="planning" taskPopupsBoardListOnly entries={[entry]} />);
expect(screen.getByTestId(popupTestId(entry.task.id, entry.originTaskView))).toBeInTheDocument(); expectVisibleTaskPopupShell(entry.task.id, entry.originTaskView);
expect(screen.getByTestId(popupTestId(entry.task.id, entry.originTaskView))).toBe(windowNode);
}); });
}); });

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { getDevServerState, saveDevServerState } from "../hooks/modalPersistence";
import { isWipColumnRole } from "../utils/columnRoles"; import { isWipColumnRole } from "../utils/columnRoles";
import type { RefObject } from "react"; import type { RefObject } from "react";
import type { TFunction } from "i18next"; import type { TFunction } from "i18next";
@@ -235,10 +236,27 @@ export function DevServerView({ addToast, projectId, tasks, columnFlagsByTaskId
The 480px threshold catches the dock's compact range before preview chrome becomes unusable while preserving full-page, true mobile viewport, and expanded pop-out inline previews. The 480px threshold catches the dock's compact range before preview chrome becomes unusable while preserving full-page, true mobile viewport, and expanded pop-out inline previews.
*/ */
const [showCandidates, setShowCandidates] = useState(true); const [showCandidates, setShowCandidates] = useState(true);
const [commandInput, setCommandInput] = useState(""); /*
FNXC:DevServer 2026-07-22-13:40:
FN remount-churn fix R12: this view unmounts on navigation by design (no keep-alive), so the selected script/task target and a typed-but-unsent command restore from per-project persisted state on remount (modalPersistence precedent). Log pagination/scroll intentionally re-derives live.
*/
const [commandInput, setCommandInput] = useState(() => getDevServerState(projectId)?.commandInput ?? "");
const [previewInput, setPreviewInput] = useState(""); const [previewInput, setPreviewInput] = useState("");
const [selectedScript, setSelectedScript] = useState<string | null>(null); const [selectedScript, setSelectedScript] = useState<string | null>(() => getDevServerState(projectId)?.selectedScript ?? null);
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null); const [selectedTaskId, setSelectedTaskId] = useState<string | null>(() => getDevServerState(projectId)?.selectedTaskId ?? null);
const devServerPersistProjectRef = useRef(projectId);
useEffect(() => {
if (devServerPersistProjectRef.current === projectId) return;
devServerPersistProjectRef.current = projectId;
const stored = getDevServerState(projectId);
setCommandInput(stored?.commandInput ?? "");
setSelectedScript(stored?.selectedScript ?? null);
setSelectedTaskId(stored?.selectedTaskId ?? null);
}, [projectId]);
useEffect(() => {
if (devServerPersistProjectRef.current !== projectId) return;
saveDevServerState({ selectedScript, selectedTaskId, commandInput }, projectId);
}, [commandInput, projectId, selectedScript, selectedTaskId]);
const [actionInFlight, setActionInFlight] = useState<"start" | "stop" | "restart" | "preview" | null>(null); const [actionInFlight, setActionInFlight] = useState<"start" | "stop" | "restart" | "preview" | null>(null);
/* /*
@@ -371,7 +389,11 @@ export function DevServerView({ addToast, projectId, tasks, columnFlagsByTaskId
} }
if (selectedCandidate) { if (selectedCandidate) {
setCommandInput(selectedCandidate.command); /*
FNXC:DevServer 2026-07-22-13:50:
Never clobber a non-empty command with the candidate default: the user may have customized the command after selecting the script (explicit candidate clicks still sync it via handleSelectCandidate), and R12's restored typed-but-unsent command must survive the remount this effect runs on.
*/
setCommandInput((current) => (current.trim().length > 0 ? current : selectedCandidate.command));
return; return;
} }

View File

@@ -24,6 +24,18 @@ The Tasks tab empty state is a real compact task list, not a blank placeholder.
FNXC:RightDockTasks 2026-06-28-18:25: FNXC:RightDockTasks 2026-06-28-18:25:
The compact right-dock Tasks list is an active-work queue by default. It hides completed work until the local Show Done toggle is enabled and never renders archived tasks, including in the expanded dock modal that reuses this component. The compact right-dock Tasks list is an active-work queue by default. It hides completed work until the local Show Done toggle is enabled and never renders archived tasks, including in the expanded dock modal that reuses this component.
*/ */
/*
FNXC:RightDockTasks 2026-07-22-12:05:
Row-key helper: the first occurrence of an id keys as the bare id (stable across reorders — no remount), later occurrences of the same id get an occurrence suffix so duplicate-id data never produces React duplicate-key warnings.
*/
function dockRowKey(taskId: string, index: number, list: Array<Task | TaskDetail>): string {
let occurrence = 0;
for (let i = 0; i < index; i += 1) {
if (list[i].id === taskId) occurrence += 1;
}
return occurrence === 0 ? taskId : `${taskId}--dup-${occurrence}`;
}
export function DockTaskList({ columnFlagsByTaskId, export function DockTaskList({ columnFlagsByTaskId,
tasks, tasks,
projectId, projectId,
@@ -90,8 +102,14 @@ export function DockTaskList({ columnFlagsByTaskId,
<p className="dock-task-list__empty-title">{emptyTitle}</p> <p className="dock-task-list__empty-title">{emptyTitle}</p>
<p className="dock-task-list__empty-copy">{emptyCopy}</p> <p className="dock-task-list__empty-copy">{emptyCopy}</p>
</div> </div>
) : visibleTasks.map((task, index) => ( ) : visibleTasks.map((task, index, list) => (
<div key={`${task.id}-${index}`} className="dock-task-list__row" data-testid={`dock-task-list-row-${task.id}`}> /*
FNXC:RightDockTasks 2026-07-22-12:05:
Rows are keyed by task.id (with an occurrence suffix only for duplicate ids). The old `${task.id}-${index}` key remounted every surviving TaskCard on any reorder, filter toggle, or status change, discarding card-local state (open menus, edit drafts).
Keying by id also guarantees an instance never migrates between tasks, so no stale per-card oversight/authorization state can cross tasks; TaskCard's FN-8251 render guard covers within-instance prop switches.
Duplicate ids (a data anomaly this list deliberately tolerates) keep distinct identities without duplicate-key warnings via the occurrence count.
*/
<div key={dockRowKey(task.id, index, list)} className="dock-task-list__row" data-testid={`dock-task-list-row-${task.id}`}>
<TaskCard <TaskCard
task={task as Task} task={task as Task}
projectId={projectId} projectId={projectId}

View File

@@ -0,0 +1,18 @@
/*
FNXC:KeepAlive 2026-07-22-12:20:
Keep-alive wrapper for mounted-but-hidden surfaces. Visible: plain in-flow flex child filling the host (so footer-safe padding and flex sizing chains apply). Hidden: out-of-flow absolute overlay (no layout space consumed) that keeps a real box via visibility (never display:none) so xterm/FitAddon geometry inside never collapses to zero (see mobile-terminal-blank-render-zero-geometry-container.md).
*/
.keep-alive-view {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
min-height: 0;
}
.keep-alive-view--hidden {
position: absolute;
inset: 0;
visibility: hidden;
pointer-events: none;
}

View File

@@ -0,0 +1,30 @@
import type { ReactNode } from "react";
import "./KeepAliveView.css";
/*
FNXC:KeepAlive 2026-07-22-12:20:
Shared wrapper for surfaces that stay mounted-but-hidden across navigation (Planning Mode main view, task-detail terminal/planner-chat tabs).
Contract, mirroring FloatingWindow's hidden branch:
- Hiding uses `visibility: hidden` + `pointer-events: none`, NEVER `display: none` — xterm's FitAddon floors a zero-geometry container to a degenerate 2x1 grid permanently (docs/solutions/ui-bugs/mobile-terminal-blank-render-zero-geometry-container.md), so the hidden box must keep real dimensions.
- The hidden state is out-of-flow (position: absolute; inset: 0): a hidden in-flow flex child would still occupy layout space beside the active view. While visible the wrapper is a plain in-flow flex child so footer padding and definite flex sizing chains keep working.
- Hidden wrappers carry aria-hidden so assistive tech never walks a kept-alive invisible subtree.
The host container must be `position: relative` so the hidden absolute box keeps its (non-zero) size.
*/
export interface KeepAliveViewProps {
hidden: boolean;
children: ReactNode;
className?: string;
testId?: string;
}
export function KeepAliveView({ hidden, children, className, testId }: KeepAliveViewProps) {
return (
<div
className={`keep-alive-view${hidden ? " keep-alive-view--hidden" : ""}${className ? ` ${className}` : ""}`}
aria-hidden={hidden || undefined}
data-testid={testId}
>
{children}
</div>
);
}

View File

@@ -45,6 +45,7 @@ import {
type PlanningContextualComment, type PlanningContextualComment,
} from "../api"; } from "../api";
import { subscribeSse } from "../sse-bus"; import { subscribeSse } from "../sse-bus";
import { recordResumeEvent } from "../utils/resumeInstrumentation";
import { FloatingWindow } from "./FloatingWindow"; import { FloatingWindow } from "./FloatingWindow";
import { useEmbeddedPresentation, type ModalPresentation } from "../hooks/useEmbeddedPresentation"; import { useEmbeddedPresentation, type ModalPresentation } from "../hooks/useEmbeddedPresentation";
import { import {
@@ -162,6 +163,11 @@ interface PlanningModeModalProps {
initialSessions?: AiSessionSummary[]; initialSessions?: AiSessionSummary[];
/** Render without the full-screen modal chrome when Planning Mode is mounted as a top-level app view. */ /** Render without the full-screen modal chrome when Planning Mode is mounted as a top-level app view. */
presentation?: ModalPresentation; presentation?: ModalPresentation;
/*
FNXC:PlanningKeepAlive 2026-07-22-12:25:
Keep-alive visibility gate. The embedded Planning view stays mounted-but-hidden after its first open so navigation preserves ViewState, conversation, streaming output, and drafts (FN remount-churn fix R5). While `active` is false the session-list SSE subscription, the loading-state recovery poll, and the elapsed ticker are suspended (R8); the in-flight per-session stream connection intentionally stays open so a generating turn keeps accumulating. Defaults to true so the modal presentation is unaffected.
*/
active?: boolean;
} }
interface QuestionResponse { interface QuestionResponse {
@@ -468,7 +474,7 @@ function parseModelSelection(value: string): { provider?: string; modelId?: stri
}; };
} }
export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreated, onViewTask, tasks, initialPlan: initialPlanProp, onInitialPlanConsumed, projectId, workflowId, resumeSessionId, initialSessions, presentation = "modal" }: PlanningModeModalProps) { export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreated, onViewTask, tasks, initialPlan: initialPlanProp, onInitialPlanConsumed, projectId, workflowId, resumeSessionId, initialSessions, presentation = "modal", active = true }: PlanningModeModalProps) {
const { t } = useTranslation("app"); const { t } = useTranslation("app");
// FNXC:EmbeddedPresentation 2026-06-22-12:00: shared hook supplies isEmbedded (DOM branching) plus the modal-only gates. // FNXC:EmbeddedPresentation 2026-06-22-12:00: shared hook supplies isEmbedded (DOM branching) plus the modal-only gates.
// Note: the Escape handler intentionally does NOT gate on embedded here — embedded planning preserves its historical // Note: the Escape handler intentionally does NOT gate on embedded here — embedded planning preserves its historical
@@ -551,6 +557,33 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const [newSessionFocusSignal, setNewSessionFocusSignal] = useState(0); const [newSessionFocusSignal, setNewSessionFocusSignal] = useState(0);
const modalRef = useRef<HTMLDivElement>(null); const modalRef = useRef<HTMLDivElement>(null);
const streamConnectionRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null); const streamConnectionRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null);
// FNXC:PlanningKeepAlive 2026-07-22-12:25: true while the session-list SSE channel is suspended (hidden keep-alive); triggers a one-shot sessions refresh on reveal.
const sessionEventsSuspendedRef = useRef(false);
// FNXC:PlanningKeepAlive 2026-07-22-12:25: distinguishes first activation of this instance ("remount") from a keep-alive reveal ("route-active") for resume instrumentation.
const hasEverActivatedRef = useRef(false);
/*
FNXC:PlanningKeepAlive 2026-07-22-12:25:
Remount-vs-reveal instrumentation (mirrors Board's recordResumeEvent usage): the first activation of an instance records `remount`; later reveals of the kept-alive instance record `route-active` and hides record `route-inactive`. Regression tests assert a navigation round-trip yields route-active — never a second remount.
*/
useEffect(() => {
if (!isOpen || !active) return;
recordResumeEvent({
view: "PlanningMode",
trigger: hasEverActivatedRef.current ? "route-active" : "remount",
projectId,
replayAttempted: false,
});
hasEverActivatedRef.current = true;
return () => {
recordResumeEvent({
view: "PlanningMode",
trigger: "route-inactive",
projectId,
replayAttempted: false,
});
};
}, [active, isOpen, projectId]);
const streamConnectionEpochRef = useRef(0); const streamConnectionEpochRef = useRef(0);
const currentSessionIdRef = useRef<string | null>(null); const currentSessionIdRef = useRef<string | null>(null);
const viewRef = useRef<ViewState>({ type: "initial" }); const viewRef = useRef<ViewState>({ type: "initial" });
@@ -1141,6 +1174,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setElapsedSeconds(0); setElapsedSeconds(0);
return; return;
} }
// FNXC:PlanningKeepAlive 2026-07-22-12:25: no ticking while hidden (R8); elapsed recomputes from startedAt on reveal so the counter stays correct.
if (!active) return;
const startedAt = generationStartTime ?? Date.now(); const startedAt = generationStartTime ?? Date.now();
if (generationStartTime === null) { if (generationStartTime === null) {
@@ -1155,7 +1190,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const timer = setInterval(updateElapsed, 1000); const timer = setInterval(updateElapsed, 1000);
return () => clearInterval(timer); return () => clearInterval(timer);
}, [generationStartTime, view.type]); }, [active, generationStartTime, view.type]);
/* /*
FNXC:PlanningMultiTask 2026-07-24-01:40: FNXC:PlanningMultiTask 2026-07-24-01:40:
@@ -1186,6 +1221,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
// during normal generation. // during normal generation.
useEffect(() => { useEffect(() => {
if (view.type !== "loading") return; if (view.type !== "loading") return;
// FNXC:PlanningKeepAlive 2026-07-22-12:25: a hidden kept-alive Planning view must not poll (R8). A question/summary event dropped while hidden is recovered within one tick after reveal because this effect re-arms when `active` flips true.
if (!active) return;
let cancelled = false; let cancelled = false;
/* /*
@@ -1298,7 +1335,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
cancelled = true; cancelled = true;
clearInterval(interval); clearInterval(interval);
}; };
}, [projectId, resetPlanningAutoRetryBudget, t, view.type]); }, [active, projectId, resetPlanningAutoRetryBudget, t, view.type]);
const resetDetailState = useCallback((options?: { preserveInitialPlan?: boolean }) => { const resetDetailState = useCallback((options?: { preserveInitialPlan?: boolean }) => {
if (!options?.preserveInitialPlan) { if (!options?.preserveInitialPlan) {
@@ -2280,8 +2317,19 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
a stale question. Locally streamed turns keep their existing connection and reconcile in place. a stale question. Locally streamed turns keep their existing connection and reconcile in place.
*/ */
useEffect(() => { useEffect(() => {
if (!isOpen) return; /*
FNXC:PlanningKeepAlive 2026-07-22-12:25:
While the kept-alive view is hidden (`active` false) the session-list SSE channel is closed (R8) and this effect marks the gap; on reveal it re-subscribes and refreshes the list once so session events dropped while hidden cannot leave stale sidebar rows — the same recovery the onReconnect handler performs for a dropped channel.
*/
if (!isOpen || !active) {
sessionEventsSuspendedRef.current = true;
return;
}
const params = projectId ? `?projectId=${encodeURIComponent(projectId)}` : ""; const params = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
if (sessionEventsSuspendedRef.current) {
sessionEventsSuspendedRef.current = false;
void refreshSessionsList();
}
const handleUpdated = (e: MessageEvent) => { const handleUpdated = (e: MessageEvent) => {
try { try {
@@ -2320,7 +2368,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
void refreshSessionsList(); void refreshSessionsList();
}, },
}); });
}, [isOpen, loadSession, projectId, refreshSessionsList]); }, [active, isOpen, loadSession, projectId, refreshSessionsList]);
// Sidebar handlers // Sidebar handlers
const handleSelectSession = useCallback( const handleSelectSession = useCallback(

View File

@@ -648,6 +648,11 @@
min-width: 0; min-width: 0;
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;
/*
FNXC:PlanningKeepAlive 2026-07-22-12:30:
Containing block for kept-alive main views (KeepAliveView): their hidden state is position:absolute inset:0 so a hidden Planning subtree consumes no layout space while keeping a real (non-zero) box for xterm-safe geometry.
*/
position: relative;
} }
/** /**

View File

@@ -161,6 +161,11 @@ export interface SessionTerminalProps {
showConfirmAdvance?: boolean; showConfirmAdvance?: boolean;
/** Settings deep link for the posture chip tooltip. */ /** Settings deep link for the posture chip tooltip. */
onOpenAdapterSettings?: () => void; onOpenAdapterSettings?: () => void;
/*
FNXC:TaskDetailTerminalKeepAlive 2026-07-22-12:50:
Keep-alive visibility gate (FN remount-churn fix R6/R9). The task-detail Terminal tab keeps SessionTerminal mounted-but-hidden across tab switches so the WebSocket and scrollback survive. While hidden the box stays real (visibility-based hiding), so xterm geometry never collapses; on the hidden -> active transition we refit + force a font remeasure, and if the WebSocket died while hidden we re-run the whole attach lifecycle (fresh ticket + xterm) instead of revealing a dead terminal. Defaults to true so standalone mounts are unaffected.
*/
active?: boolean;
} }
interface AttachTicketResponse { interface AttachTicketResponse {
@@ -203,6 +208,7 @@ export function SessionTerminal({
onConfirmAdvance, onConfirmAdvance,
showConfirmAdvance = false, showConfirmAdvance = false,
onOpenAdapterSettings, onOpenAdapterSettings,
active = true,
}: SessionTerminalProps) { }: SessionTerminalProps) {
const { t } = useTranslation("app"); const { t } = useTranslation("app");
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
@@ -410,6 +416,33 @@ export function SessionTerminal({
return () => window.removeEventListener("storage", onStorage); return () => window.removeEventListener("storage", onStorage);
}, [applyLiveTerminalPreferences]); }, [applyLiveTerminalPreferences]);
/*
FNXC:TaskDetailTerminalKeepAlive 2026-07-22-12:50:
Reveal handling for the kept-alive hidden state (R9 + dead-socket recovery):
- Refit on reveal: the hidden visibility-based box keeps real geometry, but its size can change while hidden; mirror the init sequence (fit -> resize frame -> forced font remeasure -> refresh) because a same-value font reassignment is a no-op against xterm's OptionsService (docs/solutions/ui-bugs/xterm-options-noop-remeasure-after-font-settle.md).
- Dead-socket recovery: keep-alive intentionally leaves the WS open while hidden, but if the server closed it in the meantime, bump reattachEpoch so the main lifecycle effect re-runs the full attach (fresh ticket, fresh xterm) instead of revealing a dead terminal.
*/
const [reattachEpoch, setReattachEpoch] = useState(0);
useEffect(() => {
if (!active) return;
const term = xtermRef.current;
const fitAddon = fitAddonRef.current as unknown as { fit?: () => void } | null;
if (term && fitAddon?.fit) {
try {
fitAddon.fit();
sendResizeMessage(term.cols, term.rows);
forceTerminalFontRemeasure(term, String(term.options.fontFamily ?? ""));
term.refresh(0, Math.max(0, term.rows - 1));
} catch {
/* ignore transient measure failures on reveal */
}
}
const ws = wsRef.current;
if (ws && ws.readyState !== WebSocket.OPEN && ws.readyState !== WebSocket.CONNECTING) {
setReattachEpoch((epoch) => epoch + 1);
}
}, [active, sendResizeMessage]);
// ── xterm lifecycle + WS bridge ────────────────────────────────────────── // ── xterm lifecycle + WS bridge ──────────────────────────────────────────
useEffect(() => { useEffect(() => {
if (!sessionId || typeof window === "undefined") return; if (!sessionId || typeof window === "undefined") return;
@@ -745,7 +778,8 @@ export function SessionTerminal({
} }
fitAddonRef.current = null; fitAddonRef.current = null;
}; };
}, [sessionId, readOnly, mode, projectId, sendResizeMessage]); // FNXC:TaskDetailTerminalKeepAlive 2026-07-22-12:50: reattachEpoch re-runs this whole lifecycle when a reveal finds the WS dead (single-effect teardown semantics preserved).
}, [sessionId, readOnly, mode, projectId, sendResizeMessage, reattachEpoch]);
const replayLabel = useMemo(() => { const replayLabel = useMemo(() => {
if (mode === "idle") return t("cliTerminal.replayIdle", "Session idle"); if (mode === "idle") return t("cliTerminal.replayIdle", "Session idle");

View File

@@ -1091,7 +1091,13 @@ export function TaskChatTab({ task, columnFlags, projectId, active, addToast, on
</header> </header>
<div className="task-chat-group-bubbles"> <div className="task-chat-group-bubbles">
{segments.map((segment) => { {segments.map((segment) => {
const segmentKey = `${segment.kind}-${segment.startIndex}-${segment.entries.length}`; /*
FNXC:TaskChatSegmentKey 2026-07-22-12:00:
Segment identity is kind + startIndex only — never entries.length.
Embedding the count remounted the actively streaming segment on every appended entry, collapsing an expanded thinking block mid-stream.
A genuinely new segment always has a different startIndex, so identity stays correct without the volatile suffix.
*/
const segmentKey = `${segment.kind}-${segment.startIndex}`;
return <TaskChatSegmentView key={segmentKey} segment={segment} defaultOpen={thinkingDefaultOpen} />; return <TaskChatSegmentView key={segmentKey} segment={segment} defaultOpen={thinkingDefaultOpen} />;
})} })}
</div> </div>

View File

@@ -97,6 +97,11 @@ Task detail workflow badges share the board badge's slight token-based icon-to-l
scrollbar-color: var(--border) transparent; scrollbar-color: var(--border) transparent;
scrollbar-width: thin; scrollbar-width: thin;
flex: 1; flex: 1;
/*
FNXC:TaskDetailTabKeepAlive 2026-07-22-12:55:
Containing block for kept-alive tab bodies (KeepAliveView): their hidden state is position:absolute inset:0, so a hidden terminal/planner-chat body consumes no layout space beside the active tab while keeping a real box for xterm-safe geometry.
*/
position: relative;
} }
.detail-body::-webkit-scrollbar { .detail-body::-webkit-scrollbar {

View File

@@ -66,6 +66,7 @@ import { BranchGroupCard } from "./BranchGroupCard";
import { PluginSlot } from "./PluginSlot"; import { PluginSlot } from "./PluginSlot";
import { ProviderIcon } from "./ProviderIcon"; import { ProviderIcon } from "./ProviderIcon";
import { LoadingSpinner } from "./LoadingSpinner"; import { LoadingSpinner } from "./LoadingSpinner";
import { KeepAliveView } from "./KeepAliveView";
import { subscribeSse } from "../sse-bus"; import { subscribeSse } from "../sse-bus";
import type { SessionTerminalMode, SessionTerminalPosture } from "./SessionTerminal"; import type { SessionTerminalMode, SessionTerminalPosture } from "./SessionTerminal";
import { usePluginUiSlots } from "../hooks/usePluginUiSlots"; import { usePluginUiSlots } from "../hooks/usePluginUiSlots";
@@ -422,6 +423,11 @@ export type TaskDetailContentProps = Omit<TaskDetailModalProps, "onClose"> & {
onPopOut, when supplied, renders a Maximize2 "Pop out" button in the gray header. List/Board wire it to push this task into App's floating task-detail window array, opening the same embedded TaskDetailContent inside a movable, resizable, non-blocking FloatingWindow. It is independent of embedded/onBackToBoard so List split-pane and the board full-panel can both expose it. onPopOut, when supplied, renders a Maximize2 "Pop out" button in the gray header. List/Board wire it to push this task into App's floating task-detail window array, opening the same embedded TaskDetailContent inside a movable, resizable, non-blocking FloatingWindow. It is independent of embedded/onBackToBoard so List split-pane and the board full-panel can both expose it.
*/ */
onPopOut?: (task: Task) => void; onPopOut?: (task: Task) => void;
/*
FNXC:TaskPopupViewGating 2026-07-22-13:15:
Keep-alive visibility gate (FN remount-churn fix R7/R8). Popped-out task FloatingWindows now hide instead of unmounting when the user leaves their origin view, so the embedded TaskDetailContent stays mounted with its terminal WebSocket alive. While `active` is false the detail's SSE subscriptions (workflow results, CLI session state) and useAgentLogs EventSource are closed, and the tab-level `active` gates (chat, planner-chat, terminal) are forced inactive — the terminal WS itself intentionally stays open. Defaults to true so every other host is unaffected.
*/
active?: boolean;
}; };
function truncate(s: string, max: number): string { function truncate(s: string, max: number): string {
@@ -740,6 +746,7 @@ export function TaskDetailContent({
taskDetailChatFirst = false, taskDetailChatFirst = false,
mobileHeaderMode = "close", mobileHeaderMode = "close",
embedded = false, embedded = false,
active = true,
onRequestClose, onRequestClose,
onBackToBoard, onBackToBoard,
onPopOut, onPopOut,
@@ -753,6 +760,22 @@ export function TaskDetailContent({
const [activityExpanded, setActivityExpanded] = useState(false); const [activityExpanded, setActivityExpanded] = useState(false);
const [plannerChatExpanded, setPlannerChatExpanded] = useState(false); const [plannerChatExpanded, setPlannerChatExpanded] = useState(false);
/*
FNXC:TaskDetailTabKeepAlive 2026-07-22-12:55:
FN remount-churn fix R6: the Terminal, Worktree-terminal, and Planner-chat tab bodies previously lived in the mutually-exclusive activeTab ternary, so every tab flip disposed the xterm instance, closed the terminal WebSocket, and discarded the planner composer/scroll. After a tab's first open (per-tab latch, mirroring Quick Chat's everOpened gate) its body stays mounted as a hidden KeepAliveView sibling of the ternary. The latches are scoped to one task id: switching tasks (or closing the detail) resets them so terminals fully unmount and dispose exactly as before — keep-alive covers tab switching within ONE open task detail only (R10).
*/
const [keepAliveTabs, setKeepAliveTabs] = useState({ taskId: task.id, plannerChat: false, terminal: false, worktreeTerminal: false });
if (keepAliveTabs.taskId !== task.id) {
setKeepAliveTabs({ taskId: task.id, plannerChat: false, terminal: false, worktreeTerminal: false });
} else if (activeTab === "planner-chat" && !keepAliveTabs.plannerChat) {
setKeepAliveTabs({ ...keepAliveTabs, plannerChat: true });
} else if (activeTab === "terminal" && !keepAliveTabs.terminal) {
setKeepAliveTabs({ ...keepAliveTabs, terminal: true });
} else if (activeTab === "worktree-terminal" && !keepAliveTabs.worktreeTerminal) {
setKeepAliveTabs({ ...keepAliveTabs, worktreeTerminal: true });
}
const keepAliveForCurrentTask = keepAliveTabs.taskId === task.id ? keepAliveTabs : { taskId: task.id, plannerChat: false, terminal: false, worktreeTerminal: false };
// ── CLI agent session (U11) ──────────────────────────────────────────────── // ── CLI agent session (U11) ────────────────────────────────────────────────
const [cliSession, setCliSession] = useState<CliSessionSummaryRecord | null>(null); const [cliSession, setCliSession] = useState<CliSessionSummaryRecord | null>(null);
@@ -768,7 +791,15 @@ export function TaskDetailContent({
); );
const [verificationRequest, setVerificationRequest] = useState<TaskVerificationRequest | null>(null); const [verificationRequest, setVerificationRequest] = useState<TaskVerificationRequest | null>(null);
/*
FNXC:TaskPopupViewGating 2026-07-23-10:20:
Kept-alive hidden popups (active=false) must not keep polling the verification endpoint every 5s —
with several hidden popups mounted this multiplied into constant background requests. Suspend the
interval while hidden; the effect re-runs on reveal, so an immediate refresh plus a fresh interval
resume exactly the visible behavior. Visible hosts (active defaults true) are unchanged.
*/
useEffect(() => { useEffect(() => {
if (!active) return;
let cancelled = false; let cancelled = false;
const refresh = () => void fetchTaskVerificationRequest(task.id, projectId) const refresh = () => void fetchTaskVerificationRequest(task.id, projectId)
.then((request) => { if (!cancelled) setVerificationRequest(request); }) .then((request) => { if (!cancelled) setVerificationRequest(request); })
@@ -776,7 +807,7 @@ export function TaskDetailContent({
refresh(); refresh();
const timer = window.setInterval(refresh, 5_000); const timer = window.setInterval(refresh, 5_000);
return () => { cancelled = true; window.clearInterval(timer); }; return () => { cancelled = true; window.clearInterval(timer); };
}, [task.id, projectId]); }, [task.id, projectId, active]);
useEffect(() => { useEffect(() => {
// If the prop already has a prompt field, it's a full TaskDetail // If the prop already has a prompt field, it's a full TaskDetail
@@ -1582,7 +1613,8 @@ export function TaskDetailContent({
// Subscribe to SSE for real-time workflow result updates while workflow tab is active // Subscribe to SSE for real-time workflow result updates while workflow tab is active
useEffect(() => { useEffect(() => {
if (activeTab !== "workflow") return; // FNXC:TaskPopupViewGating 2026-07-22-13:15: hidden kept-alive popups close this channel (R8).
if (activeTab !== "workflow" || !active) return;
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : ""; const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
let cancelled = false; let cancelled = false;
@@ -1628,7 +1660,7 @@ export function TaskDetailContent({
cancelled = true; cancelled = true;
unsubscribe(); unsubscribe();
}; };
}, [activeTab, task.id, projectId]); }, [activeTab, active, task.id, projectId]);
/* /*
FNXC:TaskCliSession 2026-07-26-16:36: FNXC:TaskCliSession 2026-07-26-16:36:
@@ -1706,6 +1738,8 @@ export function TaskDetailContent({
/* skip malformed events */ /* skip malformed events */
} }
}; };
// FNXC:TaskPopupViewGating 2026-07-22-13:15: hidden kept-alive popups close this channel (R8); reveal re-subscribes.
if (!active) return;
/* /*
FNXC:TaskCliSession 2026-07-26-16:40: FNXC:TaskCliSession 2026-07-26-16:40:
Resync contract (see SseSubscription in sse-bus.ts). `agentState` is advanced ONLY by Resync contract (see SseSubscription in sse-bus.ts). `agentState` is advanced ONLY by
@@ -1734,7 +1768,7 @@ export function TaskDetailContent({
cancelled = true; cancelled = true;
unsubscribe(); unsubscribe();
}; };
}, [task.id, projectId, fetchLatestCliSession]); }, [active, task.id, projectId, fetchLatestCliSession]);
// Reset dependency search when dropdown closes // Reset dependency search when dropdown closes
useEffect(() => { useEffect(() => {
@@ -2672,7 +2706,8 @@ export function TaskDetailContent({
loadingMore: agentLogLoadingMore, loadingMore: agentLogLoadingMore,
} = useAgentLogs( } = useAgentLogs(
task.id, task.id,
task.status === "failed" || (activeTab === "chat" && activitySegment === "raw-logs"), // FNXC:TaskPopupViewGating 2026-07-22-13:15: `active` forces the EventSource closed while a kept-alive popup is hidden (R8).
active && (task.status === "failed" || (activeTab === "chat" && activitySegment === "raw-logs")),
projectId, projectId,
); );
useEffect(() => { useEffect(() => {
@@ -3379,7 +3414,15 @@ export function TaskDetailContent({
if (fileInputRef.current) fileInputRef.current.value = ""; if (fileInputRef.current) fileInputRef.current.value = "";
}, [uploadFile]); }, [uploadFile]);
/*
FNXC:TaskPopupViewGating 2026-07-23-10:20:
The document-level image-paste listener must not stay registered while this detail is a kept-alive
hidden popup (active=false): pasting an image anywhere in the app would silently attach it to every
hidden task. Gate registration on `active`; visible hosts (active defaults true) are unchanged and
the listener re-registers on reveal.
*/
useEffect(() => { useEffect(() => {
if (!active) return;
const handlePaste = (e: ClipboardEvent) => { const handlePaste = (e: ClipboardEvent) => {
const items = e.clipboardData?.items; const items = e.clipboardData?.items;
if (!items) return; if (!items) return;
@@ -3397,7 +3440,7 @@ export function TaskDetailContent({
}; };
document.addEventListener("paste", handlePaste); document.addEventListener("paste", handlePaste);
return () => document.removeEventListener("paste", handlePaste); return () => document.removeEventListener("paste", handlePaste);
}, [uploadFile]); }, [uploadFile, active]);
const handleDragOver = useCallback((e: React.DragEvent) => { const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
@@ -5413,19 +5456,8 @@ export function TaskDetailContent({
<TaskCostTab task={workingTask} pricingOverrides={globalSettings?.modelPricingOverrides} /> <TaskCostTab task={workingTask} pricingOverrides={globalSettings?.modelPricingOverrides} />
</div> </div>
) : activeTab === "planner-chat" ? ( ) : activeTab === "planner-chat" ? (
<div className="detail-section detail-section--planner-chat"> /* FNXC:TaskDetailTabKeepAlive 2026-07-22-12:55: body renders from the kept-alive sibling below the ternary; null here prevents fall-through to Definition. */
<TaskPlannerChatTab null
task={workingTask}
columnFlags={detailColumnFlags}
projectId={projectId}
active={activeTab === "planner-chat"}
expanded={isPlannerChatExpanded}
onExpandedChange={setPlannerChatExpanded}
planningModel={resolveEffectivePlanning(workingTask, agentLogEntries, settings)}
addToast={addToast}
onTaskUpdated={onTaskUpdated}
/>
</div>
) : activeTab === "chat" ? ( ) : activeTab === "chat" ? (
<div className={`detail-section detail-section--activity${activitySegment === "current" || isActivityExpanded ? " detail-section--chat" : ""}${activitySegment === "raw-logs" ? " detail-section--agent-log" : ""}`}> <div className={`detail-section detail-section--activity${activitySegment === "current" || isActivityExpanded ? " detail-section--chat" : ""}${activitySegment === "raw-logs" ? " detail-section--agent-log" : ""}`}>
{/* {/*
@@ -5446,7 +5478,7 @@ export function TaskDetailContent({
columnFlags={detailColumnFlags} columnFlags={detailColumnFlags}
task={workingTask} task={workingTask}
projectId={projectId} projectId={projectId}
active={activeTab === "chat" && activitySegment === "current"} active={active && activeTab === "chat" && activitySegment === "current"}
addToast={addToast} addToast={addToast}
sessionLive={isCliSessionLive(cliSession)} sessionLive={isCliSessionLive(cliSession)}
onTaskUpdated={handleChatTaskUpdated} onTaskUpdated={handleChatTaskUpdated}
@@ -5748,39 +5780,11 @@ export function TaskDetailContent({
/> />
</div> </div>
) : activeTab === "terminal" ? ( ) : activeTab === "terminal" ? (
<div className="detail-section detail-section--terminal"> /* FNXC:TaskDetailTabKeepAlive 2026-07-22-12:55: body renders from the kept-alive sibling below the ternary. */
{cliSession && cliTabVisibility.kind !== "hidden" ? ( null
<Suspense fallback={<div className="detail-loading"><LoadingSpinner label={t("taskDetail.terminal.loading", "Loading terminal…")} /></div>}>
<LazySessionTerminal
sessionId={cliSession.id}
projectId={projectId}
posture={cliPosture}
readOnly={
cliTabVisibility.kind === "replay" ||
(cliTabVisibility.kind === "live" && cliTabVisibility.readOnly)
}
mode={cliTabVisibility.mode}
showConfirmAdvance={
cliTabVisibility.kind === "live" && cliTabVisibility.showConfirmAdvance
}
onConfirmAdvance={handleConfirmAdvance}
/>
</Suspense>
) : null}
</div>
) : activeTab === "worktree-terminal" && showWorktreeTerminalTab ? ( ) : activeTab === "worktree-terminal" && showWorktreeTerminalTab ? (
<div className="detail-section detail-section--worktree-terminal"> /* FNXC:TaskDetailTabKeepAlive 2026-07-22-12:55: body renders from the kept-alive sibling below the ternary. */
<Suspense fallback={<div className="detail-loading"><LoadingSpinner label={t("taskDetail.terminal.loadingInteractive", "Loading interactive terminal…")} /></div>}> null
<LazyTerminalModal
isOpen={true}
onClose={() => setActiveTab("definition")}
embedded
defaultCwd={taskWorktreeCwd}
scopeId={task.id}
projectId={projectId}
/>
</Suspense>
</div>
) : ( ) : (
<> <>
{/* FNXC:TaskDetailSummaryTab 2026-07-29-00:00: FN-8197 keeps Definition focused on plan, retry, and source metadata; completed merge metadata renders exclusively in the done-only Summary tab. */} {/* FNXC:TaskDetailSummaryTab 2026-07-29-00:00: FN-8197 keeps Definition focused on plan, retry, and source metadata; completed merge metadata renders exclusively in the done-only Summary tab. */}
@@ -6563,6 +6567,85 @@ export function TaskDetailContent({
)} )}
</> </>
)} )}
{/*
FNXC:TaskDetailTabKeepAlive 2026-07-22-12:55:
Kept-alive tab bodies (mounted after each tab's first open for this task, hidden via KeepAliveView's out-of-flow visibility contract while another tab is active):
- Planner chat keeps its composer draft and scroll; `active` closes its useAgentLogs EventSource while hidden (R8).
- Terminal keeps the WebSocket and xterm scrollback alive intentionally; `active` drives SessionTerminal's reveal refit + dead-socket recovery (R9).
- Worktree terminal keeps the embedded TerminalModal shell session alive across tab flips.
Task switch or modal close resets the latches, so terminals dispose exactly as before keep-alive (R10).
*/}
{keepAliveForCurrentTask.plannerChat ? (
<KeepAliveView hidden={activeTab !== "planner-chat"} testId="planner-chat-keep-alive">
<div className="detail-section detail-section--planner-chat">
<TaskPlannerChatTab
task={workingTask}
/* FNXC:WorkflowResolvedColumns 2026-07-30-23:40: the kept-alive sibling renders the
body now, so it carries the resolved flags the inline render used to. Without
them TaskPlannerChatTab's `isWipColumnRole(columnFlags, task.column)` falls back
to the legacy id and `agentRunning` is wrong on a renamed board. */
columnFlags={detailColumnFlags}
projectId={projectId}
active={active && activeTab === "planner-chat"}
expanded={isPlannerChatExpanded}
onExpandedChange={setPlannerChatExpanded}
planningModel={resolveEffectivePlanning(workingTask, agentLogEntries, settings)}
addToast={addToast}
onTaskUpdated={onTaskUpdated}
/>
</div>
</KeepAliveView>
) : null}
{keepAliveForCurrentTask.terminal ? (
<KeepAliveView hidden={activeTab !== "terminal"} testId="terminal-keep-alive">
<div className="detail-section detail-section--terminal">
{cliSession && cliTabVisibility.kind !== "hidden" ? (
<Suspense fallback={<div className="detail-loading"><LoadingSpinner label={t("taskDetail.terminal.loading", "Loading terminal…")} /></div>}>
<LazySessionTerminal
sessionId={cliSession.id}
projectId={projectId}
posture={cliPosture}
active={active && activeTab === "terminal"}
readOnly={
cliTabVisibility.kind === "replay" ||
(cliTabVisibility.kind === "live" && cliTabVisibility.readOnly)
}
mode={cliTabVisibility.mode}
showConfirmAdvance={
cliTabVisibility.kind === "live" && cliTabVisibility.showConfirmAdvance
}
onConfirmAdvance={handleConfirmAdvance}
/>
</Suspense>
) : null}
</div>
</KeepAliveView>
) : null}
{keepAliveForCurrentTask.worktreeTerminal && showWorktreeTerminalTab ? (
<KeepAliveView hidden={activeTab !== "worktree-terminal"} testId="worktree-terminal-keep-alive">
<div className="detail-section detail-section--worktree-terminal">
<Suspense fallback={<div className="detail-loading"><LoadingSpinner label={t("taskDetail.terminal.loadingInteractive", "Loading interactive terminal…")} /></div>}>
<LazyTerminalModal
isOpen={true}
/*
FNXC:TaskPopupViewGating 2026-07-23-10:20:
Keep-alive contract for the worktree terminal: isOpen stays true so xterm and the
terminal WebSocket survive hidden popups and tab flips, while `active` (popup
visible AND this tab selected — same composition as SessionTerminal above)
suspends only auxiliary work: visual-viewport/keyboard listeners, resize
observers, refit rAF loops, and keydown handlers. See TerminalModal `active`.
*/
active={active && activeTab === "worktree-terminal"}
onClose={() => setActiveTab("definition")}
embedded
defaultCwd={taskWorktreeCwd}
scopeId={task.id}
projectId={projectId}
/>
</Suspense>
</div>
</KeepAliveView>
) : null}
</div> </div>
{isReviewColumn && ( {isReviewColumn && (
<PrCreateModal <PrCreateModal

View File

@@ -464,6 +464,18 @@ interface TerminalModalProps {
scopeId?: string; scopeId?: string;
/** Whether the fixed ExecutorStatusBar footer is currently rendered; reserves space for it in below-mode. */ /** Whether the fixed ExecutorStatusBar footer is currently rendered; reserves space for it in below-mode. */
footerVisible?: boolean; footerVisible?: boolean;
/*
FNXC:TaskPopupViewGating 2026-07-23-10:25:
Keep-alive suspension gate (FN remount-churn fix follow-up). Kept-alive hosts (the task-detail
worktree Terminal tab inside a hidden popup or behind another tab) keep isOpen=true so the xterm
instance and terminal WebSocket survive, but pass active=false to suspend auxiliary background
work only: visual-viewport/keyboard/orientation listeners, window-resize listeners,
ResizeObservers, refit rAF loops, tabs-overflow measurement, and the zoom/Escape keydown
handlers. xterm init, WS bridging, disposal-on-close, and rendering stay keyed on isOpen alone.
On the false -> true transition the gated refit effects re-run, so the reveal gets a corrective
fit for free. Defaults to true so every standalone host is unaffected.
*/
active?: boolean;
} }
/** /**
@@ -481,8 +493,10 @@ interface TerminalModalProps {
* *
* The terminal spawns a real shell (bash/zsh/powershell based on platform). * The terminal spawns a real shell (bash/zsh/powershell based on platform).
*/ */
export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandGeneration = 0, projectId, embedded = false, defaultCwd, scopeId, footerVisible = false }: TerminalModalProps) { export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandGeneration = 0, projectId, embedded = false, defaultCwd, scopeId, footerVisible = false, active = true }: TerminalModalProps) {
const { t } = useTranslation("app"); const { t } = useTranslation("app");
// FNXC:TaskPopupViewGating 2026-07-23-10:25: auxiliary-effect gate — see the `active` prop doc above. Never used for xterm init/cleanup or render.
const auxEffectsActive = isOpen && active;
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// FNXC:Terminal 2026-07-23-20:10: In-flight guard for the manual "Start terminal" action (GitHub #2121/#2307 review): rapid clicks must not create duplicate PTY sessions, and the Windows bootstrap-failure cohort this button serves must SEE createTab failures instead of a silently dead button. // FNXC:Terminal 2026-07-23-20:10: In-flight guard for the manual "Start terminal" action (GitHub #2121/#2307 review): rapid clicks must not create duplicate PTY sessions, and the Windows bootstrap-failure cohort this button serves must SEE createTab failures instead of a silently dead button.
const [isStartingTerminal, setIsStartingTerminal] = useState(false); const [isStartingTerminal, setIsStartingTerminal] = useState(false);
@@ -622,7 +636,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
}, [projectId]); }, [projectId]);
useEffect(() => { useEffect(() => {
if (!isOpen) return; if (!auxEffectsActive) return;
/* /*
FNXC:Terminal 2026-06-21-22:58: FNXC:Terminal 2026-06-21-22:58:
Viewport changes must force the terminal back onto the mobile fullscreen path at <=768px or touch-primary short landscape, then restore the stored desktop/tablet docked/floating mode when the viewport expands. Viewport changes must force the terminal back onto the mobile fullscreen path at <=768px or touch-primary short landscape, then restore the stored desktop/tablet docked/floating mode when the viewport expands.
@@ -638,7 +652,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
window.removeEventListener("resize", updateViewportMode); window.removeEventListener("resize", updateViewportMode);
window.visualViewport?.removeEventListener("resize", updateViewportMode); window.visualViewport?.removeEventListener("resize", updateViewportMode);
}; };
}, [isOpen]); }, [auxEffectsActive]);
const checkTabsFit = useCallback(() => { const checkTabsFit = useCallback(() => {
const measuredTabs = terminalTabsMeasureRef.current; const measuredTabs = terminalTabsMeasureRef.current;
@@ -803,7 +817,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
// Track virtual keyboard overlap on mobile so the terminal entry area // Track virtual keyboard overlap on mobile so the terminal entry area
// stays visible above the keyboard. On desktop this is a no-op. // stays visible above the keyboard. On desktop this is a no-op.
useEffect(() => { useEffect(() => {
if (!isOpen || !isMobileDevice()) return; if (!auxEffectsActive || !isMobileDevice()) return;
const vv = window.visualViewport; const vv = window.visualViewport;
if (!vv) return; if (!vv) return;
@@ -878,25 +892,28 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
setViewportHeight(null); setViewportHeight(null);
setViewportWidth(null); setViewportWidth(null);
}; };
}, [fitAndResizeForSession, isOpen]); }, [fitAndResizeForSession, auxEffectsActive]);
/* /*
FNXC:Terminal 2026-06-21-22:07: FNXC:Terminal 2026-06-21-22:07:
Docked resize interactions change the terminal viewport without a window resize event, so refit xterm after display mode or docked height changes. FloatingWindow geometry is handled by its dedicated event listener. Docked resize interactions change the terminal viewport without a window resize event, so refit xterm after display mode or docked height changes. FloatingWindow geometry is handled by its dedicated event listener.
*/ */
useEffect(() => { useEffect(() => {
if (!isOpen) return; if (!auxEffectsActive) return;
const sessionId = typeof xtermInitializedRef.current === "string" ? xtermInitializedRef.current : undefined; const sessionId = typeof xtermInitializedRef.current === "string" ? xtermInitializedRef.current : undefined;
const frame = requestAnimationFrame(() => fitAndResizeForSession(sessionId)); const frame = requestAnimationFrame(() => fitAndResizeForSession(sessionId));
return () => cancelAnimationFrame(frame); return () => cancelAnimationFrame(frame);
}, [displayMode, dockedHeight, fitAndResizeForSession, isOpen]); /* FNXC:TerminalKeepAlive 2026-07-30-23:55: `floatingSize` was in this array on the PR branch and no
longer exists — main removed it. Dropped rather than reconstructed: the effect body reads only
`auxEffectsActive` and `fitAndResizeForSession`, and the rest are layout re-run triggers. */
}, [displayMode, dockedHeight, fitAndResizeForSession, auxEffectsActive]);
// Refit xterm whenever the user drags the modal's CSS resize grip. // Refit xterm whenever the user drags the modal's CSS resize grip.
// The window/visualViewport listeners only fire on viewport changes; native // The window/visualViewport listeners only fire on viewport changes; native
// `resize: both` does NOT emit window resize, so we observe the modal node // `resize: both` does NOT emit window resize, so we observe the modal node
// directly and ask xterm to refit to the new pixel box. // directly and ask xterm to refit to the new pixel box.
useEffect(() => { useEffect(() => {
if (!isOpen) return; if (!auxEffectsActive) return;
const node = modalRef.current; const node = modalRef.current;
if (!node || typeof ResizeObserver === "undefined") return; if (!node || typeof ResizeObserver === "undefined") return;
@@ -918,7 +935,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
observer.disconnect(); observer.disconnect();
if (pendingFrame !== null) cancelAnimationFrame(pendingFrame); if (pendingFrame !== null) cancelAnimationFrame(pendingFrame);
}; };
}, [fitAndResizeForSession, isOpen]); }, [fitAndResizeForSession, auxEffectsActive]);
// Use the session management hook // Use the session management hook
const { const {
@@ -940,7 +957,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
}); });
useEffect(() => { useEffect(() => {
if (!isOpen) { if (!auxEffectsActive) {
setTabsOverflow(false); setTabsOverflow(false);
return; return;
} }
@@ -957,7 +974,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
window.removeEventListener("resize", checkTabsFit); window.removeEventListener("resize", checkTabsFit);
observer?.disconnect(); observer?.disconnect();
}; };
}, [checkTabsFit, isOpen, tabs.length]); }, [checkTabsFit, auxEffectsActive, tabs.length]);
useEffect(() => { useEffect(() => {
checkTabsFit(); checkTabsFit();
@@ -988,7 +1005,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
container div). See docs/solutions/ui-bugs/mobile-terminal-blank-render-zero-geometry-container.md. container div). See docs/solutions/ui-bugs/mobile-terminal-blank-render-zero-geometry-container.md.
*/ */
useEffect(() => { useEffect(() => {
if (!isOpen) return; if (!auxEffectsActive) return;
const node = terminalRef.current; const node = terminalRef.current;
if (!node || typeof ResizeObserver === "undefined") return; if (!node || typeof ResizeObserver === "undefined") return;
@@ -1010,7 +1027,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
observer.disconnect(); observer.disconnect();
if (pendingFrame !== null) cancelAnimationFrame(pendingFrame); if (pendingFrame !== null) cancelAnimationFrame(pendingFrame);
}; };
}, [fitAndResizeForSession, isOpen, activeTab?.sessionId]); }, [fitAndResizeForSession, auxEffectsActive, activeTab?.sessionId]);
const { const {
projectName: terminalWorkspaceProjectName, projectName: terminalWorkspaceProjectName,
@@ -1945,8 +1962,9 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
}, [resolvedFontFamily, terminalPreferences, xtermReady, refitTerminal]); }, [resolvedFontFamily, terminalPreferences, xtermReady, refitTerminal]);
// Handle keyboard shortcuts (zoom) // Handle keyboard shortcuts (zoom)
// FNXC:TaskPopupViewGating 2026-07-23-10:25: gated on auxEffectsActive so a kept-alive hidden terminal never intercepts global zoom keystrokes.
useEffect(() => { useEffect(() => {
if (!isOpen) return; if (!auxEffectsActive) return;
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (!e.ctrlKey && !e.metaKey) return; if (!e.ctrlKey && !e.metaKey) return;
@@ -1975,11 +1993,12 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
window.addEventListener("keydown", handleKeyDown); window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, setFontSize]); }, [auxEffectsActive, setFontSize]);
// Handle escape key to close the open worktree menu before closing the terminal. // Handle escape key to close the open worktree menu before closing the terminal.
// FNXC:TaskPopupViewGating 2026-07-23-10:25: gated on auxEffectsActive so a kept-alive hidden terminal never swallows Escape or closes itself.
useEffect(() => { useEffect(() => {
if (!isOpen) return; if (!auxEffectsActive) return;
const handleKey = (e: KeyboardEvent) => { const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") { if (e.key === "Escape") {
@@ -1994,7 +2013,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
}; };
document.addEventListener("keydown", handleKey); document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey); return () => document.removeEventListener("keydown", handleKey);
}, [isOpen, onClose, terminalWorkspaceMenuOpen]); }, [auxEffectsActive, onClose, terminalWorkspaceMenuOpen]);
// Focus terminal when connected // Focus terminal when connected
useEffect(() => { useEffect(() => {

View File

@@ -3189,12 +3189,52 @@ describe("App Planning Mode", () => {
fireEvent.click(screen.getByLabelText("Close")); fireEvent.click(screen.getByLabelText("Close"));
/*
FNXC:PlanningKeepAlive 2026-07-22-12:40:
Closing the embedded Planning view returns to Board, but the planning subtree now stays mounted-but-hidden (keep-alive) instead of unmounting — the assertion moved from "not in DOM" to "hidden and inert" (aria-hidden wrapper) so the setting's intent (planning is not visible/interactive on other views) still holds.
*/
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText("Transform your idea into a detailed task")).toBeNull(); expect(screen.getByTestId("planning-keep-alive")).toHaveAttribute("aria-hidden", "true");
expect(screen.getByTestId("sidebar-nav-board").getAttribute("aria-current")).toBe("page"); expect(screen.getByTestId("sidebar-nav-board").getAttribute("aria-current")).toBe("page");
}); });
}); });
/*
FNXC:PlanningKeepAlive 2026-07-22-12:40:
FN remount-churn fix R5: Planning mounts lazily on first open, then survives sidebar navigation mounted-but-hidden; returning reveals the same subtree instead of remounting it.
*/
it("keeps Planning Mode mounted-but-hidden across navigation round-trips", async () => {
localStorage.setItem("kb-dashboard-view-mode", "project");
vi.mocked(fetchSettings).mockResolvedValueOnce({
...defaultSettings,
experimentalFeatures: { ...defaultSettings.experimentalFeatures, leftSidebarNav: true },
});
render(<App />);
// First-mount laziness: before Planning is ever opened, no kept-alive planning subtree exists.
await screen.findByTestId("sidebar-nav-planning");
expect(screen.queryByTestId("planning-keep-alive")).toBeNull();
fireEvent.click(screen.getByTestId("sidebar-nav-planning"));
await waitFor(() => {
expect(screen.getByTestId("planning-view")).toBeTruthy();
});
expect(screen.getByTestId("planning-keep-alive")).not.toHaveAttribute("aria-hidden");
fireEvent.click(screen.getByTestId("sidebar-nav-board"));
await waitFor(() => {
expect(screen.getByTestId("planning-keep-alive")).toHaveAttribute("aria-hidden", "true");
});
// Mounted-but-hidden: the planning subtree is still in the DOM while Board is active.
expect(screen.getByTestId("planning-view")).toBeTruthy();
fireEvent.click(screen.getByTestId("sidebar-nav-planning"));
await waitFor(() => {
expect(screen.getByTestId("planning-keep-alive")).not.toHaveAttribute("aria-hidden");
});
expect(screen.getByTestId("planning-view")).toBeTruthy();
});
it("renders planning embedded view with correct initial state", async () => { it("renders planning embedded view with correct initial state", async () => {
localStorage.setItem(taskViewStorageKey(), "planning"); localStorage.setItem(taskViewStorageKey(), "planning");

View File

@@ -179,6 +179,8 @@ describe("DevServerView", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
// FNXC:DevServer 2026-07-22-13:45: persisted selection/command state (R12) must not leak between tests.
localStorage.clear();
mockUseDevServer.mockReturnValue(createDevServerHookState()); mockUseDevServer.mockReturnValue(createDevServerHookState());
mockUseDevServerConfig.mockReturnValue(createConfigHookState()); mockUseDevServerConfig.mockReturnValue(createConfigHookState());
mockUseDevServerLogs.mockReturnValue(createDevServerLogsHookState()); mockUseDevServerLogs.mockReturnValue(createDevServerLogsHookState());
@@ -500,4 +502,31 @@ describe("DevServerView", () => {
expect(screen.queryByText("FN-100 — No checkout")).not.toBeInTheDocument(); expect(screen.queryByText("FN-100 — No checkout")).not.toBeInTheDocument();
expect(screen.getByText("FN-101 — Has checkout")).toBeInTheDocument(); expect(screen.getByText("FN-101 — Has checkout")).toBeInTheDocument();
}); });
/*
FNXC:DevServer 2026-07-22-13:45:
FN remount-churn fix R12: DevServerView unmounts on navigation by design, so the typed-but-unsent command and the selected script restore from per-project persisted state after an unmount round-trip; a fresh project keeps defaults.
*/
it("restores the typed command and selected script after an unmount round-trip", () => {
localStorage.clear();
const { unmount } = render(<DevServerView addToast={addToast} projectId="project-a" />);
fireEvent.click(screen.getByTestId("dev-server-candidate-dev-root"));
fireEvent.change(screen.getByTestId("dev-server-command-input"), { target: { value: "pnpm dev --port 0" } });
unmount();
render(<DevServerView addToast={addToast} projectId="project-a" />);
expect(screen.getByTestId("dev-server-command-input")).toHaveValue("pnpm dev --port 0");
expect(screen.getByTestId("dev-server-selected-summary")).toHaveTextContent("dev");
});
it("keeps defaults for a project with no persisted dev-server state", () => {
localStorage.clear();
render(<DevServerView addToast={addToast} projectId="fresh-project" />);
// No selection restored; the command shows the existing first-candidate auto-suggestion.
expect(screen.getByTestId("dev-server-command-input")).toHaveValue("pnpm dev");
expect(screen.queryByTestId("dev-server-selected-summary")).not.toBeInTheDocument();
});
}); });

View File

@@ -1,20 +1,32 @@
import { useEffect } from "react";
import { fireEvent, render, screen } from "@testing-library/react"; import { fireEvent, render, screen } from "@testing-library/react";
import type { Task, TaskDetail } from "@fusion/core"; import type { Task, TaskDetail } from "@fusion/core";
import { describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { DockTaskList } from "../DockTaskList"; import { DockTaskList } from "../DockTaskList";
/*
FNXC:RightDockTasks 2026-07-22-12:05:
The mock records each TaskCard mount so tests can assert row identity stability: reorders, filter toggles, and status changes must not remount surviving cards (the old `${id}-${index}` key did).
*/
const { taskCardMountLog } = vi.hoisted(() => ({ taskCardMountLog: [] as string[] }));
vi.mock("../TaskCard", () => ({ vi.mock("../TaskCard", () => ({
TaskCard: ({ task, onOpenDetail, onDeleteTask, disableDrag }: { task: Task | TaskDetail; onOpenDetail: (task: Task | TaskDetail) => void; onDeleteTask?: (id: string) => Promise<Task>; disableDrag?: boolean }) => ( TaskCard: ({ task, onOpenDetail, onDeleteTask, disableDrag }: { task: Task | TaskDetail; onOpenDetail: (task: Task | TaskDetail) => void; onDeleteTask?: (id: string) => Promise<Task>; disableDrag?: boolean }) => {
<button useEffect(() => {
type="button" taskCardMountLog.push(task.id);
data-testid={`mock-task-card-${task.id}`} }, []);
data-disable-drag={String(disableDrag)} return (
data-has-delete={String(Boolean(onDeleteTask))} <button
onClick={() => onOpenDetail(task)} type="button"
> data-testid={`mock-task-card-${task.id}`}
{task.title ?? task.id} data-disable-drag={String(disableDrag)}
</button> data-has-delete={String(Boolean(onDeleteTask))}
), onClick={() => onOpenDetail(task)}
>
{task.title ?? task.id}
</button>
);
},
})); }));
/* /*
@@ -24,6 +36,45 @@ DockTaskList must route TaskCard's own open action to the dock snapshot setter.
const makeTask = (id: string, title: string, column: string) => ({ id, title, column }) as Task; const makeTask = (id: string, title: string, column: string) => ({ id, title, column }) as Task;
describe("DockTaskList", () => { describe("DockTaskList", () => {
beforeEach(() => {
taskCardMountLog.length = 0;
});
/*
FNXC:RightDockTasks 2026-07-22-12:05:
Regression coverage for the `${id}-${index}` volatile-key bug: any reorder, membership change, or status change remounted every surviving card.
*/
it("keeps TaskCard identity across list reorders and status changes", () => {
const first = makeTask("FN-1", "First task", "todo");
const second = makeTask("FN-2", "Second task", "in-progress");
const { rerender } = render(<DockTaskList tasks={[first, second]} onOpenTask={vi.fn()} addToast={vi.fn()} />);
expect(taskCardMountLog).toEqual(["FN-1", "FN-2"]);
rerender(<DockTaskList tasks={[makeTask("FN-2", "Second task", "in-review"), first]} onOpenTask={vi.fn()} addToast={vi.fn()} />);
expect(screen.getAllByTestId(/dock-task-list-row-/).map((row) => row.getAttribute("data-testid"))).toEqual([
"dock-task-list-row-FN-2",
"dock-task-list-row-FN-1",
]);
expect(taskCardMountLog).toEqual(["FN-1", "FN-2"]);
});
it("keeps surviving TaskCard identity when membership changes via the Show Done toggle", () => {
const active = makeTask("FN-ACTIVE", "Active task", "todo");
const done = makeTask("FN-DONE", "Done task", "done");
render(<DockTaskList tasks={[active, done]} onOpenTask={vi.fn()} addToast={vi.fn()} />);
expect(taskCardMountLog).toEqual(["FN-ACTIVE"]);
fireEvent.click(screen.getByRole("button", { name: "Show Done" }));
expect(taskCardMountLog).toEqual(["FN-ACTIVE", "FN-DONE"]);
fireEvent.click(screen.getByRole("button", { name: "Hide Done" }));
expect(screen.queryByTestId("dock-task-list-row-FN-DONE")).toBeNull();
expect(taskCardMountLog).toEqual(["FN-ACTIVE", "FN-DONE"]);
});
/* /*
FNXC:TaskDeletion 2026-07-12-00:00: FNXC:TaskDeletion 2026-07-12-00:00:
The reported inert delete localized to the right-dock Tasks list host: it rendered TaskCard without onDeleteTask, so that surface could not enter the shared confirm→delete flow while board/list/detail hosts were wired. The reported inert delete localized to the right-dock Tasks list host: it rendered TaskCard without onDeleteTask, so that surface could not enter the shared confirm→delete flow while board/list/detail hosts were wired.

View File

@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { KeepAliveView } from "../KeepAliveView";
/*
FNXC:DashboardTests 2026-07-26-07:25:
Gate check-no-cwd-relative-dashboard-test-reads rejects process.cwd()/bare app/ reads under packages/dashboard/app/__tests__. Load KeepAliveView.css via the module-relative cssFixture helper so root-launched Vitest does not ENOENT.
*/
import { loadComponentCss } from "../../test/cssFixture";
/*
FNXC:KeepAlive 2026-07-22-12:35:
The keep-alive wrapper contract (FN remount-churn fix R5/R8/R9): children stay mounted while hidden, hiding is visibility-based (never display:none, so xterm geometry never collapses to zero), the hidden state is out-of-flow (no layout space consumed beside the active view), and hidden wrappers carry aria-hidden.
*/
describe("KeepAliveView", () => {
it("keeps hidden children mounted with aria-hidden and the hidden class", () => {
const { rerender } = render(
<KeepAliveView hidden={false} testId="keep-alive-probe">
<input defaultValue="draft text" aria-label="probe input" />
</KeepAliveView>,
);
const wrapper = screen.getByTestId("keep-alive-probe");
expect(wrapper.className).toBe("keep-alive-view");
expect(wrapper).not.toHaveAttribute("aria-hidden");
const input = screen.getByLabelText("probe input") as HTMLInputElement;
input.value = "edited while visible";
rerender(
<KeepAliveView hidden testId="keep-alive-probe">
<input defaultValue="draft text" aria-label="probe input" />
</KeepAliveView>,
);
expect(wrapper.className).toBe("keep-alive-view keep-alive-view--hidden");
expect(wrapper).toHaveAttribute("aria-hidden", "true");
// Mounted-but-hidden: the same DOM node (and its uncommitted user state) survives.
expect((wrapper.querySelector("input") as HTMLInputElement).value).toBe("edited while visible");
rerender(
<KeepAliveView hidden={false} testId="keep-alive-probe">
<input defaultValue="draft text" aria-label="probe input" />
</KeepAliveView>,
);
expect(wrapper.className).toBe("keep-alive-view");
expect(wrapper).not.toHaveAttribute("aria-hidden");
expect((wrapper.querySelector("input") as HTMLInputElement).value).toBe("edited while visible");
});
it("hides via out-of-flow visibility, never display:none", () => {
const css = loadComponentCss("KeepAliveView.css");
const hiddenRuleStart = css.indexOf(".keep-alive-view--hidden");
expect(hiddenRuleStart).toBeGreaterThanOrEqual(0);
const hiddenRule = css.slice(hiddenRuleStart, css.indexOf("}", hiddenRuleStart));
expect(hiddenRule).toContain("visibility: hidden");
expect(hiddenRule).toContain("pointer-events: none");
// Out-of-flow while hidden so the invisible box consumes no layout space beside the active view.
expect(hiddenRule).toContain("position: absolute");
expect(hiddenRule).toContain("inset: 0");
expect(hiddenRule).not.toMatch(/\bdisplay\s*:\s*none\b/);
// Strip comments so prose like "(never display:none)" doesn't trip the declaration check.
expect(css.replace(/\/\*[\s\S]*?\*\//g, "")).not.toMatch(/\bdisplay\s*:\s*none\b/);
});
});

View File

@@ -0,0 +1,98 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { PlanningModeModal } from "../PlanningModeModal";
import { mockFetchAiSession, mockFetchAiSessions, mockSummary, mockTasks } from "./PlanningModeModal.test-helpers";
const mockViewportMode = vi.hoisted(() => vi.fn(() => "desktop" as "desktop" | "tablet" | "mobile"));
const mockConnectPlanningStream = vi.hoisted(() => vi.fn());
vi.mock("../../hooks/useToast", () => ({ useOptionalToast: () => null, useToast: () => ({ addToast: vi.fn(), removeToast: vi.fn(), toasts: [] }) }));
vi.mock("../../hooks/useNavigationHistory", () => ({ useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }) }));
vi.mock("../../hooks/useViewportMode", () => ({ MOBILE_MEDIA_QUERY: "(max-width: 768px)", isFullScreenSheetViewport: () => false, isShortViewport: () => false, getViewportMode: () => mockViewportMode(), isMobileViewport: () => mockViewportMode() === "mobile", useViewportMode: () => mockViewportMode() }));
vi.mock("../../hooks/useMobileKeyboard", () => ({ useMobileKeyboard: () => ({ keyboardOverlap: 0, viewportHeight: null, viewportOffsetTop: 0, keyboardOpen: false }) }));
vi.mock("../../hooks/useConfirm", () => ({ useConfirm: () => ({ confirm: vi.fn().mockResolvedValue(true) }) }));
vi.mock("../../sse-bus", () => ({ subscribeSse: vi.fn(() => () => undefined) }));
vi.mock("../../api", () => {
const fn = vi.fn;
return {
fetchAiSession: (...args: unknown[]) => mockFetchAiSession(...args), fetchAiSessions: (...args: unknown[]) => mockFetchAiSessions(...args),
respondToPlanning: fn(), validatePlanningSession: fn(), createTaskFromPlanning: fn(),
fetchSettings: fn().mockResolvedValue({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {} }), fetchGlobalSettings: fn().mockResolvedValue({}), fetchModels: fn().mockResolvedValue([]), fetchWorkflowSteps: fn().mockResolvedValue([]), fetchBoardWorkflows: fn().mockResolvedValue({ workflows: [] }),
startPlanning: fn(), startPlanningStreaming: fn(), createPlanningDraft: fn(), connectPlanningStream: (...args: unknown[]) => mockConnectPlanningStream(...args), rewindPlanningSession: fn(), retryPlanningSession: fn().mockResolvedValue({ success: true }), cancelPlanning: fn(), stopPlanningGeneration: fn(), updatePlanningSessionDraft: fn(), updatePlanningSessionTitle: fn(), startPlanningBreakdown: fn(), createTasksFromPlanning: fn(), parseConversationHistory: (raw: string) => JSON.parse(raw || "[]"), acquireSessionLock: fn(), releaseSessionLock: fn(), forceAcquireSessionLock: fn(), uploadAttachment: fn(), deleteAttachment: fn(), updateTask: fn(), pauseTask: fn(), unpauseTask: fn(), fetchTaskDetail: fn(), requestSpecRevision: fn(), approvePlan: fn(), rejectPlan: fn(), refineTask: fn(), deleteAiSession: fn(), refineText: fn(), getRefineErrorMessage: (error: Error) => error.message,
};
});
const base = { id: "session-1", title: "Kept-alive plan", projectId: "project-1", updatedAt: new Date().toISOString(), archived: false, conversationHistory: "[]", thinkingOutput: "" };
const awaitingQuestionSession = {
...base,
status: "awaiting_input",
currentQuestion: JSON.stringify({
id: "q-scope",
type: "single_select",
question: "What is the scope?",
options: [{ id: "small", label: "Small" }, { id: "large", label: "Large" }],
}),
result: JSON.stringify(mockSummary),
inputPayload: "{}",
};
function renderPlanning() {
return render(
<PlanningModeModal
isOpen
onClose={vi.fn()}
onTaskCreated={vi.fn()}
onTasksCreated={vi.fn()}
tasks={mockTasks}
projectId="project-1"
resumeSessionId="session-1"
presentation="embedded"
/>,
);
}
/*
FNXC:PlanningKeepAlive 2026-07-22-13:30:
FN remount-churn fix R11 (internal-transition audit): Planning Mode's session-list mode and mobile list/detail flips are CSS-class transitions over one always-mounted detail pane — they must never unmount the interview pane or trigger a session reload. Re-selecting the already-active session from the list is an early-return visibility restore, not a loadSession round-trip. These tests lock that invariant on both desktop and mobile breakpoints.
*/
describe("PlanningModeModal internal transitions", () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
mockViewportMode.mockReturnValue("desktop");
mockFetchAiSessions.mockResolvedValue([
{ id: "session-1", type: "planning", status: "awaiting_input", title: "Kept-alive plan", projectId: "project-1", updatedAt: base.updatedAt, archived: false },
]);
mockFetchAiSession.mockResolvedValue(awaitingQuestionSession);
});
it("keeps the interview pane mounted across a session-list toggle round-trip without reloading", async () => {
renderPlanning();
expect(await screen.findByTestId("planning-question-text")).toHaveTextContent("What is the scope?");
const sessionLoads = mockFetchAiSession.mock.calls.length;
fireEvent.click(screen.getByRole("button", { name: "Back to sessions" }));
// List mode is a CSS-class flip: the interview pane stays mounted with its state.
expect(screen.getByTestId("planning-question-text")).toHaveTextContent("What is the scope?");
// Re-selecting the active session is an early-return visibility restore — no loadSession refetch.
fireEvent.click(screen.getByRole("button", { name: /Kept-alive plan/ }));
expect(screen.getByTestId("planning-question-text")).toHaveTextContent("What is the scope?");
expect(mockFetchAiSession.mock.calls.length).toBe(sessionLoads);
});
it("keeps the detail pane mounted across a mobile list/detail flip round-trip", async () => {
mockViewportMode.mockReturnValue("mobile");
renderPlanning();
expect(await screen.findByTestId("planning-question-text")).toHaveTextContent("What is the scope?");
const sessionLoads = mockFetchAiSession.mock.calls.length;
fireEvent.click(screen.getByRole("button", { name: "Back to sessions" }));
expect(screen.getByTestId("planning-question-text")).toHaveTextContent("What is the scope?");
fireEvent.click(screen.getByRole("button", { name: /Kept-alive plan/ }));
expect(screen.getByTestId("planning-question-text")).toHaveTextContent("What is the scope?");
expect(mockFetchAiSession.mock.calls.length).toBe(sessionLoads);
});
});

View File

@@ -0,0 +1,177 @@
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
import { act, render, screen, waitFor } from "@testing-library/react";
import { PlanningModeModal } from "../PlanningModeModal";
import { clearResumeEvents, getResumeEvents } from "../../utils/resumeInstrumentation";
import { mockFetchAiSession, mockFetchAiSessions, mockSummary, mockTasks } from "./PlanningModeModal.test-helpers";
const mockViewportMode = vi.hoisted(() => vi.fn(() => "desktop" as "desktop" | "tablet" | "mobile"));
const mockConnectPlanningStream = vi.hoisted(() => vi.fn());
const mockSseState = vi.hoisted(() => ({ subscribeCalls: 0, unsubscribeCalls: 0 }));
vi.mock("../../hooks/useToast", () => ({ useOptionalToast: () => null, useToast: () => ({ addToast: vi.fn(), removeToast: vi.fn(), toasts: [] }) }));
vi.mock("../../hooks/useNavigationHistory", () => ({ useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }) }));
vi.mock("../../hooks/useViewportMode", () => ({ MOBILE_MEDIA_QUERY: "(max-width: 768px)", isFullScreenSheetViewport: () => false, isShortViewport: () => false, getViewportMode: () => mockViewportMode(), isMobileViewport: () => mockViewportMode() === "mobile", useViewportMode: () => mockViewportMode() }));
vi.mock("../../hooks/useMobileKeyboard", () => ({ useMobileKeyboard: () => ({ keyboardOverlap: 0, viewportHeight: null, viewportOffsetTop: 0, keyboardOpen: false }) }));
vi.mock("../../hooks/useConfirm", () => ({ useConfirm: () => ({ confirm: vi.fn().mockResolvedValue(true) }) }));
vi.mock("../../sse-bus", () => ({
subscribeSse: vi.fn(() => {
mockSseState.subscribeCalls += 1;
return () => {
mockSseState.unsubscribeCalls += 1;
};
}),
}));
vi.mock("../../api", () => {
const fn = vi.fn;
return {
fetchAiSession: (...args: unknown[]) => mockFetchAiSession(...args), fetchAiSessions: (...args: unknown[]) => mockFetchAiSessions(...args),
respondToPlanning: fn(), validatePlanningSession: fn(), createTaskFromPlanning: fn(),
fetchSettings: fn().mockResolvedValue({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {} }), fetchGlobalSettings: fn().mockResolvedValue({}), fetchModels: fn().mockResolvedValue([]), fetchWorkflowSteps: fn().mockResolvedValue([]), fetchBoardWorkflows: fn().mockResolvedValue({ workflows: [] }),
startPlanning: fn(), startPlanningStreaming: fn(), createPlanningDraft: fn(), connectPlanningStream: (...args: unknown[]) => mockConnectPlanningStream(...args), rewindPlanningSession: fn(), retryPlanningSession: fn().mockResolvedValue({ success: true }), cancelPlanning: fn(), stopPlanningGeneration: fn(), updatePlanningSessionDraft: fn(), updatePlanningSessionTitle: fn(), startPlanningBreakdown: fn(), createTasksFromPlanning: fn(), parseConversationHistory: (raw: string) => JSON.parse(raw || "[]"), acquireSessionLock: fn(), releaseSessionLock: fn(), forceAcquireSessionLock: fn(), uploadAttachment: fn(), deleteAttachment: fn(), updateTask: fn(), pauseTask: fn(), unpauseTask: fn(), fetchTaskDetail: fn(), requestSpecRevision: fn(), approvePlan: fn(), rejectPlan: fn(), refineTask: fn(), deleteAiSession: fn(), refineText: fn(), getRefineErrorMessage: (error: Error) => error.message,
};
});
const base = { id: "session-1", title: "Kept-alive plan", projectId: "project-1", updatedAt: new Date().toISOString(), archived: false, conversationHistory: "[]", thinkingOutput: "" };
const awaitingQuestionSession = {
...base,
status: "awaiting_input",
currentQuestion: JSON.stringify({
id: "q-scope",
type: "single_select",
question: "What is the scope?",
options: [{ id: "small", label: "Small" }, { id: "large", label: "Large" }],
}),
result: JSON.stringify(mockSummary),
inputPayload: "{}",
};
function planningProps(active: boolean) {
return {
isOpen: true,
active,
onClose: vi.fn(),
onTaskCreated: vi.fn(),
onTasksCreated: vi.fn(),
tasks: mockTasks,
projectId: "project-1",
resumeSessionId: "session-1",
presentation: "embedded" as const,
};
}
/*
FNXC:PlanningKeepAlive 2026-07-22-12:35:
FN remount-churn fix R5/R8: the kept-alive embedded Planning view must suspend its background work while hidden (session-list SSE closed, recovery poll idle) and restore instantly on reveal — same instance, no session reload, resume instrumentation recording route-active rather than a second remount.
*/
describe("PlanningModeModal keep-alive gating", () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
clearResumeEvents();
mockSseState.subscribeCalls = 0;
mockSseState.unsubscribeCalls = 0;
mockViewportMode.mockReturnValue("desktop");
mockFetchAiSessions.mockResolvedValue([]);
});
afterEach(() => {
vi.useRealTimers();
});
it("closes the session-list SSE while hidden, then re-subscribes and refreshes the list on reveal", async () => {
mockFetchAiSession.mockResolvedValue(awaitingQuestionSession);
const { rerender } = render(<PlanningModeModal {...planningProps(true)} />);
await waitFor(() => expect(mockSseState.subscribeCalls).toBeGreaterThanOrEqual(1));
const subscribedWhileVisible = mockSseState.subscribeCalls;
const listRefreshesWhileVisible = mockFetchAiSessions.mock.calls.length;
rerender(<PlanningModeModal {...planningProps(false)} />);
await waitFor(() => expect(mockSseState.unsubscribeCalls).toBe(subscribedWhileVisible));
expect(mockSseState.subscribeCalls).toBe(subscribedWhileVisible);
rerender(<PlanningModeModal {...planningProps(true)} />);
await waitFor(() => expect(mockSseState.subscribeCalls).toBe(subscribedWhileVisible + 1));
// Reveal refreshes the sessions list once so events dropped while hidden cannot leave stale rows.
await waitFor(() => expect(mockFetchAiSessions.mock.calls.length).toBe(listRefreshesWhileVisible + 1));
});
it("preserves the in-flight interview across hide/reveal without reloading the session", async () => {
mockFetchAiSession.mockResolvedValue(awaitingQuestionSession);
const { rerender } = render(<PlanningModeModal {...planningProps(true)} />);
expect(await screen.findByTestId("planning-question-text")).toHaveTextContent("What is the scope?");
const sessionLoads = mockFetchAiSession.mock.calls.length;
rerender(<PlanningModeModal {...planningProps(false)} />);
rerender(<PlanningModeModal {...planningProps(true)} />);
// Same instance, same ViewState — no reload flash, no re-fetch of the session.
expect(screen.getByTestId("planning-question-text")).toHaveTextContent("What is the scope?");
expect(mockFetchAiSession.mock.calls.length).toBe(sessionLoads);
});
it("suspends the loading-state recovery poll while hidden and re-arms it on reveal", async () => {
mockFetchAiSession.mockResolvedValue({
...base,
status: "generating",
currentQuestion: null,
result: null,
inputPayload: JSON.stringify({ generationPurpose: "plan_update" }),
});
const { rerender } = render(<PlanningModeModal {...planningProps(true)} />);
await waitFor(() => expect(mockConnectPlanningStream).toHaveBeenCalledTimes(1));
const loadsBeforeHide = mockFetchAiSession.mock.calls.length;
vi.useFakeTimers();
rerender(<PlanningModeModal {...planningProps(false)} />);
await act(async () => {
vi.advanceTimersByTime(20_000);
});
expect(mockFetchAiSession.mock.calls.length).toBe(loadsBeforeHide);
rerender(<PlanningModeModal {...planningProps(true)} />);
await act(async () => {
vi.advanceTimersByTime(9_000);
});
expect(mockFetchAiSession.mock.calls.length).toBeGreaterThan(loadsBeforeHide);
});
/*
FNXC:PlanningKeepAlive 2026-07-22-13:55:
Surface Enumeration: the keep-alive gating must hold on the mobile breakpoint too (landscape phones included) — same suspend/reveal contract as desktop.
*/
it("suspends and restores the session-list SSE across hide/reveal on the mobile breakpoint", async () => {
mockViewportMode.mockReturnValue("mobile");
mockFetchAiSession.mockResolvedValue(awaitingQuestionSession);
const { rerender } = render(<PlanningModeModal {...planningProps(true)} />);
await waitFor(() => expect(mockSseState.subscribeCalls).toBeGreaterThanOrEqual(1));
const subscribedWhileVisible = mockSseState.subscribeCalls;
rerender(<PlanningModeModal {...planningProps(false)} />);
await waitFor(() => expect(mockSseState.unsubscribeCalls).toBe(subscribedWhileVisible));
rerender(<PlanningModeModal {...planningProps(true)} />);
await waitFor(() => expect(mockSseState.subscribeCalls).toBe(subscribedWhileVisible + 1));
expect(screen.getByTestId("planning-question-text")).toHaveTextContent("What is the scope?");
});
it("records remount on first activation and route-active (not remount) on keep-alive reveal", async () => {
mockFetchAiSession.mockResolvedValue(awaitingQuestionSession);
const { rerender } = render(<PlanningModeModal {...planningProps(true)} />);
expect(await screen.findByTestId("planning-question-text")).toHaveTextContent("What is the scope?");
const planningTriggers = () => getResumeEvents().filter((event) => event.view === "PlanningMode").map((event) => event.trigger);
expect(planningTriggers()).toEqual(["remount"]);
rerender(<PlanningModeModal {...planningProps(false)} />);
expect(planningTriggers()).toEqual(["remount", "route-inactive"]);
rerender(<PlanningModeModal {...planningProps(true)} />);
expect(planningTriggers()).toEqual(["remount", "route-inactive", "route-active"]);
});
});

View File

@@ -775,6 +775,50 @@ describe("SessionTerminal", () => {
await waitFor(() => expect(onConfirmAdvance).toHaveBeenCalledWith("not-yet")); await waitFor(() => expect(onConfirmAdvance).toHaveBeenCalledWith("not-yet"));
await waitFor(() => expect(screen.queryByText("Not yet")).toBeNull()); await waitFor(() => expect(screen.queryByText("Not yet")).toBeNull());
}); });
/*
FNXC:TaskDetailTerminalKeepAlive 2026-07-22-13:00:
FN remount-churn fix R6/R9: kept-alive hidden terminals must NOT close the WS or dispose xterm; reveal refits and recovers a socket that died while hidden.
*/
describe("keep-alive active gating", () => {
it("keeps the WebSocket open and xterm alive across hide/reveal, refitting on reveal", async () => {
const { rerender } = render(<SessionTerminal sessionId="s1" active />);
await waitFor(() => expect(FakeWS.instances.length).toBe(1));
const ws = FakeWS.instances[0];
rerender(<SessionTerminal sessionId="s1" active={false} />);
expect(ws.readyState).toBe(1);
expect(mockTerm.dispose).not.toHaveBeenCalled();
mockFitAddon.fit.mockClear();
ws.sent.length = 0;
rerender(<SessionTerminal sessionId="s1" active />);
expect(mockTerm.dispose).not.toHaveBeenCalled();
expect(FakeWS.instances.length).toBe(1);
expect(apiMock).toHaveBeenCalledTimes(1);
// Reveal refit: fit + resize frame so the grid matches any size change that happened while hidden.
await waitFor(() => expect(mockFitAddon.fit).toHaveBeenCalled());
expect(ws.sent.some((frame) => (JSON.parse(frame) as { type?: string }).type === "resize")).toBe(true);
});
it("re-runs the full attach lifecycle when the WS died while hidden", async () => {
const { rerender } = render(<SessionTerminal sessionId="s1" active />);
await waitFor(() => expect(FakeWS.instances.length).toBe(1));
const ws = FakeWS.instances[0];
rerender(<SessionTerminal sessionId="s1" active={false} />);
// Server closes the socket while the tab is hidden.
ws.readyState = 3;
rerender(<SessionTerminal sessionId="s1" active />);
// Dead-socket recovery: fresh ticket, fresh WS, old xterm disposed by the lifecycle teardown.
await waitFor(() => expect(FakeWS.instances.length).toBe(2));
expect(apiMock).toHaveBeenCalledTimes(2);
expect(mockTerm.dispose).toHaveBeenCalled();
});
});
}); });
/* /*

View File

@@ -1214,6 +1214,59 @@ describe("TaskChatTab", () => {
expect(thinkingBlocks[0].nextElementSibling).toBeNull(); expect(thinkingBlocks[0].nextElementSibling).toBeNull();
}); });
/*
FNXC:TaskChatSegmentKey 2026-07-22-12:00:
Segment keys must be kind + startIndex only. Embedding entries.length remounted the actively streaming segment on every appended entry, collapsing an expanded thinking block mid-stream.
*/
it("keeps an expanded thinking block expanded while entries stream into the same segment", async () => {
const user = userEvent.setup();
mockLogs([
makeEntry({ agent: "executor", type: "thinking", text: "First streamed thought" }),
]);
const { rerender } = render(<TaskChatTab task={makeTask({ column: "todo" })} active addToast={vi.fn()} />);
const thinking = screen.getByTestId("task-chat-thinking");
expect(thinking).not.toHaveAttribute("open");
await user.click(within(thinking).getByText("Thinking"));
expect(thinking).toHaveAttribute("open");
mockLogs([
makeEntry({ agent: "executor", type: "thinking", text: "First streamed thought" }),
makeEntry({ agent: "executor", type: "thinking", text: "Second streamed thought", timestamp: "2026-06-12T00:00:01.000Z" }),
]);
rerender(<TaskChatTab task={makeTask({ column: "todo" })} active addToast={vi.fn()} />);
expect(screen.getByTestId("task-chat-thinking")).toHaveAttribute("open");
expect(screen.getByText(/Second streamed thought/)).toBeVisible();
});
it("gives a genuinely new segment a fresh instance with defaultOpen applied", async () => {
const user = userEvent.setup();
mockLogs([
makeEntry({ agent: "executor", type: "thinking", text: "Original reasoning" }),
]);
const { rerender } = render(<TaskChatTab task={makeTask({ column: "in-progress" })} active addToast={vi.fn()} />);
const firstThinking = screen.getByTestId("task-chat-thinking");
expect(firstThinking).toHaveAttribute("open");
await user.click(within(firstThinking).getByText("Thinking"));
expect(firstThinking).not.toHaveAttribute("open");
mockLogs([
makeEntry({ agent: "executor", type: "thinking", text: "Original reasoning" }),
makeEntry({ agent: "executor", text: "interleaved response", timestamp: "2026-06-12T00:00:01.000Z" }),
makeEntry({ agent: "executor", type: "thinking", text: "Later reasoning", timestamp: "2026-06-12T00:00:02.000Z" }),
]);
rerender(<TaskChatTab task={makeTask({ column: "in-progress" })} active addToast={vi.fn()} />);
const thinkingBlocks = screen.getAllByTestId("task-chat-thinking");
expect(thinkingBlocks).toHaveLength(2);
expect(thinkingBlocks[0]).not.toHaveAttribute("open");
expect(thinkingBlocks[1]).toHaveAttribute("open");
});
it("creates distinct tool segments when text or thinking entries are interleaved", () => { it("creates distinct tool segments when text or thinking entries are interleaved", () => {
mockLogs([ mockLogs([
makeEntry({ agent: "executor", type: "tool", text: "first tool", detail: "first detail" }), makeEntry({ agent: "executor", type: "tool", text: "first tool", detail: "first detail" }),

View File

@@ -1327,6 +1327,38 @@ describe("TaskDetailModal", () => {
expect(container.querySelector(".detail-body--agent-log")).toBeTruthy(); expect(container.querySelector(".detail-body--agent-log")).toBeTruthy();
}); });
/*
FNXC:TaskDetailTabKeepAlive 2026-07-22-13:05:
FN remount-churn fix R6: switching away from Planner chat must not destroy the composer draft or transcript — the tab body stays mounted inside an aria-hidden keep-alive wrapper and restores instantly on return.
*/
it("keeps the planner chat mounted with its draft across tab switches", () => {
render(
<TaskDetailModal
task={makeTask({ prompt: "# Hello\n\nContent" })}
taskDetailChatFirst
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const composer = screen.getByLabelText("Message planner chat") as HTMLTextAreaElement;
fireEvent.change(composer, { target: { value: "unsent planner draft" } });
expect(screen.getByTestId("planner-chat-keep-alive")).not.toHaveAttribute("aria-hidden");
fireEvent.click(screen.getByRole("button", { name: "Activity" }));
// Mounted-but-hidden: same instance, draft intact, wrapper inert for assistive tech.
expect(screen.getByTestId("planner-chat-keep-alive")).toHaveAttribute("aria-hidden", "true");
expect((screen.getByLabelText("Message planner chat") as HTMLTextAreaElement).value).toBe("unsent planner draft");
fireEvent.click(screen.getByRole("button", { name: "Chat" }));
expect(screen.getByTestId("planner-chat-keep-alive")).not.toHaveAttribute("aria-hidden");
expect((screen.getByLabelText("Message planner chat") as HTMLTextAreaElement).value).toBe("unsent planner draft");
});
it("FN-6347 removes the chat body modifier while editing", () => { it("FN-6347 removes the chat body modifier while editing", () => {
const { baseElement: container } = render( const { baseElement: container } = render(
<TaskDetailModal <TaskDetailModal

View File

@@ -0,0 +1,120 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, act, waitFor } from "@testing-library/react";
import {
makeTask,
noop,
noopDelete,
noopMerge,
noopMove,
noopOpenDetail,
setupTaskDetailModalHooks,
} from "./TaskDetailModal.test-helpers";
import { TaskDetailContent } from "../TaskDetailModal";
import * as dashboardApi from "../../api";
/*
FNXC:TaskPopupViewGating 2026-07-23-10:30:
FN remount-churn fix follow-up (PR #2420 review): kept-alive hidden popups render TaskDetailContent
with active={false}. While hidden, the detail must not react to document-level image paste (a paste
anywhere in the app used to attach the image to every hidden task) and must not poll the
verification-request endpoint on its 5s interval. Both resume exactly on reveal (active back to
true): paste re-registers, polling refreshes immediately and re-arms the interval.
*/
setupTaskDetailModalHooks();
function renderContent(active: boolean) {
const props = {
task: makeTask({ id: "FN-9001" }),
onMoveTask: noopMove,
onDeleteTask: noopDelete,
onMergeTask: noopMerge,
onOpenDetail: noopOpenDetail,
addToast: noop,
initialTab: "definition" as const,
};
const view = render(<TaskDetailContent {...props} active={active} />);
const rerenderWithActive = (nextActive: boolean) =>
view.rerender(<TaskDetailContent {...props} active={nextActive} />);
return { ...view, rerenderWithActive };
}
function dispatchImagePaste(file: File) {
const pasteEvent = new Event("paste", { bubbles: true }) as any;
pasteEvent.clipboardData = {
items: [
{
type: "image/png",
getAsFile: () => file,
},
],
};
document.dispatchEvent(pasteEvent);
}
describe("TaskDetailContent hidden-popup gating (active=false)", () => {
afterEach(() => {
vi.useRealTimers();
});
it("ignores document image paste while hidden and resumes paste handling on reveal", async () => {
const mockUpload = vi.mocked(dashboardApi.uploadAttachment);
mockUpload.mockClear();
mockUpload.mockResolvedValue({
filename: "abc123.png",
originalName: "image.png",
size: 1024,
mimeType: "image/png",
createdAt: "2026-01-01T00:00:00Z",
});
const imageFile = new File(["fake-image"], "image.png", { type: "image/png" });
const { rerenderWithActive } = renderContent(false);
await act(async () => {
dispatchImagePaste(imageFile);
});
// Hidden popup: the document paste listener must not be registered at all.
expect(mockUpload).not.toHaveBeenCalled();
rerenderWithActive(true);
await act(async () => {
dispatchImagePaste(imageFile);
});
await waitFor(() => {
expect(mockUpload).toHaveBeenCalledWith("FN-9001", imageFile, undefined);
});
});
it("suspends the 5s verification polling while hidden and resumes it on reveal", async () => {
vi.useFakeTimers();
const mockVerification = vi.mocked(dashboardApi.fetchTaskVerificationRequest);
mockVerification.mockClear();
mockVerification.mockResolvedValue(null);
const { rerenderWithActive } = renderContent(false);
// Hidden popup: neither the initial refresh nor the interval may fire.
await act(async () => {
await vi.advanceTimersByTimeAsync(16_000);
});
expect(mockVerification).not.toHaveBeenCalled();
// Reveal: immediate refresh plus a live 5s interval again.
rerenderWithActive(true);
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(mockVerification).toHaveBeenCalledTimes(1);
await act(async () => {
await vi.advanceTimersByTimeAsync(5_000);
});
expect(mockVerification).toHaveBeenCalledTimes(2);
// Hide again: the interval is torn down.
rerenderWithActive(false);
await act(async () => {
await vi.advanceTimersByTimeAsync(16_000);
});
expect(mockVerification).toHaveBeenCalledTimes(2);
});
});

View File

@@ -173,7 +173,12 @@ describe("TaskDetailModal planner Chat tab", () => {
await user.click(screen.getByRole("button", { name: "Activity" })); await user.click(screen.getByRole("button", { name: "Activity" }));
expect(screen.getByRole("button", { name: "Activity" })).toHaveClass("detail-tab-active"); expect(screen.getByRole("button", { name: "Activity" })).toHaveClass("detail-tab-active");
expect(screen.queryByTestId("task-planner-chat-panel")).not.toBeInTheDocument(); /*
FNXC:TaskDetailTabKeepAlive 2026-07-22-13:05:
The planner chat body is now kept alive across tab switches (FN remount-churn fix R6): the assertion moved from "not in DOM" to "hidden and inert" so the tab's intent (planner chat is not visible/interactive on Activity) still holds.
*/
expect(screen.getByTestId("planner-chat-keep-alive")).toHaveAttribute("aria-hidden", "true");
expect(screen.getByTestId("task-planner-chat-panel")).toBeInTheDocument();
}); });
it("preserves Summary as the default for done tasks while keeping Chat then Activity order", () => { it("preserves Summary as the default for done tasks while keeping Chat then Activity order", () => {

View File

@@ -26,7 +26,7 @@ import {
noopOpenDetail, noopOpenDetail,
setupTaskDetailModalHooks, setupTaskDetailModalHooks,
} from "./TaskDetailModal.test-helpers"; } from "./TaskDetailModal.test-helpers";
import { TaskDetailModal } from "../TaskDetailModal"; import { TaskDetailModal, TaskDetailContent } from "../TaskDetailModal";
import * as dashboardApi from "../../api"; import * as dashboardApi from "../../api";
setupTaskDetailModalHooks(); setupTaskDetailModalHooks();
@@ -191,4 +191,104 @@ describe("TaskDetailModal worktree terminal tab", () => {
expect(await screen.findByRole("button", { name: "Session" })).toBeInTheDocument(); expect(await screen.findByRole("button", { name: "Session" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Terminal" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Terminal" })).toBeInTheDocument();
}); });
/*
FNXC:TaskDetailTabKeepAlive 2026-07-22-13:05:
FN remount-churn fix R6/R10: the worktree Terminal tab body stays mounted-but-hidden across tab flips (no shell teardown), while a task switch still fully unmounts it.
*/
it("keeps the worktree terminal mounted-but-hidden across tab switches", async () => {
renderDetail(undefined, "worktree-terminal");
fireEvent.click(await screen.findByRole("button", { name: "Terminal" }));
expect(await screen.findByTestId("mock-worktree-terminal")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Plan" }));
// Mounted-but-hidden: the embedded terminal survives inside the aria-hidden keep-alive wrapper.
expect(screen.getByTestId("mock-worktree-terminal")).toBeInTheDocument();
expect(screen.getByTestId("worktree-terminal-keep-alive")).toHaveAttribute("aria-hidden", "true");
fireEvent.click(screen.getByRole("button", { name: "Terminal" }));
expect(screen.getByTestId("mock-worktree-terminal")).toBeInTheDocument();
expect(screen.getByTestId("worktree-terminal-keep-alive")).not.toHaveAttribute("aria-hidden");
});
it("fully unmounts the kept-alive worktree terminal when the task changes", async () => {
const { rerender } = renderDetail(undefined, "worktree-terminal");
fireEvent.click(await screen.findByRole("button", { name: "Terminal" }));
expect(await screen.findByTestId("mock-worktree-terminal")).toBeInTheDocument();
// Hide the terminal behind another tab, then switch tasks: the per-task latch must reset.
fireEvent.click(screen.getByRole("button", { name: "Plan" }));
expect(screen.getByTestId("worktree-terminal-keep-alive")).toBeInTheDocument();
rerender(
<TaskDetailModal
task={makeTask({ id: "FN-OTHER", worktree: "/repo/.worktrees/FN-OTHER" })}
projectId="proj-123"
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Keep-alive is scoped to one open task: the latch resets on task switch, so the terminal disposes exactly as before.
await waitFor(() => {
expect(screen.queryByTestId("worktree-terminal-keep-alive")).toBeNull();
expect(screen.queryByTestId("mock-worktree-terminal")).toBeNull();
});
});
/*
FNXC:TaskPopupViewGating 2026-07-23-10:35:
FN remount-churn fix follow-up (PR #2420 review): the kept-alive worktree terminal keeps
isOpen=true (xterm + WS survive) but must receive active=false whenever it is not the front
surface — behind another tab, or inside a hidden off-view popup (TaskDetailContent active=false) —
so its viewport listeners, resize observers, and refit loops suspend.
*/
it("suspends the kept-alive worktree terminal (active=false, isOpen=true) behind another tab and resumes on return", async () => {
renderDetail(undefined, "worktree-terminal");
fireEvent.click(await screen.findByRole("button", { name: "Terminal" }));
await screen.findByTestId("mock-worktree-terminal");
expect(mockEmbeddedTerminal).toHaveBeenLastCalledWith(expect.objectContaining({ isOpen: true, active: true }));
fireEvent.click(screen.getByRole("button", { name: "Plan" }));
expect(screen.getByTestId("mock-worktree-terminal")).toBeInTheDocument();
expect(mockEmbeddedTerminal).toHaveBeenLastCalledWith(expect.objectContaining({ isOpen: true, active: false }));
fireEvent.click(screen.getByRole("button", { name: "Terminal" }));
expect(mockEmbeddedTerminal).toHaveBeenLastCalledWith(expect.objectContaining({ isOpen: true, active: true }));
});
it("suspends the worktree terminal while the whole detail is a hidden kept-alive popup", async () => {
const task = makeTask({ id: "FN-7813", worktree: "/repo/.worktrees/FN-7813" });
const contentProps = {
task,
projectId: "proj-123",
onMoveTask: noopMove,
onDeleteTask: noopDelete,
onMergeTask: noopMerge,
onOpenDetail: noopOpenDetail,
addToast: noop,
initialTab: "worktree-terminal" as const,
};
const { rerender } = render(<TaskDetailContent {...contentProps} active />);
fireEvent.click(await screen.findByRole("button", { name: "Terminal" }));
await screen.findByTestId("mock-worktree-terminal");
expect(mockEmbeddedTerminal).toHaveBeenLastCalledWith(expect.objectContaining({ isOpen: true, active: true }));
// Popup hidden on another view: terminal stays mounted with the WS-preserving isOpen=true, aux work suspended.
rerender(<TaskDetailContent {...contentProps} active={false} />);
expect(screen.getByTestId("mock-worktree-terminal")).toBeInTheDocument();
expect(mockEmbeddedTerminal).toHaveBeenLastCalledWith(expect.objectContaining({ isOpen: true, active: false }));
// Reveal resumes auxiliary work.
rerender(<TaskDetailContent {...contentProps} active />);
expect(mockEmbeddedTerminal).toHaveBeenLastCalledWith(expect.objectContaining({ isOpen: true, active: true }));
});
}); });

View File

@@ -459,6 +459,29 @@ describe("TerminalModal", () => {
expect(container.firstChild).toBeNull(); expect(container.firstChild).toBeNull();
}); });
/*
FNXC:TaskPopupViewGating 2026-07-23-10:40:
FN remount-churn fix follow-up (PR #2420 review): kept-alive hosts keep isOpen=true and pass
active=false. The terminal must stay mounted (xterm + WS survive) while its auxiliary handlers —
here observable via the document Escape keydown — are suspended, then resume on reveal.
*/
it("stays mounted but suspends auxiliary keydown handling while active=false, resuming on reveal", async () => {
const { rerender } = render(<TerminalModal isOpen={true} active={false} onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByTestId("terminal-modal")).toBeTruthy();
});
// Suspended: the hidden terminal must not swallow Escape or close itself.
fireEvent.keyDown(document, { key: "Escape" });
expect(mockOnClose).not.toHaveBeenCalled();
// Reveal: the Escape handler re-registers and behaves exactly as before.
rerender(<TerminalModal isOpen={true} active={true} onClose={mockOnClose} />);
fireEvent.keyDown(document, { key: "Escape" });
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
it("renders embedded mode in-flow without overlay chrome while keeping shell tabs", async () => { it("renders embedded mode in-flow without overlay chrome while keeping shell tabs", async () => {
const { container } = render( const { container } = render(
<TerminalModal <TerminalModal

View File

@@ -5,6 +5,7 @@ import type { ActivityAnalytics, ColorTheme, SignalsAnalytics, ThemeMode, TokenA
import { api, fetchCodebaseMetrics, withProjectId, type CodebaseMetrics } from "../../api/legacy"; import { api, fetchCodebaseMetrics, withProjectId, type CodebaseMetrics } from "../../api/legacy";
import { formatBytes } from "../../utils/formatBytes"; import { formatBytes } from "../../utils/formatBytes";
import { DateRangePicker, defaultPresets, rangeFromPreset, type DateRange } from "./DateRangePicker"; import { DateRangePicker, defaultPresets, rangeFromPreset, type DateRange } from "./DateRangePicker";
import { getCommandCenterState, saveCommandCenterState } from "../../hooks/modalPersistence";
import { LoadingSpinner } from "../LoadingSpinner"; import { LoadingSpinner } from "../LoadingSpinner";
import { TaskVerificationStatus } from "../TaskVerificationStatus"; import { TaskVerificationStatus } from "../TaskVerificationStatus";
import { TokensArea } from "./areas/TokensArea"; import { TokensArea } from "./areas/TokensArea";
@@ -585,9 +586,29 @@ export function CommandCenter({
}: CommandCenterProps = {}) { }: CommandCenterProps = {}) {
const { t } = useTranslation("app"); const { t } = useTranslation("app");
const subViews = useSubViews(nodesEnabled); const subViews = useSubViews(nodesEnabled);
const [activeTab, setActiveTab] = useState<SubViewId>("overview"); /*
FNXC:CommandCenter 2026-07-22-13:40:
FN remount-churn fix R12: this view unmounts on navigation by design (no keep-alive), so the active sub-tab and date range restore from per-project persisted state on remount. Persisting follows the getPlanningDescription/GitHub-import precedent in modalPersistence.ts; a stored tab that no longer exists (e.g. nodes disabled) falls back to overview via the guard effect below.
*/
const [activeTab, setActiveTab] = useState<SubViewId>(() => (getCommandCenterState(projectId)?.activeTab as SubViewId | undefined) ?? "overview");
const [range, setRange] = useState<DateRange>(() => rangeFromPreset(defaultPresets((_k, f) => f)[1])); const [range, setRange] = useState<DateRange>(() => getCommandCenterState(projectId)?.range ?? rangeFromPreset(defaultPresets((_k, f) => f)[1]));
const persistedProjectRef = useRef(projectId);
useEffect(() => {
if (persistedProjectRef.current === projectId) return;
persistedProjectRef.current = projectId;
const stored = getCommandCenterState(projectId);
setActiveTab((stored?.activeTab as SubViewId | undefined) ?? "overview");
setRange(stored?.range ?? rangeFromPreset(defaultPresets((_k, f) => f)[1]));
}, [projectId]);
useEffect(() => {
if (persistedProjectRef.current !== projectId) return;
saveCommandCenterState({ activeTab, range }, projectId);
}, [activeTab, projectId, range]);
useEffect(() => {
if (!subViews.some((view) => view.id === activeTab)) setActiveTab("overview");
}, [activeTab, subViews]);
const tabRefs = useRef<Array<HTMLButtonElement | null>>([]); const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);

View File

@@ -463,6 +463,8 @@ function expectDailyActivityLineBeforeTrend() {
} }
beforeEach(() => { beforeEach(() => {
// FNXC:CommandCenter 2026-07-22-13:45: persisted tab/range state (R12) must not leak between tests.
localStorage.clear();
apiMock.mockReset(); apiMock.mockReset();
subscribeSseMock.mockReset(); subscribeSseMock.mockReset();
subscribeSseMock.mockImplementation(() => () => undefined); subscribeSseMock.mockImplementation(() => () => undefined);
@@ -492,6 +494,35 @@ describe("CommandCenter shell", () => {
expect(screen.queryByTestId("cc-controls-heartbeat")).toBeNull(); expect(screen.queryByTestId("cc-controls-heartbeat")).toBeNull();
}); });
/*
FNXC:CommandCenter 2026-07-22-13:45:
FN remount-churn fix R12: CommandCenter unmounts on navigation by design, so its active sub-tab and date range restore from per-project persisted state after an unmount round-trip, while a fresh project keeps the defaults.
*/
it("restores the active sub-tab and date range after an unmount round-trip", async () => {
localStorage.clear();
const props = { projectId: "project-a", colorTheme: "default" as const, themeMode: "dark" as const, onColorThemeChange: vi.fn(), onThemeModeChange: vi.fn() };
const { unmount } = render(<CommandCenter {...props} />);
fireEvent.click(screen.getByTestId("command-center-tab-tokens"));
expect(screen.getByTestId("command-center-tab-tokens").getAttribute("aria-selected")).toBe("true");
fireEvent.click(screen.getByTestId("cc-date-range-trigger"));
fireEvent.click(screen.getByTestId("cc-date-range-preset-30d"));
unmount();
render(<CommandCenter {...props} />);
expect(screen.getByTestId("command-center-tab-tokens").getAttribute("aria-selected")).toBe("true");
expect(screen.getByTestId("cc-date-range-trigger").textContent).toContain("Last 30 days");
});
it("keeps defaults for a project with no persisted Command Center state", () => {
localStorage.clear();
render(<CommandCenter projectId="fresh-project" colorTheme="default" themeMode="dark" onColorThemeChange={vi.fn()} onThemeModeChange={vi.fn()} />);
expect(screen.getByTestId("command-center-tab-overview").getAttribute("aria-selected")).toBe("true");
expect(screen.getByTestId("cc-date-range-trigger").textContent).toContain("Last 7 days");
});
it("does not retain a duplicate report entry on Overview", () => { it("does not retain a duplicate report entry on Overview", () => {
mockEmptyOverviewApi(); mockEmptyOverviewApi();
render(<CommandCenter />); render(<CommandCenter />);

View File

@@ -16,8 +16,6 @@ import type { NativeStructureCandidate } from "../MessageComposer";
import { PageErrorBoundary } from "../ErrorBoundary"; import { PageErrorBoundary } from "../ErrorBoundary";
import { BackendConnectionErrorPage } from "../BackendConnectionErrorPage"; import { BackendConnectionErrorPage } from "../BackendConnectionErrorPage";
import { CapacityRiskBanner } from "../CapacityRiskBanner"; import { CapacityRiskBanner } from "../CapacityRiskBanner";
import { PlanningModeModal } from "../PlanningModeModal";
import { PlanningWorkflowSwitcherSlot } from "../PlanningWorkflowSwitcherSlot";
import { HeaderWorkflowSwitcherSlot } from "../HeaderWorkflowSwitcherSlot"; import { HeaderWorkflowSwitcherSlot } from "../HeaderWorkflowSwitcherSlot";
import { GraphWorkflowSwitcherSlot, filterTasksByGraphWorkflowSelection } from "../GraphWorkflowSwitcherSlot"; import { GraphWorkflowSwitcherSlot, filterTasksByGraphWorkflowSelection } from "../GraphWorkflowSwitcherSlot";
import { PluginDashboardViewHost } from "../../plugins/PluginDashboardViewHost"; import { PluginDashboardViewHost } from "../../plugins/PluginDashboardViewHost";
@@ -69,7 +67,6 @@ export function MainContent({
isRemote, isRemote,
remoteData, remoteData,
tasks, tasks,
bgPlanningSessions,
workflowSteps, workflowSteps,
subscribePluginEvents, subscribePluginEvents,
openDetailTask, openDetailTask,
@@ -115,8 +112,6 @@ export function MainContent({
ingestCreatedTasks, ingestCreatedTasks,
nodesEnabled, nodesEnabled,
openWorkflowEditorWithNav, openWorkflowEditorWithNav,
handlePlanningTaskCreated,
handlePlanningTasksCreated,
handleGitHubImport, handleGitHubImport,
devServerEnabled, devServerEnabled,
mainPanelDetailTask, mainPanelDetailTask,
@@ -190,7 +185,6 @@ export function MainContent({
_WorkflowEditorView, _WorkflowEditorView,
}: MainContentProps) { }: MainContentProps) {
const [missionWorkflowId, setMissionWorkflowId] = useState<string | null>(null); const [missionWorkflowId, setMissionWorkflowId] = useState<string | null>(null);
const [planningHeaderWorkflowId, setPlanningHeaderWorkflowId] = useState<string | null>(null);
const [nativeStructureCandidates, setNativeStructureCandidates] = useState<NativeStructureCandidate[]>([]); const [nativeStructureCandidates, setNativeStructureCandidates] = useState<NativeStructureCandidate[]>([]);
/* /*
@@ -726,61 +720,12 @@ export function MainContent({
/* /*
FNXC:Navigation 2026-06-21-00:00: 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. 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.
FNXC:PlanningKeepAlive 2026-07-22-12:30:
The planning subtree no longer renders from this switch. App.tsx mounts <PlanningKeepAlive> as a kept-alive sibling of MainContent inside .project-content (after Planning's first open), so navigating away hides it instead of unmounting the interview. This branch returns null so the switch contributes nothing while the keep-alive layer is the visible view.
Project-switch remount and one-shot initialPlan consumption (main's ProjectSwitchModalReset / PlanningMode notes) live on PlanningKeepAlive + modalManager.clearPlanningInitialPlan, not here.
*/ */
const closePlanningView = () => { return null;
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.
FNXC:WorkflowAggregation 2026-07-01-00:00:
The Planning header selector may choose All workflows for aggregate browsing, but embedded PlanningModeModal receives a real workflow id from the header or `null` default behavior; explicit modalManager workflow entry points still win.
*/}
<PlanningWorkflowSwitcherSlot
projectId={currentProject?.id}
onOpenWorkflowEditor={openWorkflowEditorWithNav}
onWorkflowSelectionChange={(selection) => setPlanningHeaderWorkflowId(selection && !selection.isAllWorkflowsSelected ? selection.selectedWorkflow.id : null)}
/>
{/*
FNXC:ProjectSwitchModalReset 2026-07-23-00:00:
Key embedded Planning by project so a project swap remounts it. Without the remount a
running plan kept its stream, selected session, and sidebar list from the previous
project, and the "durable active session" effect re-fired with the new projectId while
the old session was still selected — persisting project A's session as project B's
active planning session, so project B kept restoring project A's plan. Unmount cleanup
already closes the stream; the new mount fetches the new project's session list and
restores that project's own persisted draft/active session.
FNXC:PlanningMode 2026-07-23-00:00:
The seeded initialPlan is a one-shot handoff consumed via onInitialPlanConsumed the moment
Planning's auto-start fires. Planning fully unmounts whenever taskView leaves "planning",
which resets its in-component auto-start guard; before consumption existed, the still-set
modalManager.planningInitialPlan re-auto-started a duplicate planning session on every
navigate-back remount (and on the project-switch remount key above) while the original
session was silently abandoned.
*/}
<PlanningModeModal
key={currentProject?.id ?? "all-projects"}
isOpen={true}
onClose={closePlanningView}
onTaskCreated={handlePlanningTaskCreated}
onTasksCreated={handlePlanningTasksCreated}
onViewTask={openBoardTaskDetail}
tasks={tasks}
initialSessions={bgPlanningSessions}
initialPlan={modalManager.planningInitialPlan ?? undefined}
onInitialPlanConsumed={modalManager.clearPlanningInitialPlan}
projectId={currentProject?.id}
workflowId={modalManager.planningWorkflowId ?? planningHeaderWorkflowId}
resumeSessionId={modalManager.planningResumeSessionId}
presentation="embedded"
/>
</PageErrorBoundary>
);
} }
/* /*

View File

@@ -0,0 +1,84 @@
import { useState } from "react";
import type { Task, TaskDetail } from "@fusion/core";
import type { AiSessionSummary } from "../../api";
import { PageErrorBoundary } from "../ErrorBoundary";
import { PlanningModeModal } from "../PlanningModeModal";
import { PlanningWorkflowSwitcherSlot } from "../PlanningWorkflowSwitcherSlot";
import { KeepAliveView } from "../KeepAliveView";
import type { ModalManager, DetailTaskTab } from "../../hooks/useModalManager";
import type { TaskView } from "../../hooks/useViewState";
/*
FNXC:PlanningKeepAlive 2026-07-22-12:30:
FN remount-churn fix R5: the embedded Planning Mode view previously lived inside MainContent's pure taskView switch, so every sidebar navigation destroyed the whole interview (ViewState, conversation, streaming output, draft edits, scroll). This host renders the planning subtree as a kept-alive sibling of MainContent inside .project-content: App mounts it after Planning's first open for the current project (everOpened latch mirroring Quick Chat's quickChatEverOpenedProjectId) and it then stays mounted, hidden via KeepAliveView's out-of-flow visibility contract whenever another view is active.
- `active` (taskView === "planning") gates PlanningModeModal's background work (session-list SSE, recovery poll, elapsed ticker) while hidden per R8.
- The header WorkflowSwitcher portal renders only while active so a hidden Planning view never occupies the shared Header slot.
- App keys this host by project id + modalManager.planningEntryGeneration: project switches and payload-carrying entry points (initial-plan handoff, resume session) remount with fresh-open semantics, while plain navigation restores the live instance (R10 — explicit handoffs keep their pre-keep-alive reset behavior).
- Explicit close still runs modalManager.closePlanning() (clears the entry payload) and returns to Board; the tree stays mounted for the next instant reveal.
*/
export interface PlanningKeepAliveProps {
active: boolean;
projectId: string;
tasks: Task[];
bgPlanningSessions: AiSessionSummary[];
modalManager: ModalManager;
handleChangeTaskView: (newView: TaskView) => void;
handlePlanningTaskCreated: (task: Task) => void;
handlePlanningTasksCreated: (tasks: Task[]) => void;
openBoardTaskDetail: (task: Task | TaskDetail, initialTab?: DetailTaskTab) => void;
openWorkflowEditorWithNav: (workflowId?: string) => void;
}
export function PlanningKeepAlive({
active,
projectId,
tasks,
bgPlanningSessions,
modalManager,
handleChangeTaskView,
handlePlanningTaskCreated,
handlePlanningTasksCreated,
openBoardTaskDetail,
openWorkflowEditorWithNav,
}: PlanningKeepAliveProps) {
const [planningHeaderWorkflowId, setPlanningHeaderWorkflowId] = useState<string | null>(null);
const closePlanningView = () => {
modalManager.closePlanning();
handleChangeTaskView("board");
};
return (
<KeepAliveView hidden={!active} testId="planning-keep-alive">
<PageErrorBoundary>
{active ? (
<PlanningWorkflowSwitcherSlot
projectId={projectId}
onOpenWorkflowEditor={openWorkflowEditorWithNav}
onWorkflowSelectionChange={(selection) => setPlanningHeaderWorkflowId(selection && !selection.isAllWorkflowsSelected ? selection.selectedWorkflow.id : null)}
/>
) : null}
{/*
FNXC:PlanningKeepAlive 2026-07-26-07:20:
Wire main's onInitialPlanConsumed (clearPlanningInitialPlan) so a keep-alive remount on project switch or planningEntryGeneration cannot re-auto-start a still-set initialPlan after the first consumption. Project identity is App's key on this host.
*/}
<PlanningModeModal
isOpen={true}
active={active}
onClose={closePlanningView}
onTaskCreated={handlePlanningTaskCreated}
onTasksCreated={handlePlanningTasksCreated}
onViewTask={openBoardTaskDetail}
tasks={tasks}
initialSessions={bgPlanningSessions}
initialPlan={modalManager.planningInitialPlan ?? undefined}
onInitialPlanConsumed={modalManager.clearPlanningInitialPlan}
projectId={projectId}
workflowId={modalManager.planningWorkflowId ?? planningHeaderWorkflowId}
resumeSessionId={modalManager.planningResumeSessionId}
presentation="embedded"
/>
</PageErrorBoundary>
</KeepAliveView>
);
}

View File

@@ -109,7 +109,6 @@ export interface MainContentProps {
remoteData: UseRemoteNodeDataResult; remoteData: UseRemoteNodeDataResult;
tasks: Task[]; tasks: Task[];
/** Active planning sessions loaded by App before the Planning view mounts. */ /** Active planning sessions loaded by App before the Planning view mounts. */
bgPlanningSessions: AiSessionSummary[];
workflowSteps: WorkflowStep[]; workflowSteps: WorkflowStep[];
subscribePluginEvents: ( subscribePluginEvents: (
pluginId: string, pluginId: string,
@@ -163,8 +162,6 @@ export interface MainContentProps {
ingestCreatedTasks: (tasks: Task[]) => void; ingestCreatedTasks: (tasks: Task[]) => void;
nodesEnabled: boolean; nodesEnabled: boolean;
openWorkflowEditorWithNav: (workflowId?: string) => void; openWorkflowEditorWithNav: (workflowId?: string) => void;
handlePlanningTaskCreated: (task: Task) => void;
handlePlanningTasksCreated: (tasks: Task[]) => void;
handleGitHubImport: (task: Task) => void; handleGitHubImport: (task: Task) => void;
devServerEnabled: boolean; devServerEnabled: boolean;
mainPanelDetailTask: Task | TaskDetail | null; mainPanelDetailTask: Task | TaskDetail | null;

View File

@@ -609,7 +609,11 @@ export function McpServersCard({ scope, form, setForm, globalSettings, projectId
const validation = validateStates[server.name] ?? { status: "idle" as const }; const validation = validateStates[server.name] ?? { status: "idle" as const };
const editable = scope === "global" || state !== "inherited"; const editable = scope === "global" || state !== "inherited";
return ( return (
<article className="mcp-server-row" key={`${state}:${server.name}`} data-testid={`mcp-server-row-${server.name}`}> /*
FNXC:McpSettings 2026-07-22-12:10:
Row identity is server.name alone. Embedding the derived `state` in the key remounted the row on every state transition (inherited -> project-local, enable/disable) even though the row represents the same server; validation state lives externally in `validateStates`, so nothing relies on the remount.
*/
<article className="mcp-server-row" key={server.name} data-testid={`mcp-server-row-${server.name}`}>
<div className="mcp-server-row__main"> <div className="mcp-server-row__main">
<div className="mcp-server-row__titleline"> <div className="mcp-server-row__titleline">
<strong>{server.name}</strong> <strong>{server.name}</strong>

View File

@@ -141,3 +141,83 @@ export function getGitHubImportState(projectId?: string): GitHubImportPersistedS
export function clearGitHubImportState(projectId?: string): void { export function clearGitHubImportState(projectId?: string): void {
removeScopedItem(STORED_GITHUB_IMPORT_KEY, projectId); removeScopedItem(STORED_GITHUB_IMPORT_KEY, projectId);
} }
// Command Center / Dev Server cheap-view persistence
/*
FNXC:CommandCenter 2026-07-22-13:40:
FN remount-churn fix R12: CommandCenter fully unmounts on main-content navigation (it is intentionally NOT kept alive), so its cheap UI state — active sub-tab and date range — persists per project like the GitHub import state above. Only restorable selection state is stored; fetched analytics re-derive on remount. A fresh project (nothing stored) keeps today's defaults, and an unknown/removed tab id falls back to overview at the consumer.
*/
export const STORED_COMMAND_CENTER_KEY = "kb-dashboard-command-center-state";
export interface CommandCenterPersistedState {
activeTab: string;
range: { from: string | null; to: string | null; preset: string };
}
export function saveCommandCenterState(state: CommandCenterPersistedState, projectId?: string): void {
try {
setScopedItem(STORED_COMMAND_CENTER_KEY, JSON.stringify(state), projectId);
} catch {
// Best-effort persistence; ignore storage failures.
}
}
export function getCommandCenterState(projectId?: string): CommandCenterPersistedState | null {
try {
const raw = getScopedItem(STORED_COMMAND_CENTER_KEY, projectId);
if (!raw) return null;
const parsed: unknown = JSON.parse(raw);
if (!parsed || typeof parsed !== "object") return null;
const p = parsed as Record<string, unknown>;
const range = p.range as Record<string, unknown> | undefined;
if (typeof p.activeTab !== "string" || !range || typeof range !== "object" || typeof range.preset !== "string") return null;
return {
activeTab: p.activeTab,
range: {
from: typeof range.from === "string" ? range.from : null,
to: typeof range.to === "string" ? range.to : null,
preset: range.preset,
},
};
} catch {
return null;
}
}
/*
FNXC:DevServer 2026-07-22-13:40:
FN remount-churn fix R12: DevServerView also unmounts on navigation; the selected script/task target and a typed-but-unsent command survive the round-trip per project. Log pagination/scroll intentionally does not persist (logs re-derive live). A fresh project gets defaults.
*/
export const STORED_DEV_SERVER_KEY = "kb-dashboard-dev-server-state";
export interface DevServerPersistedState {
selectedScript: string | null;
selectedTaskId: string | null;
commandInput: string;
}
export function saveDevServerState(state: DevServerPersistedState, projectId?: string): void {
try {
setScopedItem(STORED_DEV_SERVER_KEY, JSON.stringify(state), projectId);
} catch {
// Best-effort persistence; ignore storage failures.
}
}
export function getDevServerState(projectId?: string): DevServerPersistedState | null {
try {
const raw = getScopedItem(STORED_DEV_SERVER_KEY, projectId);
if (!raw) return null;
const parsed: unknown = JSON.parse(raw);
if (!parsed || typeof parsed !== "object") return null;
const p = parsed as Record<string, unknown>;
return {
selectedScript: typeof p.selectedScript === "string" ? p.selectedScript : null,
selectedTaskId: typeof p.selectedTaskId === "string" ? p.selectedTaskId : null,
commandInput: typeof p.commandInput === "string" ? p.commandInput : "",
};
} catch {
return null;
}
}

View File

@@ -51,6 +51,11 @@ export interface ModalManager {
planningInitialPlan: string | null; planningInitialPlan: string | null;
planningResumeSessionId: string | undefined; planningResumeSessionId: string | undefined;
planningWorkflowId: string | null | undefined; planningWorkflowId: string | null | undefined;
/*
FNXC:PlanningKeepAlive 2026-07-22-12:20:
Monotonic counter bumped by every payload-carrying planning entry point (initial plan handoff, resume/session open). The kept-alive embedded Planning instance keys on it so explicit handoffs remount with the pre-keep-alive fresh-open semantics (auto-start, session load), while plain sidebar navigation (openPlanning/closePlanning) leaves it untouched and restores the live instance.
*/
planningEntryGeneration: number;
isSubtaskOpen: boolean; isSubtaskOpen: boolean;
subtaskInitialDescription: string | null; subtaskInitialDescription: string | null;
subtaskResumeSessionId: string | undefined; subtaskResumeSessionId: string | undefined;
@@ -201,6 +206,8 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
const [planningInitialPlan, setPlanningInitialPlan] = useState<string | null>(null); const [planningInitialPlan, setPlanningInitialPlan] = useState<string | null>(null);
const [planningResumeSessionId, setPlanningResumeSessionId] = useState<string | undefined>(undefined); const [planningResumeSessionId, setPlanningResumeSessionId] = useState<string | undefined>(undefined);
const [planningWorkflowId, setPlanningWorkflowId] = useState<string | null | undefined>(undefined); const [planningWorkflowId, setPlanningWorkflowId] = useState<string | null | undefined>(undefined);
// FNXC:PlanningKeepAlive 2026-07-22-12:20: see ModalManager.planningEntryGeneration.
const [planningEntryGeneration, setPlanningEntryGeneration] = useState(0);
const [isSubtaskOpen, setIsSubtaskOpen] = useState(false); const [isSubtaskOpen, setIsSubtaskOpen] = useState(false);
const [subtaskInitialDescription, setSubtaskInitialDescription] = useState<string | null>(null); const [subtaskInitialDescription, setSubtaskInitialDescription] = useState<string | null>(null);
const [subtaskResumeSessionId, setSubtaskResumeSessionId] = useState<string | undefined>(undefined); const [subtaskResumeSessionId, setSubtaskResumeSessionId] = useState<string | undefined>(undefined);
@@ -302,6 +309,8 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
setPlanningResumeSessionId(undefined); setPlanningResumeSessionId(undefined);
setPlanningInitialPlan(initialPlan); setPlanningInitialPlan(initialPlan);
setPlanningWorkflowId(workflowId); setPlanningWorkflowId(workflowId);
// FNXC:PlanningKeepAlive 2026-07-22-12:20: payload-carrying entries bump the generation so the kept-alive instance remounts with fresh-open semantics.
setPlanningEntryGeneration((generation) => generation + 1);
setIsPlanningOpen(true); setIsPlanningOpen(true);
}, []); }, []);
const resumePlanning = useCallback(() => { const resumePlanning = useCallback(() => {
@@ -309,11 +318,13 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
if (!session) return; if (!session) return;
setPlanningWorkflowId(undefined); setPlanningWorkflowId(undefined);
setPlanningResumeSessionId(session.id); setPlanningResumeSessionId(session.id);
setPlanningEntryGeneration((generation) => generation + 1);
setIsPlanningOpen(true); setIsPlanningOpen(true);
}, [planningSessions]); }, [planningSessions]);
const openPlanningWithSession = useCallback((sessionId: string) => { const openPlanningWithSession = useCallback((sessionId: string) => {
setPlanningWorkflowId(undefined); setPlanningWorkflowId(undefined);
setPlanningResumeSessionId(sessionId); setPlanningResumeSessionId(sessionId);
setPlanningEntryGeneration((generation) => generation + 1);
setIsPlanningOpen(true); setIsPlanningOpen(true);
}, []); }, []);
const clearPlanningInitialPlan = useCallback(() => { const clearPlanningInitialPlan = useCallback(() => {
@@ -562,6 +573,7 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
planningInitialPlan, planningInitialPlan,
planningResumeSessionId, planningResumeSessionId,
planningWorkflowId, planningWorkflowId,
planningEntryGeneration,
isSubtaskOpen, isSubtaskOpen,
subtaskInitialDescription, subtaskInitialDescription,
subtaskResumeSessionId, subtaskResumeSessionId,