Concurrent dashboard work added runtime exports that hardcoded module
mocks did not expose, so any suite rendering the affected component threw
"No <export> is defined on the mock". Adds the missing `../api` exports
(system-info probe, update install/restart, cloudflared, provider key and
login helpers, git remotes/branches) and the `isTabletTouchViewport`
viewport helper across the 26 suites that mock those modules.
Verified: all 26 files pass (1012 tests).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
TaskCard inferred "unplanned" from steps.length === 0 while triage's
todo-discovery and the scheduler's dispatch filter both decide from
PROMPT.md seed-ness, so the badges disagreed with the engine in both
directions: a real spec that parsed to zero steps read as "Queued to
plan" while the scheduler already treated it as a WIP-slot candidate, and
a re-seeded card still carrying old steps read as "Ready" while triage
was about to plan it. Either way the badge sent operators to the wrong
cap.
Adds the shared isTaskAwaitingPlanning predicate (replan park, missing
spec, seed-vs-real content) used by both triage's discovery and a new
best-effort `awaitingPlanning` enrichment on GET /api/tasks. TaskCard
derives both badges from that one value — strict complements — and keeps
the step count only as a fallback for SSE payloads and older servers.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sweeping the other views for the FN-8606 typing bug found no further breakage,
but it did expose why the bug shipped: almost all field coverage uses
fireEvent.change, which sets a value in one shot on a node it already holds and
never needs the input to stay mounted. A remount is invisible to it.
Adds expectStableTyping (types character by character via userEvent, then asserts
DOM node identity, accumulated value, and retained focus) and applies it to the
uncovered surfaces: the FN-8606-migrated AddNode, ConnectNode, NodeDetail,
Scripts, and WorkflowAddStep modals, plus the board's QuickEntryBox composer and
SubtaskBreakdownModal title editing. All pass — this is a detection floor, not a
fix.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds fusion-react/no-nested-component-definitions, a custom rule in the house
style of the existing detached-spawn guard. A component declared in render is a
new element type every render, so React remounts its subtree on each parent
update and destroys focus, scroll, and local state.
This pattern shipped three times without review or tests catching it: FN-8606's
ModalShell left Planning Mode and Settings untypable, and MailboxModal's
ReplyContextExpandable collapsed expanded reply rows. Tests missed it because
fireEvent.change sets a value without needing the node to stay mounted.
The rule reports PascalCase functions (including memo()/forwardRef()-wrapped)
that return JSX and are declared inside another JSX-returning function.
Lowercase render helpers are deliberately allowed — they are the sanctioned fix.
Escape hatch: // nested-component-allowlist: <reason>.
Scoped to production .tsx, with a vitest guard for the rule itself. Hoists the
two pre-existing violations (ProviderStatusBadge, GitHubStatusBadge in
ModelOnboardingModal) to module scope so the rule lands clean at "error".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ReplyContextExpandable was declared inside MailboxModal's render, making it a new
element type on every render. Any parent update remounted the whole recursive
reply thread, so expanding one reply row collapsed the others and discarded their
DOM identity, focus, and scroll position.
Hoist it to module scope and pass the parent's reply state and handlers through an
explicit env prop so the element type stays stable. Adds a regression test that
expands one row, expands a second, and asserts the first keeps both its node
identity and aria-expanded state — same defect class as the FN-8606 ModalShell fix.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FN-8606 declared the window shell as a component inside PlanningModeModal and
SettingsModal render. A component declared in render is a new element type on
every render, so React remounted the whole subtree on each keystroke, destroying
the focused input: Planning Mode dropped everything after the first character and
Settings text fields did the same.
Replace ModalShell with a plain renderModalShell(children) call so the returned
element types stay stable, and add a Planning regression test that types
per-character across both the modal and embedded surfaces (fireEvent.change
cannot observe this class of bug, which is why it shipped).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The client bundle aliases `@fusion/core` to the leaf `core/src/types.ts` to
keep Node-only dependencies out of the browser, so a package-root import of
`FUSION_CLIENT_HEADER`/`FUSION_DASHBOARD_UI_CLIENT` typechecked but failed
`vite build`:
"FUSION_CLIENT_HEADER" is not exported by "../core/src/types.ts"
Follow the documented pattern instead of widening the root alias: declare a
`./task-delete-attribution` subpath export, add the matching Vite alias ahead
of the broader `@fusion/core` key (Vite matches in order), register the module
in the browser-safe-core allowlist, and import the subpath from the client.
`task-delete-attribution.ts` has no imports at all, so it is a safe leaf.
`app/utils/detectContentLanguage.ts` already warned about exactly this trap;
the miss was mine for verifying with typecheck, lint and test:gate but not
`pnpm build`, which is one of the four checks CI blocks on.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three related fixes, all originating from a `[api:error] Request failed`
log line showing a 500 on `GET /api/tasks/FN-8610/runtime-fallback`.
1. Missing/deleted tasks now return 404 instead of 500.
`getTaskImpl` signalled a miss with a bare `Error`, and route catches
only mapped errno `ENOENT` to 404 — a leftover from the file-backed
storage era. In Postgres mode nothing sets an errno code, so every
unknown/missing/soft-deleted/wrong-project read returned 500. Adds a
typed `TaskNotFoundError` (message byte-identical) plus a shared
`task-lookup-error` mapper applied across the task, session-diff,
git/GitHub, workflow and file-workspace route registrars. The same
bare throw existed on both archive-lifecycle delete paths, so
`DELETE /tasks/:id` was affected too.
2. 5xx logs now carry the origin stack.
`rethrowAsApiError` constructed a fresh `ApiError` from the message
and discarded the original, so the `FNXC:ApiErrorDiagnostics`
contract logged the rethrow site rather than the throw site — the
reported log entry had no stack at all. Threads `cause` through the
error factories and walks the chain (bounded, cycle-guarded).
3. Task deletions are attributable, and non-operator deletes notify.
`task:deleted` audit rows recorded `agentId: "system"` for every HTTP
delete, making an operator click indistinguishable from a script or
an agent; the calling agent's task id was accepted by the store and
then never persisted. Adds a `callerKind` union recorded in audit
metadata, tags every delete call site, and stamps a self-reported
`x-fusion-client` header from the dashboard client. When the caller
is `agent-tool` or `api-unattributed`, a best-effort notice is sent
to the operator mailbox; operator and engine deletes stay silent.
`x-fusion-client` is attribution, not authentication — anything can send
it. No delete-blocking, gating or permission logic is added here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A pre-merge check reporting a blocker is the ordinary in-review resting state
rather than an exceptional one, so badging it marked routine cards abnormal.
Operator-requested removal.
Suppression is expressed as a code list next to the existing
no-worktree-no-merge-confirmed entry, so both surfaces that gate on
shouldShowInReviewStallBadge -- the card header badge and the Task Detail
diagnostic block -- drop it from one place. The previous carve-out only
suppressed merge-blocker while isActiveMergeStatus(task.status) held; that is
gone, and the test row that used to expect a badge for status undefined now
asserts the suppression is unconditional.
task.inReviewStall keeps being computed and stored -- only the affordance is
withheld -- so the Review tab, run-audit, and self-healing are unaffected.
No dead CSS: the shared .in-review-stall classes still serve the remaining
codes and no --merge-blocker rule existed. The card test asserts no empty
badge shell is left behind.
Two TaskDetailModal cases used merge-blocker only as a fixture for the
diagnostics row and jump-to-activity-entry behavior; repointed at
transient-merge-status-no-owner so they still guard what they were written for.
Note for follow-up: this badge was the board's only signal for a card blocked
on a failed pre-merge step. self-healing's needsOperatorBypass comment already
flags that such cards "sit silently" behind a generic badge; with the badge
gone they show nothing at all on the board.
Verified: tsc -p tsconfig.app.json clean, pnpm lint clean, 693 tests passing
across the 6 affected suites.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An 11-reviewer pass over f157bf7460..f5163d8351 found defects in the mobile
tab-discard change set itself. This fixes them.
Silent data loss (the recurring defect class):
- AgentDetailView reconnect refetched limit:100 and replaced wholesale, so 380
displayed lines vanished with no "Load older" and no indicator; it now
reconciles through the shared logStreamReconcile helper.
- useActivityLog.loadMore past the cap discarded the page it had just fetched
while advancing the cursor and leaving hasMore true, so the feed silently
stopped paginating behind a live-looking button.
- useAgentLogs: loadMore and resyncFromServer had no mutual exclusion, a
no-overlap resync discarded explicitly paged-back history, a resync outliving
the reconnect delay left an unmarked gap, and the live-tail trim could evict
the gap marker itself.
- useLiveTranscript's resync overwrote live entries that raced the refetch.
The premise itself was not fully delivered:
- useProjects, useNodes, and useMeshState never called clearInterval, so they
polled the whole time the tab was hidden. useProjects is mounted for the
entire session, so the page never went idle -- the primary mechanism this
work depends on. All three now use the shared visibility gate.
- sse-bus fired onReconnect twice per reconnect cycle and fanned out ~28
subscribers in one tick, against a ~6-connection-per-origin cap on a waking
radio. The successful open is now the single authority, and the fan-out uses
the same exported stagger primitive as the polling path rather than a second
copy of the slot formula.
- A channel first subscribed during the hidden window opened a live EventSource
and keepalive; suspension is now a module-level condition openChannel
consults, and a channel opened inside the grace window re-arms it.
Credentials and correctness:
- The service worker persisted every GET /api/* to durable Cache Storage,
including /api/settings with daemonToken, githubAuthToken, gitlabAuthToken
and ntfyAccessToken in plaintext, with no exclusion and no purge path --
"Clear all cached data" only walked localStorage. Now gated, bounded, and
genuinely purgeable.
- useTasks cleared its own snapshot when the mount revalidation failed on a
waking radio, so the board blanked and the next restore was empty too.
Suspension-class failures no longer destroy the cache.
- A single-row SSE update reset lastFetchTimeMs to now while an hours-old
hydrated snapshot was on screen, re-marking every in-progress card stuck.
- ListView's "Select all visible tasks" acted on the full filtered set while
only 50 rows rendered, so a bulk delete reached rows the operator could not
see. Column's search window reset keyed on a boolean, so refining a query
kept the expanded window.
Tests that could not fail:
- App.test.tsx mocked TerminalModal as isOpen ? <div/> : null, making the
unmount-on-close invariant unobservable; MockEventSource kept its listeners
after close(), so cases passed with their onReconnect handlers deleted.
- The SSE resync ratchet scanned only hooks/, exempting ~13 component call
sites -- the exact regression it exists to prevent.
- MissionControlPanel's bespoke poll and the xterm scrollback constants and
WebGL disposal had no coverage at all.
Verified: tsc -p tsconfig.app.json clean, pnpm lint clean, pnpm
check:changesets clean, 877 tests passing across 36 scoped files.
Known unrelated red: MailboxView.test.tsx's FN-8407 CSS guard fails at HEAD
too -- this diff adds no @media rule and no .mailbox-view--mobile selector,
the only two things that assertion inspects. Left alone deliberately.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follow-up to f157bf7460, fixing the regressions an adversarial review found in
the mobile tab-discard work.
- SSE hidden-suspend dropped events silently: the per-task/run log streams are
live-only with no replay, and many subscribers had no onReconnect, so a 60s+
hidden window left invisible gaps in logs, a never-rendered approval banner,
a diverged chat transcript, and a missed merge advance notice. Every
subscriber now resyncs authoritative state on reopen.
- useAgentLogs refetches its authoritative page on reconnect and reports
hasMore truthfully once paging reaches the first entry.
- Agent run logs are windowed rather than discarded, so the head of a long run
stays reachable.
- lastFetchTimeMs is seeded from the cached envelope's savedAt, so a hydrated
stale snapshot no longer renders every in-progress card as stuck.
- MAX_IMMUTABLE_CACHE_ENTRIES lands as 200; it was committed as Infinity, which
left the cache-first bucket unbounded and the cap dead code.
- useAgentLogs.ts held a literal NUL byte that made git treat the file as binary
and grep skip it; replaced with an escape sequence so it stays reviewable.
Verified: tsc -p tsconfig.app.json clean, pnpm lint clean, pnpm check:changesets
clean, 25 scoped test files / 1337 tests passing. The xterm scrollback constants
and several components still lacking onReconnect remain untested.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Suspend poll/SSE work when the tab is hidden, cap log buffers, restore board scroll more reliably, and improve list windowing/live tickers with related tests and a mobile-tab retention changeset.
Columns are narrower than a phone viewport, so the first/last column's ideal
centered scrollLeft is outside the reachable scroll range. isColumnCentered
compared against that unreachable value, so an edge rest never read as
centered and commitDirectionalPage took its origin at release (already moved
onto the next column) instead of at gesture start — paging two columns.
Clamp the centering target to the reachable range, and clamp the mid-transit
origin against the gesture-start column so drag travel is never counted twice.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The `.floating-window__body` resize-handle clearance gutter (FN-8015) was
carved out only for 769-1024px, so landscape iPads (1180-1366 CSS px) fell
through to the desktop contract and kept it — content stopped ~17px short of
the right edge while the left edge stayed flush.
Gate the carve-out on the input device instead of viewport width: the gutter
only protects resize hot zones a pointer can actually grab. `(pointer: coarse)`
is primary-input only, so a touchscreen laptop on a trackpad still reports
`fine` and keeps desktop clearance.
Measured at 1180px: header inset went from 1px/17px to 1px/1px.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Add the `autoUpdateAndRestart` global setting (default off, Settings ->
General next to Release channel). When enabled, the dashboard host installs
available updates on the selected channel by itself and requests the
supervised in-place restart. Supervised hosts only: without a parent to
respawn, installing would leave a running process whose code no longer
matches its own install.
Fix two ways the restart affordance could silently do nothing:
- The supervisor now stamps FUSION_SUPERVISOR_PID and supervision is only
counted when that pid is the real parent. FUSION_RESTART_SUPERVISED is
inherited by every process Fusion spawns, so `fn dashboard` launched from
an agent terminal skipped its own supervisor while still advertising
restart support -- a restart request then killed it for good.
- Settings and the update banner probe /system/info on mount and treat
capability as advisory: the button always issues the request and shows the
server's actual refusal instead of sitting disabled after a failed probe.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Group mobile project switcher favorites ahead of other projects.
- Reuse the shared bookmark store for mobile project rows and toggles.
- Add responsive favorite-control styling and coverage for grouping, empty states, and selection behavior.
- Add a release changeset for the mobile favorite-project experience.
Files changed:
.changeset/mobile-project-favorites.md | 7 ++
packages/dashboard/app/components/Header.tsx | 120 +++++++++++++++------
.../dashboard/app/components/ProjectSelector.css | 40 +++++++
.../Header.mobile-project-favorites.test.tsx | 116 ++++++++++++++++++++
4 files changed, 253 insertions(+), 30 deletions(-)
Fusion-Task-Id: FN-8595
Fusion-Task-Lineage: 1f739ee9-1ff5-4b47-ac9e-7e4a5857f1a5
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
Extract shared fixed-menu positioning so deps/agent/node/priority pickers
clamp max-height without detaching from the trigger when free space is
shorter than the preferred dropdown height.
The footer rail already had overflow-x: auto, so it scrolled with a mouse,
but the global mobile `* { touch-action: pan-y }` lock intersected horizontal
pans away from every element. Opt the rail and its inner touch targets (the
buttons receive the touchstart; touch-action is not inherited) back into
pan-x, contain overscroll so a fling does not chain out to the document, and
free the button groups from the mobile max-width: 100% reset that squeezed
them into overlap instead of widening the scrollable content.
Also align the footer's media query with MOBILE_MEDIA_QUERY (max-width 768px
OR max-height 480px), which SettingsModal.tsx uses to pick the mobile footer
markup: landscape phones were rendering mobile markup under desktop CSS.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Multi-column fling reach came from release velocity alone, so a quick short
thumb flick (~30px, several px/ms) bought 2-3 extra columns and the board flew
past the intended column. Extra pages now also have to be earned with travel.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Promote on a held card printed the raw i18n key `board.rejection.unplannedForExecution`:
FN-8471 added the server-side code without a client case or catalog entry, so
translateRejection fell through to `t(messageKey, messageKey)`.
- Add the explicit rejection case (both translate helpers) plus the en catalog
entry and secondary-locale stubs.
- promoteHeldTask(..., { force }) waives ONLY the unplanned-for-execution gate;
hold membership, capacity and slot reservation still arbitrate. It clears a
needs-replan/plan-review-unavailable status so triage rediscovery cannot pull
the card back into the waived replan, and emits task:promote-forced-unplanned.
- POST /tasks/:id/promote accepts { force: true }; the board asks for explicit
confirmation first and only offers the override for this rejection.
Force stays operator-only — the sweep, the webhook release and fn_task_promote
never set it, so FN-7648 still holds for every automatic surface.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The mobile footer is a single nowrap scrolling rail, so an update banner
joining it clipped mid-sentence and pushed Import/Export/Reset/Close
off-screen. Render the update-check result in its own full-width row above
the rail on mobile; desktop/tablet keep it inline next to the version button.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The plan review pane rendered both the document-adjacent and the action-rail
"Add comment to selection" triggers, so operators saw duplicate buttons. Delete
the document variant and its --document/--mobile CSS pair; the rail button is
now the single control at every breakpoint.
Selection capture also ran on every mid-drag selectionchange, which mounted and
unmounted the trigger as the user dragged. Gate quote writes between pointerdown
and pointerup inside the plan document so the control appears once, on release.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Start performs a bare column move, so "Started planning {id}" reported an
outcome the handler cannot observe: the engine still has to admit the card, and
a busy pool (per-project maxConcurrent or cross-project globalMaxConcurrent) can
defer that indefinitely. The wait was only visible in the engine log
("Plan throttled by running-agent cap|global semaphore"), so a throttled card
looked like a bug.
- Add a "Queued to plan" badge: the exact complement of "Ready" (same idle-Todo
conditions, but no steps yet, so it waits for a PLANNING slot rather than a WIP
slot). Three Todo states are now distinguishable: planning in flight, queued to
plan, and ready. Pause suppression matches Ready; the badge reuses the existing
status-badge primitives with a color-mix tint, no new tokens.
- Retitle the Start toast to "Queued {id} for planning" in both call sites
(TaskCard Start and QuickEntryBox quick-add Start).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The "Open" button on a possible-duplicate warning did nothing — the modal
closed and no task appeared. The app has two task deep-link shapes and only
one had a consumer: `?task=<id>` was handled by useDeepLink, while
`#/tasks/<id>` had no `hashchange` listener anywhere in the dashboard.
Five surfaces write the hash form. InlineCreateCard and NewTaskModal write it
unconditionally, so their Open was always dead. QuickEntryBox, Column, and
ListView try an in-memory board lookup first and fall through to the dead hash,
which is why it looked intermittent: duplicate matches come from a
project-wide searchTasks, so a match that is `done` or outside the loaded
board slice misses the lookup and lands on the no-op.
Handle the hash form inside useDeepLink so the app keeps one deep-link
authority owning both shapes, rather than forking a parallel hook. The id is
resolved by fetch instead of an in-memory lookup (that lookup is the dead end
being removed), the hash is cleared via replaceState so re-Opening the same
task fires again, and an unresolvable id toasts instead of failing silently.
Regression tests assert the invariant shared by all five surfaces rather than
the single reported repro: a written hash always resolves to an open or a
toast, never a no-op. Verified against the pre-fix code — 5 of the 6 new tests
fail without this change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
POST /system/agents/restart-all constructed its AgentStore from rootDir alone,
with no AsyncDataLayer, so it fell through to the sync SQLite Database path
deleted under VAL-REMOVAL-005 and threw instead of bouncing agents. It now
builds the store against the scoped project's PostgreSQL layer via
requireAsyncLayer, failing loudly when project wiring is incomplete rather
than reading a SQLite shadow. This was the last unmigrated AgentStore call
site; the route test harness lacked getAsyncLayer, which is why nothing
caught it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CLI JSON/create success lines write via result() (raw stdout) so quiet mode
cannot drop machine-readable output; capture that seam in research/update/task
tests instead of console.log. Allowlist nested voiceInput settings for the
FN-7505 default-description guard and ship locale keys for Voice Input UI.