FN-8229: remove background AI tasks pill

Replace the redundant footer AI session control with progress and action visibility in the session notification banner.

- Delete the BackgroundTasksIndicator component, styles, footer wiring, and obsolete translations.
- Surface non-planning generating and retained error sessions through the notification banner while leaving Planning in its dedicated view.
- Update session lifecycle, localization, documentation, and regression coverage.

Files changed:
 .../fn-8229-remove-ai-background-tasks-pill.md     |   7 +
 docs/dashboard-guide.md                            |   4 +-
 packages/dashboard/README.md                       |   1 -
 packages/dashboard/app/App.tsx                     |  28 +--
 .../app/__tests__/css-hygiene-scan.test.ts         |   2 -
 .../dashboard-component-color-tokenization.test.ts |   1 -
 packages/dashboard/app/api/legacy.ts               |   2 +-
 .../app/components/BackgroundTasksIndicator.css    | 130 -------------
 .../app/components/BackgroundTasksIndicator.tsx    | 210 ---------------------
 .../dashboard/app/components/ExecutorStatusBar.css |   3 -
 .../dashboard/app/components/ExecutorStatusBar.tsx |  30 +--
 .../app/components/SessionNotificationBanner.css   |  10 +
 .../app/components/SessionNotificationBanner.tsx   |  52 ++++-
 .../app/components/SubtaskBreakdownModal.tsx       |   7 +-
 .../app/components/__tests__/App.test.tsx          |  11 +-
 .../__tests__/ExecutorStatusBar.test.tsx           |   1 -
 .../__tests__/SessionNotificationBanner.test.tsx   |  88 ++++-----
 .../components/__tests__/utility-mobile.test.tsx   | 191 +------------------
 .../app/components/dashboard/DashboardBanners.tsx  |   8 +-
 .../dashboard/__tests__/DashboardBanners.test.tsx  |  30 ++-
 .../hooks/__tests__/useBackgroundSessions.test.ts  |  21 ++-
 .../dashboard/app/hooks/useBackgroundSessions.ts   |  21 ++-
 .../app/utils/__tests__/appLifecycle.test.ts       |  61 ++----
 packages/dashboard/app/utils/appLifecycle.ts       |  16 +-
 packages/dashboard/src/routes.ts                   |   2 +-
 packages/i18n/locales/en/app.json                  |  33 +---
 packages/i18n/locales/es/app.json                  |  44 ++---
 packages/i18n/locales/fr/app.json                  |  44 ++---
 packages/i18n/locales/ko/app.json                  |  44 ++---
 packages/i18n/locales/zh-CN/app.json               |  44 ++---
 packages/i18n/locales/zh-TW/app.json               |  44 ++---
 packages/i18n/src/resources.d.ts                   |  48 ++---
 32 files changed, 316 insertions(+), 922 deletions(-)

Fusion-Task-Id: FN-8229

Fusion-Task-Lineage: a232fb04-08ff-4f0a-a440-e94545243e61

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-17 14:25:30 -07:00
parent 0b6957a1b2
commit 9152d8fa61
32 changed files with 316 additions and 922 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Remove the footer AI session pill; background progress now appears in the session notification banner.
category: feature
dev: Deletes BackgroundTasksIndicator and its footer wiring. The banner now shows non-planning generating and retained error sessions; planning remains in its docked view and nav badge, while cli-agent progress is observational to avoid a dead Resume action.

View File

@@ -490,9 +490,9 @@ Use **Show worktree grouping on the board** in **Settings → Worktrees** when y
Planning is a desktop/tablet left-sidebar main-content destination after **Command Center**. It opens the planning-session list and composer in the main content region; mobile continues to use the compact planning entry points. Planning Mode now includes branch controls on the summary screen before you create a task.
<!-- FNXC:SessionBanner 2026-07-15-00:00: FN-8001 requires every planning-session resume entry point to navigate to the embedded Planning view before reconnecting the session. The footer Background Tasks row and any needs-input Resume banner share this behavior on desktop and mobile. -->
<!-- FNXC:SessionBanner 2026-07-16-20:55: FN-8229 removes the redundant footer AI pill. The session notification banner preserves non-planning in-progress, needs-input, and error visibility while Planning sessions remain on their dedicated docked surface and navigation badge. -->
When a Planning session is awaiting your input, use its **Background Tasks** footer row or **Resume** banner action to open the docked Planning view and reconnect to that session. The Planning navigation item also opens the docked Planning view; its yellow needs-input dot is visible on the desktop left sidebar and mobile More controls.
When a Planning session needs your input or needs attention, open the docked Planning view from the **Planning** navigation item. Its yellow needs-input dot is visible on the desktop left sidebar and mobile More controls. Non-planning in-progress, needs-input, and error sessions appear in the session notification banner, where available Resume actions reconnect to their matching surface.
<!-- FNXC:PlanningRetry 2026-07-13-00:00: If Planning AI generation stalls and the server persists a terminal generation error, Planning Mode should auto-retry the same session up to three times before showing the permanent Retry/Dismiss error panel; any question or summary progress resets that budget. -->
When Planning AI generation appears stuck, Planning Mode automatically retries the same session up to three times and shows **Retrying… (attempt N of 3)** before falling back to the permanent **Retry**/**Dismiss** error panel. Any successful question or summary progress resets the automatic retry budget.

View File

@@ -260,7 +260,6 @@ In addition to the global mobile foundation, several power-user surfaces now inc
- Tree-view indentation is reduced (`.agent-tree__indent--1..4`) to prevent horizontal overflow at deeper hierarchy levels.
- **Utility components**
- **BackgroundTasksIndicator** popover switches to fixed viewport anchoring on mobile (`left/right: 8px`, `bottom: 40px`) so it is not clipped by parent containers.
- **ExecutorStatusBar** mobile layout includes tighter spacing and overflow guards (`min-width: 0`, hidden overflow in segments) for narrow screens.
- **ActiveAgentsPanel** grid stacks to one column on mobile (`.active-agents-grid { grid-template-columns: 1fr; }`).
- **ToastContainer** shifts above footer surfaces with safe-area awareness (`bottom: calc(44px + env(safe-area-inset-bottom, 0px))`, full-width toasts).

View File

@@ -93,8 +93,7 @@ import {
resolveDesktopShellRedirectTarget,
requiresNativeShellOnboarding,
shouldShowFirstEverBootLoader,
isSessionNeedingInputForBanner,
isPlanningAwaitingInput,
shouldShowSessionInBanner,
getCliActionDisabledReasonForBanner,
executeCliSessionBannerAction,
} from "./utils/appLifecycle";
@@ -106,7 +105,7 @@ export {
requiresNativeShellOnboarding,
shouldShowFirstEverBootLoader,
isSessionNeedingInputForBanner,
isPlanningAwaitingInput,
shouldShowSessionInBanner,
getCliActionDisabledReasonForBanner,
executeCliSessionBannerAction,
} from "./utils/appLifecycle";
@@ -382,18 +381,14 @@ function AppInner() {
const { themeMode, colorTheme, dashboardFontScalePct, shadcnCustomColors, resolvedThemeMode, setThemeMode, setColorTheme, setDashboardFontScalePct, setShadcnCustomColors } = useTheme();
// Background AI sessions - required before useModalManager
const { sessions: bgSessions, generating: bgGenerating, needsInput: bgNeedsInput, planningSessions: bgPlanningSessions, dismissSession: bgDismiss } = useBackgroundSessions(currentProject?.id);
const { sessions: bgSessions, planningSessions: bgPlanningSessions } = useBackgroundSessions(currentProject?.id);
/*
* FNXC:SessionBanner 2026-06-14-19:32:
* CLI agent sessions use `waiting_on_input` and `needs_attention` to represent user-actionable states. The banner feed must include those statuses in addition to the legacy planning-session statuses so visible CLI actions cannot be silently hidden from users.
*
* FNXC:SessionBanner 2026-07-05-00:00:
* Planning `awaiting_input` sessions are excluded from the banner feed: the banner's Resume button did not
* reliably redirect into Planning Mode. That signal now surfaces as a yellow `status-dot--pending` nav badge
* (see `planningNeedsInput` below) whose click target is the already-correct `planning` view navigation.
* Planning sessions in `error` status are unaffected and still render in the banner.
* FNXC:SessionBanner 2026-07-16-20:55:
* FN-8229 replaces the removed footer AI pill with this banner feed. It keeps
* non-planning generating and error sessions observable while Planning owns
* all of its statuses through the docked view and navigation badge.
*/
const sessionsNeedingInput = bgSessions.filter((s) => isSessionNeedingInputForBanner(s) && !isPlanningAwaitingInput(s));
const sessionsNeedingInput = bgSessions.filter(shouldShowSessionInBanner);
const sessionBannersHidden = useSessionBannersHidden();
const planningNeedsInput = bgPlanningSessions.some((s) => s.status === "awaiting_input");
@@ -1275,7 +1270,7 @@ function AppInner() {
// Dismissing the "needs input" banner only hides the prompt — it must NOT
// delete the underlying session. Sessions remain accessible from the
// Planning modal's sidebar (or the AI background tasks pill) so the user
// Planning modal's sidebar or the session notification banner so the user
// can return to them later. The banner already tracks dismissals locally
// via its own `dismissedIds` set, so these handlers are intentional no-ops.
const handleDismissNeedingInputSession = useCallback(() => {
@@ -1752,11 +1747,6 @@ function AppInner() {
projectId={currentProject.id}
taskStuckTimeoutMs={taskStuckTimeoutMs}
staleHighFanoutBlockerAgeThresholdMs={staleHighFanoutBlockerAgeThresholdMs}
backgroundSessions={bgSessions}
backgroundGenerating={bgGenerating}
backgroundNeedsInput={bgNeedsInput}
onOpenBackgroundSession={handleOpenBackgroundSession}
onDismissBackgroundSession={bgDismiss}
lastFetchTimeMs={lastFetchTimeMs}
currentProjectPath={currentProject.path}
onOpenProjectDirectory={handleOpenProjectDirectory}

View File

@@ -15,7 +15,6 @@ const recentlyLandedResponsiveFiles = [
const recentlyLandedHygieneFiles = [
...recentlyLandedResponsiveFiles,
"MobileWorkflowGraphView.css",
"BackgroundTasksIndicator.css",
"PullRequestView.css",
"TaskCard.css",
"TaskFieldsSection.css",
@@ -69,7 +68,6 @@ function extractSelectorBlocks(source: string, selector: string): string[] {
describe("component CSS hygiene scan regressions", () => {
it("keeps fixed text-on-accent CSS on --cta-text instead of hardcoded white", () => {
const fixedSelectors: Array<[string, string]> = [
["BackgroundTasksIndicator.css", ".background-tasks-indicator__pill--attention"],
["PullRequestView.css", ".pr-action--merge-confirm"],
["TaskCard.css", ".card-field-badge--boolean"],
["TaskFieldsSection.css", ".task-field-chip.is-active"],

View File

@@ -8,7 +8,6 @@ const root = resolve(__dirname, "../components");
const auditedCompliant = ["ChatView.css", "MobileNavBar.css", "ListView.css", "WorkflowResultsTab.css"];
const cleanedFiles = [
"AgentReflectionsTab.css",
"BackgroundTasksIndicator.css",
"CliBinaryInstallBanner.css",
"CliBinaryPanel.css",
"FileMentionPopup.css",

View File

@@ -8420,7 +8420,7 @@ export function reorderTodoItems(listId: string, itemIds: string[], projectId?:
});
}
// ── AI Sessions (Background Tasks) ─────────────────────────────────────────
// ── AI Sessions ────────────────────────────────────────────────────────────
/**
* Needs-attention variants for a CLI agent session (CLI Agent Executor, U11).

View File

@@ -1,130 +0,0 @@
/* === Background Tasks Indicator === */
.background-tasks-indicator {
position: relative;
}
.background-tasks-indicator__pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 1px 8px;
border-radius: 12px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text-muted);
cursor: pointer;
font-size: 12px;
font-weight: 500;
line-height: 18px;
white-space: nowrap;
transition: background var(--transition-fast);
}
.background-tasks-indicator__pill:hover:not(.background-tasks-indicator__pill--attention) {
background: color-mix(in srgb, var(--surface) 85%, var(--text-muted));
}
.background-tasks-indicator__pill--attention {
background: var(--triage);
color: var(--cta-text);
border-color: var(--triage);
}
.background-tasks-indicator__pill--attention:hover {
background: color-mix(in srgb, var(--triage) 85%, #000);
}
.background-tasks-indicator__popover {
position: absolute;
bottom: calc(100% + 8px);
left: 0;
min-width: 280px;
max-width: 360px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: var(--shadow-md);
z-index: 1000;
overflow: hidden;
}
.background-tasks-indicator__popover-header {
padding: 8px 12px;
border-bottom: 1px solid var(--border);
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
}
.background-tasks-indicator__popover-list {
max-height: 240px;
overflow-y: auto;
}
.background-tasks-indicator__item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
cursor: pointer;
}
.background-tasks-indicator__item:last-child {
border-bottom: none;
}
.background-tasks-indicator__session-icon {
flex-shrink: 0;
color: var(--text-muted);
}
.background-tasks-indicator__session-content {
flex: 1;
min-width: 0;
}
.background-tasks-indicator__session-title {
font-size: 13px;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.background-tasks-indicator__session-meta {
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
color: var(--text-muted);
}
.background-tasks-indicator__item-dismiss {
flex-shrink: 0;
background: none;
border: none;
cursor: pointer;
padding: 2px;
color: var(--text-muted);
border-radius: 4px;
}
@media (max-width: 768px) {
.background-tasks-indicator__popover {
position: fixed;
/* Add iOS home-indicator inset so the popover doesn't sit inside the
indicator zone on iPhone with viewport-fit=cover. */
bottom: calc(
var(--mobile-nav-height) +
env(safe-area-inset-bottom, 0px) +
var(--standalone-bottom-gap)
);
left: max(8px, env(safe-area-inset-left, 0px));
right: max(8px, env(safe-area-inset-right, 0px));
min-width: auto;
max-width: none;
}
.background-tasks-indicator__pill {
min-height: 28px;
}
}

View File

@@ -1,210 +0,0 @@
import "./BackgroundTasksIndicator.css";
import { useState, useRef, useEffect, useMemo } from "react";
import { Lightbulb, Layers, Target, Terminal, Loader2, HelpCircle, X, AlertCircle } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { AiSessionSummary } from "../api";
interface BackgroundTasksIndicatorProps {
sessions: AiSessionSummary[];
generating: number;
needsInput: number;
onOpenSession: (session: AiSessionSummary) => void;
onDismissSession: (id: string) => void;
}
const TYPE_ICONS = {
planning: Lightbulb,
subtask: Layers,
mission_interview: Target,
milestone_interview: Target,
slice_interview: Target,
"cli-agent": Terminal,
} as const;
export function BackgroundTasksIndicator({
sessions,
generating,
needsInput,
onOpenSession,
onDismissSession,
}: BackgroundTasksIndicatorProps) {
const { t } = useTranslation("app");
const [popoverOpen, setPopoverOpen] = useState(false);
const [recentlyUpdated, setRecentlyUpdated] = useState<Set<string>>(new Set());
const containerRef = useRef<HTMLDivElement>(null);
const previousSessionSignatureRef = useRef<Map<string, string>>(new Map());
const clearUpdatedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Type labels that are translatable
const TYPE_LABELS = useMemo(
() => ({
planning: t("backgroundTasks.typeLabel.planning", "Planning"),
subtask: t("backgroundTasks.typeLabel.subtask", "Subtask Breakdown"),
mission_interview: t("backgroundTasks.typeLabel.missionInterview", "Mission Interview"),
milestone_interview: t("backgroundTasks.typeLabel.milestoneInterview", "Milestone Interview"),
slice_interview: t("backgroundTasks.typeLabel.sliceInterview", "Slice Interview"),
"cli-agent": t("backgroundTasks.typeLabel.cliAgent", "CLI Agent"),
}),
[t],
);
// Close popover on outside click
useEffect(() => {
if (!popoverOpen) return;
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setPopoverOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [popoverOpen]);
/*
FNXC:PlanningMultiTab 2026-07-14-00:00:
No tab-ownership concept remains: AI interview sessions are multi-tab, so every session opens
directly with no "active in another tab" gate, confirm prompt, or lock badge. Item animation
keys off server-derived status/timestamp only.
*/
// Animate per-item changes when session status/timestamp changes.
useEffect(() => {
const changed = new Set<string>();
const nextSignature = new Map<string, string>();
for (const session of sessions) {
const signature = [session.status, session.updatedAt].join("|");
const previous = previousSessionSignatureRef.current.get(session.id);
if (previous && previous !== signature) {
changed.add(session.id);
}
nextSignature.set(session.id, signature);
}
previousSessionSignatureRef.current = nextSignature;
if (changed.size === 0) {
return;
}
setRecentlyUpdated(changed);
if (clearUpdatedTimerRef.current) {
clearTimeout(clearUpdatedTimerRef.current);
}
clearUpdatedTimerRef.current = setTimeout(() => {
setRecentlyUpdated(new Set());
clearUpdatedTimerRef.current = null;
}, 500);
return () => {
if (clearUpdatedTimerRef.current) {
clearTimeout(clearUpdatedTimerRef.current);
clearUpdatedTimerRef.current = null;
}
};
}, [sessions]);
if (sessions.length === 0) return null;
const total = sessions.length;
const hasAttention = needsInput > 0;
return (
<div ref={containerRef} className="background-tasks-indicator">
<button
className={`background-tasks-indicator__pill${hasAttention ? " background-tasks-indicator__pill--attention" : ""}`}
onClick={() => setPopoverOpen((prev) => !prev)}
title={
needsInput > 0
? t("backgroundTasks.pillTitleWithInput", "{{count}} background AI task ({{needsInput}} needs input)", { count: total, needsInput })
: t("backgroundTasks.pillTitle", "{{count}} background AI task", { count: total })
}
>
{generating > 0 && (
<Loader2 size={12} className="animate-spin" />
)}
{needsInput > 0 && generating === 0 && <HelpCircle size={12} />}
<span>{t("backgroundTasks.pillLabel", "AI {{count}}", { count: total })}</span>
</button>
{popoverOpen && (
<div className="background-tasks-indicator__popover">
<div className="background-tasks-indicator__popover-header">
{t("backgroundTasks.popoverHeader", "Background Tasks")}
</div>
<div className="background-tasks-indicator__popover-list">
{sessions.map((session) => {
const Icon = TYPE_ICONS[session.type];
const isGenerating = session.status === "generating";
const isAwaiting = session.status === "awaiting_input";
const isError = session.status === "error";
const isUpdated = recentlyUpdated.has(session.id);
return (
<div
key={session.id}
className={`background-tasks-indicator__item${isUpdated ? " background-tasks-indicator__item--updated" : ""}`}
style={{
transition: "background-color 220ms ease, transform 220ms ease",
backgroundColor: isUpdated
? "var(--color-accent-soft, rgba(59, 130, 246, 0.14))"
: undefined,
transform: isUpdated ? "translateY(-1px)" : undefined,
}}
onClick={() => {
onOpenSession(session);
setPopoverOpen(false);
}}
>
{isError ? (
<AlertCircle size={14} className="background-tasks-indicator__session-icon" style={{ color: "var(--color-error)" }} />
) : (
<Icon size={14} className="background-tasks-indicator__session-icon" />
)}
<div className="background-tasks-indicator__session-content">
<div className="background-tasks-indicator__session-title">
{session.title}
</div>
<div className="background-tasks-indicator__session-meta">
{isError ? t("backgroundTasks.status.failed", "Failed") : TYPE_LABELS[session.type]}
{isGenerating && ` — ${t("backgroundTasks.status.generating", "generating...")}`}
{isAwaiting && ` — ${t("backgroundTasks.status.needsInput", "needs input")}`}
</div>
</div>
{isGenerating && (
<Loader2
size={14}
className="background-tasks-indicator__session-icon animate-spin"
style={{ color: "var(--color-success)" }}
/>
)}
{isAwaiting && (
<HelpCircle
size={14}
className="background-tasks-indicator__session-icon"
style={{ color: "var(--triage)" }}
/>
)}
<button
className="background-tasks-indicator__item-dismiss"
onClick={(e) => {
e.stopPropagation();
onDismissSession(session.id);
}}
title={t("backgroundTasks.dismissButton", "Dismiss")}
>
<X size={12} />
</button>
</div>
);
})}
</div>
</div>
)}
</div>
);
}

View File

@@ -463,9 +463,6 @@ FN-6887 makes the footer status bar the canonical desktop/tablet terminal launch
max-width: min(26ch, 30vw);
}
.background-tasks-indicator {
flex-shrink: 0;
}
}
/* Light theme support */

View File

@@ -13,8 +13,7 @@ import { computeBlockerFanoutMap } from "../hooks/useBlockerFanout";
import { useExecutorStats } from "../hooks/useExecutorStats";
import { isLikelyTabSuspensionError } from "../hooks/visibilitySuspension";
import { LoadingSpinner } from "./LoadingSpinner";
import type { ExecutorState, AiSessionSummary } from "../api";
import { BackgroundTasksIndicator } from "./BackgroundTasksIndicator";
import type { ExecutorState } from "../api";
import { EngineControlMenu, type EngineControlMenuHandle } from "./EngineControlMenu";
import { TerminalLauncher } from "./TerminalLauncher";
import { useViewportMode } from "../hooks/useViewportMode";
@@ -66,12 +65,6 @@ interface ExecutorStatusBarProps {
taskStuckTimeoutMs?: number;
/** Age threshold in milliseconds before high fan-out blockers escalate in dashboard surfaces. */
staleHighFanoutBlockerAgeThresholdMs?: number;
/** Background AI sessions */
backgroundSessions?: AiSessionSummary[];
backgroundGenerating?: number;
backgroundNeedsInput?: number;
onOpenBackgroundSession?: (session: AiSessionSummary) => void;
onDismissBackgroundSession?: (id: string) => void;
/** Timestamp (ms) when task data was last confirmed fresh from the server. Used for freshness-aware stuck detection. */
lastFetchTimeMs?: number;
/** Absolute path for the currently selected project directory. */
@@ -148,7 +141,7 @@ function getStateDisplay(state: ExecutorState, t: TFunction<"app">): { label: st
* - Executor state badge (idle/running/paused/stopped)
* - Last activity timestamp
*/
export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleHighFanoutBlockerAgeThresholdMs, backgroundSessions, backgroundGenerating, backgroundNeedsInput, onOpenBackgroundSession, onDismissBackgroundSession, lastFetchTimeMs, currentProjectPath, onOpenProjectDirectory, keyboardOpen, hideWhenKeyboardOpen, onToggleTerminal, onOpenScripts, onRunScript, quickChatButtonMode = "off", onOpenQuickChat }: ExecutorStatusBarProps) {
export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleHighFanoutBlockerAgeThresholdMs, lastFetchTimeMs, currentProjectPath, onOpenProjectDirectory, keyboardOpen, hideWhenKeyboardOpen, onToggleTerminal, onOpenScripts, onRunScript, quickChatButtonMode = "off", onOpenQuickChat }: ExecutorStatusBarProps) {
const { t } = useTranslation("app");
const viewportMode = useViewportMode();
const isMobile = viewportMode === "mobile";
@@ -282,19 +275,12 @@ export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleH
role="status"
aria-label={t("executor.status", "Executor status")}
>
{/* Background AI tasks indicator */}
{backgroundSessions && backgroundSessions.length > 0 && onOpenBackgroundSession && onDismissBackgroundSession && (
<>
<BackgroundTasksIndicator
sessions={backgroundSessions}
generating={backgroundGenerating ?? 0}
needsInput={backgroundNeedsInput ?? 0}
onOpenSession={onOpenBackgroundSession}
onDismissSession={onDismissBackgroundSession}
/>
<span className="executor-status-bar__divider" aria-hidden="true" />
</>
)}
{/*
* FNXC:ExecutorStatusBar 2026-07-16-20:55:
* FN-8229 removes the redundant AI-session footer segment. The banner and
* Planning navigation now own session visibility, leaving this footer for
* executor statistics and launch controls only.
*/}
{/* Queued tasks */}
<MobileStatSegment

View File

@@ -253,3 +253,13 @@ FN-7020 requires the needs-input banner to stay visible when relevant without co
}
}
/*
FNXC:SessionBanner 2026-07-16-20:55:
FN-8229 uses the established banner icon treatment for non-planning work that
is still generating after the footer pill is removed. Keep progress semantic
rather than introducing a separate component or hard-coded status color.
*/
.session-notification-banner__type-icon--progress {
color: var(--color-accent);
}

View File

@@ -1,7 +1,7 @@
import "./SessionNotificationBanner.css";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { AlertCircle, Lightbulb, Layers, Target, Terminal, X } from "lucide-react";
import { AlertCircle, Lightbulb, Layers, LoaderCircle, Target, Terminal, X } from "lucide-react";
import type { AiSessionSummary, CliNeedsAttentionVariant } from "../api";
export type CliActionId = "advance" | "retry" | "cancel" | "reauthenticate" | "relaunch";
@@ -114,15 +114,16 @@ function persistDismissed(map: Map<string, number>): void {
/**
* Statuses that warrant a banner entry. Extended for CLI agent sessions:
* `waiting_on_input` (F2) and `needs_attention` (pinned variants) join the
* existing `awaiting_input` / `error`. A CLI session returning to `busy`
* (no longer in this set) clears the banner entry — covering F2.
* existing `awaiting_input` / `error`; FN-8229 also retains non-planning
* `generating` progress after removing the footer AI pill.
*/
function isNotifyingStatus(status: AiSessionSummary["status"]): boolean {
return (
status === "awaiting_input" ||
status === "error" ||
status === "waiting_on_input" ||
status === "needs_attention"
status === "needs_attention" ||
status === "generating"
);
}
@@ -199,9 +200,22 @@ export function SessionNotificationBanner({
const errorCount = sessionsNeedingInput.filter(
(s) => s.status === "error" || s.status === "needs_attention",
).length;
const generatingCount = sessionsNeedingInput.filter((s) => s.status === "generating").length;
let headerText = "";
if (awaitingInputCount > 0 && errorCount > 0) {
if (generatingCount > 0 && (awaitingInputCount > 0 || errorCount > 0)) {
headerText = t(
"sessionBanner.headerMixed",
"{{generatingCount}} AI sessions in progress, {{actionableCount}} need attention",
{ generatingCount, actionableCount: awaitingInputCount + errorCount },
);
} else if (generatingCount > 0) {
headerText = t(
generatingCount === 1 ? "sessionBanner.headerGeneratingSingular" : "sessionBanner.headerGeneratingPlural",
generatingCount === 1 ? "{{count}} AI session in progress" : "{{count}} AI sessions in progress",
{ count: generatingCount },
);
} else if (awaitingInputCount > 0 && errorCount > 0) {
headerText = t(
awaitingInputCount === 1
? "sessionBanner.headerAwaitingAndErrorSingular"
@@ -259,7 +273,7 @@ export function SessionNotificationBanner({
};
return (
<section className="session-notification-banner" role="region" aria-live="polite" aria-label={t("sessionBanner.regionLabel", "AI sessions needing input or failed")}>
<section className="session-notification-banner" role="region" aria-live="polite" aria-label={t("sessionBanner.regionLabel", "AI sessions in progress, needing input, or failed")}>
<div className="session-notification-banner__header">
<div className="session-notification-banner__headline">
<AlertCircle size={16} aria-hidden="true" />
@@ -275,6 +289,10 @@ export function SessionNotificationBanner({
{sessionsNeedingInput.map((session) => {
const Icon = TYPE_ICONS[session.type];
const isError = session.status === "error";
const isGenerating = session.status === "generating";
const isObservationalCliProgress = session.type === "cli-agent" && (
session.status === "generating" || session.status === "waiting_on_input"
);
const variantSpec =
session.type === "cli-agent" && session.cliVariant
? CLI_VARIANT_SPEC[session.cliVariant]
@@ -359,21 +377,35 @@ export function SessionNotificationBanner({
<div className="session-notification-banner__item-main">
{isError ? (
<AlertCircle size={16} className="session-notification-banner__type-icon session-notification-banner__type-icon--error" aria-hidden="true" />
) : isGenerating ? (
<LoaderCircle size={16} className="session-notification-banner__type-icon session-notification-banner__type-icon--progress animate-spin" aria-hidden="true" />
) : (
<Icon size={16} className="session-notification-banner__type-icon" aria-hidden="true" />
)}
<div className="session-notification-banner__text">
<p className="session-notification-banner__title" title={session.title}>{session.title}</p>
<p className="session-notification-banner__meta">
{isError ? t("sessionBanner.failed", "Failed") : t(TYPE_LABEL_KEYS[session.type].key, TYPE_LABEL_KEYS[session.type].defaultVal)}
{isError
? t("sessionBanner.failed", "Failed")
: isGenerating
? t("sessionBanner.inProgress", "In progress")
: t(TYPE_LABEL_KEYS[session.type].key, TYPE_LABEL_KEYS[session.type].defaultVal)}
</p>
</div>
</div>
<div className="session-notification-banner__actions">
<button className="session-notification-banner__resume" onClick={() => handleResume(session)}>
{isError ? t("sessionBanner.retry", "Retry") : t("sessionBanner.resume", "Resume")}
</button>
{/*
* FNXC:SessionBanner 2026-07-16-20:55:
* FN-8229 moves in-progress sessions from the footer pill here.
* CLI progress has no dashboard resume destination, so it is
* observational rather than exposing a silent Resume action.
*/}
{!isObservationalCliProgress && (
<button className="session-notification-banner__resume" onClick={() => handleResume(session)}>
{isError ? t("sessionBanner.retry", "Retry") : t("sessionBanner.resume", "Resume")}
</button>
)}
<button
className="session-notification-banner__dismiss"
onClick={() => {

View File

@@ -169,7 +169,10 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
/**
* FNXC:SubtaskBreakdown 2026-06-16-21:10:
* Closing the modal is an exit affordance, not an explicit discard. Preserve running and review-ready subtask sessions in the background-session list so users can resume from BackgroundTasksIndicator instead of losing AI work.
* FNXC:SessionBanner 2026-07-16-20:55:
* FN-8229 removes the footer AI pill. Closing this modal remains an exit
* affordance, so preserve running and review-ready sessions for resuming
* through the session notification banner rather than losing AI work.
*/
if (view.type !== "generating" && view.type !== "editing") return;
@@ -196,7 +199,7 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
if (hasUnsavedChanges) {
const shouldClose = await confirm({
title: t("subtasks.keepSessionTitle", "Keep subtask session available?"),
message: t("subtasks.keepSessionMessage", "Close this modal and keep the subtask session available from Background Tasks? Edited fields that have not been saved to the session may be reset when you resume."),
message: t("subtasks.keepSessionMessage", "Close this modal and keep the subtask session available from the session notification banner? Edited fields that have not been saved to the session may be reset when you resume."),
});
if (!shouldClose) {
return;

View File

@@ -616,6 +616,7 @@ vi.mock("../../hooks/useViewportMode", () => ({
useViewportMode: (...args: unknown[]) => mockUseViewportMode(...args),
getViewportMode: () => mockUseViewportMode(),
isMobileViewport: () => mockUseViewportMode() === "mobile",
isFullScreenSheetViewport: () => mockUseViewportMode() === "mobile",
}));
// Mock isIOS so FN-3290 keyboard-open behavior is testable in jsdom
@@ -3119,7 +3120,7 @@ describe("App Planning Mode", () => {
localStorage.removeItem(taskViewStorageKey());
});
it("opens a footer planning session in the embedded Planning view with its resume session", async () => {
it("opens a planning session from the retained Planning navigation surface", async () => {
const session: AiSessionSummary = {
id: "planning-session-needs-input",
type: "planning",
@@ -3138,13 +3139,12 @@ describe("App Planning Mode", () => {
render(<App />);
fireEvent.click(await screen.findByRole("button", { name: /AI 1/i }));
fireEvent.click(document.querySelector(".background-tasks-indicator__session-title")!);
fireEvent.click(await screen.findByTestId("sidebar-nav-planning"));
await waitFor(() => {
const planningView = screen.getByTestId("planning-view");
expect(planningView).toBeInTheDocument();
expect(planningView).toHaveAttribute("data-resume-session-id", session.id);
expect(screen.getByTestId("sidebar-nav-planning")).toHaveAttribute("aria-current", "page");
});
});
@@ -3172,8 +3172,7 @@ describe("App Planning Mode", () => {
render(<App />);
fireEvent.click(await screen.findByRole("button", { name: /AI 1/i }));
fireEvent.click(document.querySelector(".background-tasks-indicator__session-title")!);
fireEvent.click(await screen.findByRole("button", { name: "Resume" }));
await waitFor(() => {
expect(screen.getByTestId(`sidebar-nav-${expectedView}`)).toHaveAttribute("aria-current", "page");

View File

@@ -196,7 +196,6 @@ describe("ExecutorStatusBar", () => {
expectSegmentCount("In Review", "1");
expect(statusBar).toHaveTextContent("Overlap queue");
expect(statusBar).toHaveTextContent("FN-010 · 5 todo");
expect(statusBar).toHaveTextContent("AI 2");
expect(statusBar).not.toHaveTextContent("Done");
expect(statusBar.firstElementChild).not.toHaveClass("executor-status-bar__divider");
expect(statusBar.lastElementChild).toHaveClass("executor-status-bar__segment--engine-controls");

View File

@@ -2,7 +2,7 @@ import { describe, expect, it, vi, beforeEach } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import type { AiSessionSummary } from "../../api";
import { SessionNotificationBanner, dismissedIds } from "../SessionNotificationBanner";
import { isPlanningAwaitingInput, isSessionNeedingInputForBanner } from "../../utils/appLifecycle";
import { shouldShowSessionInBanner } from "../../utils/appLifecycle";
function buildSession(overrides: Partial<AiSessionSummary>): AiSessionSummary {
return {
@@ -20,11 +20,11 @@ describe("SessionNotificationBanner", () => {
dismissedIds.clear();
});
it("renders nothing when no sessions need input", () => {
it("renders nothing when no sessions need input or are in progress", () => {
const { container } = render(
<SessionNotificationBanner
sessions={[
buildSession({ id: "a", status: "generating" }),
buildSession({ id: "a", status: "complete" }),
buildSession({ id: "b", status: "complete" }),
]}
onResumeSession={vi.fn()}
@@ -56,23 +56,21 @@ describe("SessionNotificationBanner", () => {
expect(screen.queryByText("Done")).not.toBeInTheDocument();
});
it("does not render sessions that are generating or complete", () => {
render(
<SessionNotificationBanner
sessions={[
buildSession({ id: "planning", type: "planning", title: "Planning Session", status: "awaiting_input" }),
buildSession({ id: "gen", title: "Generating", status: "generating" }),
buildSession({ id: "complete", title: "Complete", status: "complete" }),
]}
onResumeSession={vi.fn()}
onDismissSession={vi.fn()}
onDismissAll={vi.fn()}
/>,
);
it("renders an in-progress entry and resumes non-planning work", () => {
const onResumeSession = vi.fn();
const generating = buildSession({ id: "gen", type: "subtask", title: "Generating", status: "generating" });
render(<SessionNotificationBanner sessions={[generating]} onResumeSession={onResumeSession} onDismissSession={vi.fn()} onDismissAll={vi.fn()} />);
expect(screen.getByText("1 AI session in progress")).toBeInTheDocument();
expect(screen.getByText("Generating")).toBeInTheDocument();
expect(screen.getByText("In progress")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Resume" }));
expect(onResumeSession).toHaveBeenCalledWith(generating);
});
expect(screen.getByText("Planning Session")).toBeInTheDocument();
expect(screen.queryByText("Generating")).not.toBeInTheDocument();
expect(screen.queryByText("Complete")).not.toBeInTheDocument();
it("renders cli progress observationally without a dead Resume button", () => {
render(<SessionNotificationBanner sessions={[buildSession({ id: "cli-progress", type: "cli-agent", title: "CLI progress", status: "generating" })]} onResumeSession={vi.fn()} onDismissSession={vi.fn()} onDismissAll={vi.fn()} />);
expect(screen.getByText("CLI progress")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Resume" })).not.toBeInTheDocument();
});
it("calls onResumeSession with the selected session", () => {
@@ -283,46 +281,23 @@ describe("SessionNotificationBanner", () => {
expect(onDismissSession).toHaveBeenCalledWith("error-dismiss");
});
/*
FNXC:SessionBanner 2026-07-05-00:00:
Symptom Verification (FN-7614): the production banner feed (App.tsx `sessionsNeedingInput`) filters via
`isSessionNeedingInputForBanner(s) && !isPlanningAwaitingInput(s)` before it ever reaches this component. Given a
lone planning awaiting_input session, that filter yields an empty array, so the banner must render NO entry
(and no banner region at all) — reproducing the original broken-Resume-button symptom being fixed by the nav badge.
*/
it("renders no banner entry (and no banner region) for a lone planning awaiting_input session, using the production banner filter", () => {
const planningAwaitingInput = buildSession({ id: "planning-solo", type: "planning", status: "awaiting_input", title: "Solo planning session" });
const filtered = [planningAwaitingInput].filter((s) => isSessionNeedingInputForBanner(s) && !isPlanningAwaitingInput(s));
const { container } = render(
<SessionNotificationBanner
sessions={filtered}
onResumeSession={vi.fn()}
onDismissSession={vi.fn()}
onDismissAll={vi.fn()}
/>,
it("keeps planning sessions out through the production banner predicate", () => {
const planningSessions = ["generating", "awaiting_input", "error"].map((status) =>
buildSession({ id: `planning-${status}`, type: "planning", status: status as AiSessionSummary["status"], title: `Planning ${status}` }),
);
const filtered = planningSessions.filter(shouldShowSessionInBanner);
const { container } = render(<SessionNotificationBanner sessions={filtered} onResumeSession={vi.fn()} onDismissSession={vi.fn()} onDismissAll={vi.fn()} />);
expect(filtered).toEqual([]);
expect(screen.queryByText("Solo planning session")).not.toBeInTheDocument();
expect(container.firstChild).toBeNull();
});
it("keeps a mixed non-planning awaiting-input session visible while excluding planning-awaiting-input via the production filter", () => {
const planningAwaitingInput = buildSession({ id: "planning-solo2", type: "planning", status: "awaiting_input", title: "Excluded planning session" });
const cliAwaitingInput = buildSession({ id: "cli-mixed", type: "cli-agent", status: "waiting_on_input", title: "Visible CLI session" });
const filtered = [planningAwaitingInput, cliAwaitingInput].filter((s) => isSessionNeedingInputForBanner(s) && !isPlanningAwaitingInput(s));
render(
<SessionNotificationBanner
sessions={filtered}
onResumeSession={vi.fn()}
onDismissSession={vi.fn()}
onDismissAll={vi.fn()}
/>,
);
expect(screen.queryByText("Excluded planning session")).not.toBeInTheDocument();
it("keeps a mixed non-planning entry visible through the production filter", () => {
const sessions = [
buildSession({ id: "planning", type: "planning", status: "error", title: "Excluded planning" }),
buildSession({ id: "cli", type: "cli-agent", status: "waiting_on_input", title: "Visible CLI session" }),
].filter(shouldShowSessionInBanner);
render(<SessionNotificationBanner sessions={sessions} onResumeSession={vi.fn()} onDismissSession={vi.fn()} onDismissAll={vi.fn()} />);
expect(screen.queryByText("Excluded planning")).not.toBeInTheDocument();
expect(screen.getByText("Visible CLI session")).toBeInTheDocument();
});
@@ -390,7 +365,7 @@ describe("SessionNotificationBanner — cli-agent (U11)", () => {
expect(screen.getByText("Implement FN-1")).toBeInTheDocument();
});
it("waiting_on_input surfaces a banner entry; busy clears it (F2)", () => {
it("waiting_on_input transitions to observable generating progress (F2)", () => {
const { rerender, container } = render(
<SessionNotificationBanner
sessions={[buildCliSession({ status: "waiting_on_input" })]}
@@ -409,7 +384,8 @@ describe("SessionNotificationBanner — cli-agent (U11)", () => {
onDismissAll={vi.fn()}
/>,
);
expect(container.querySelector(".session-notification-banner")).toBeFalsy();
expect(container.querySelector(".session-notification-banner")).toBeTruthy();
expect(screen.getByText("In progress")).toBeInTheDocument();
});
it("userExited needs-attention renders pinned copy + Advance/Retry/Cancel task", () => {

View File

@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { loadAllAppCss } from "../../test/cssFixture";
import { fireEvent, render, screen } from "@testing-library/react";
import type { Agent, AiSessionSummary } from "../../api";
import { render, screen } from "@testing-library/react";
import type { Agent } from "../../api";
import type { Toast } from "../../hooks/useToast";
vi.mock("../../hooks/useExecutorStats", () => ({
@@ -30,7 +30,6 @@ vi.mock("../../hooks/useToast", () => ({
}));
import { useExecutorStats } from "../../hooks/useExecutorStats";
import { BackgroundTasksIndicator } from "../BackgroundTasksIndicator";
import { ExecutorStatusBar } from "../ExecutorStatusBar";
import { ActiveAgentsPanel } from "../ActiveAgentsPanel";
import { ToastContainer } from "../ToastContainer";
@@ -67,191 +66,6 @@ describe("Utility component mobile adaptations", () => {
});
});
it("renders BackgroundTasksIndicator pill when sessions exist", () => {
const sessions: AiSessionSummary[] = [
{
id: "sess-1",
type: "planning",
status: "generating",
title: "Refine onboarding flow",
projectId: "proj-1",
updatedAt: new Date().toISOString(),
},
];
render(
<BackgroundTasksIndicator
sessions={sessions}
generating={1}
needsInput={0}
onOpenSession={vi.fn()}
onDismissSession={vi.fn()}
/>,
);
expect(screen.getByRole("button", { name: /AI 1/i })).toBeTruthy();
});
it("renders BackgroundTasksIndicator popover on pill click", () => {
const sessions: AiSessionSummary[] = [
{
id: "sess-2",
type: "subtask",
status: "awaiting_input",
title: "Break down API tasks",
projectId: "proj-1",
updatedAt: new Date().toISOString(),
},
];
render(
<BackgroundTasksIndicator
sessions={sessions}
generating={0}
needsInput={1}
onOpenSession={vi.fn()}
onDismissSession={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /AI 1/i }));
expect(screen.getByText("Background Tasks")).toBeTruthy();
expect(screen.getByText("Break down API tasks")).toBeTruthy();
});
/*
FNXC:PlanningMultiTab 2026-07-16-17:35:
Background task rows open directly on mobile for every session state, even when a legacy
session payload reports another tab as its prior lock holder. No confirm gate or lock banner
may be reintroduced because interviews are intentionally multi-tab.
*/
it("opens legacy other-tab-owned sessions directly without a lock affordance", () => {
const onOpenSession = vi.fn();
const sessions = [
{
id: "sess-generating-other-tab",
type: "planning",
status: "generating",
title: "Generating plan",
projectId: "proj-1",
updatedAt: new Date().toISOString(),
lockedByTab: "tab-other",
},
{
id: "sess-awaiting-other-tab",
type: "mission_interview",
status: "awaiting_input",
title: "Awaiting mission input",
projectId: "proj-1",
updatedAt: new Date().toISOString(),
lockedByTab: "tab-other",
},
{
id: "sess-failed-other-tab",
type: "slice_interview",
status: "error",
title: "Failed slice interview",
projectId: "proj-1",
updatedAt: new Date().toISOString(),
lockedByTab: "tab-other",
},
] as unknown as AiSessionSummary[];
render(
<BackgroundTasksIndicator
sessions={sessions}
generating={1}
needsInput={1}
onOpenSession={onOpenSession}
onDismissSession={vi.fn()}
/>,
);
for (const session of sessions) {
fireEvent.click(screen.getByRole("button", { name: /AI 3/i }));
fireEvent.click(screen.getByText(session.title));
}
expect(onOpenSession).toHaveBeenNthCalledWith(1, sessions[0]);
expect(onOpenSession).toHaveBeenNthCalledWith(2, sessions[1]);
expect(onOpenSession).toHaveBeenNthCalledWith(3, sessions[2]);
expect(screen.queryByRole("button", { name: /take control/i })).toBeNull();
expect(screen.queryByText(/active in another tab|live heartbeat/i)).toBeNull();
});
it("returns null for BackgroundTasksIndicator with no sessions", () => {
const { container } = render(
<BackgroundTasksIndicator
sessions={[]}
generating={0}
needsInput={0}
onOpenSession={vi.fn()}
onDismissSession={vi.fn()}
/>,
);
expect(container.firstChild).toBeNull();
});
it("calls onOpenSession when clicking on a milestone_interview session item", () => {
const onOpenSession = vi.fn();
const sessions: AiSessionSummary[] = [
{
id: "sess-milestone-1",
type: "milestone_interview",
status: "awaiting_input",
title: "Plan milestone scope",
projectId: "proj-1",
updatedAt: new Date().toISOString(),
},
];
render(
<BackgroundTasksIndicator
sessions={sessions}
generating={0}
needsInput={1}
onOpenSession={onOpenSession}
onDismissSession={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /AI 1/i }));
fireEvent.click(screen.getByText("Plan milestone scope"));
expect(onOpenSession).toHaveBeenCalledWith(sessions[0]);
});
it("calls onOpenSession when clicking on a slice_interview session item", () => {
const onOpenSession = vi.fn();
const sessions: AiSessionSummary[] = [
{
id: "sess-slice-1",
type: "slice_interview",
status: "error",
title: "Plan slice scope",
projectId: "proj-1",
updatedAt: new Date().toISOString(),
},
];
render(
<BackgroundTasksIndicator
sessions={sessions}
generating={0}
needsInput={0}
onOpenSession={onOpenSession}
onDismissSession={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /AI 1/i }));
fireEvent.click(screen.getByText("Plan slice scope"));
expect(onOpenSession).toHaveBeenCalledWith(sessions[0]);
});
it("renders ExecutorStatusBar segments", () => {
render(<ExecutorStatusBar tasks={[]} />);
@@ -311,6 +125,5 @@ describe("Utility component mobile adaptations", () => {
expectMobileRule(css, ".toast-container", "bottom: auto;");
expectMobileRule(css, ".toast-container", "right: var(--space-sm);");
expectMobileRule(css, ".toast-container", "left: var(--space-sm);");
expectMobileRule(css, ".background-tasks-indicator__popover", "position: fixed;");
});
});

View File

@@ -95,7 +95,13 @@ export function DashboardBanners({
/>
</>
)}
{viewMode === "project" && currentProject && taskView !== "missions" && !modalManager.isPlanningOpen && !sessionBannersHidden && (
{/*
* FNXC:SessionBanner 2026-07-16-21:10:
* FN-8229 removed the always-visible footer AI pill, so active non-planning
* sessions must override the appearance preference that hides idle banners.
* Per-session dismissal still lets operators suppress an individual entry.
*/}
{viewMode === "project" && currentProject && taskView !== "missions" && !modalManager.isPlanningOpen && (!sessionBannersHidden || sessionsNeedingInput.length > 0) && (
<SessionNotificationBanner
sessions={sessionsNeedingInput}
onResumeSession={handleOpenBackgroundSession}

View File

@@ -219,7 +219,7 @@ function buildProps(overrides: Partial<DashboardBannersProps> = {}): DashboardBa
}
function querySessionBanner(): HTMLElement | null {
return screen.queryByRole("region", { name: /AI sessions needing input or failed/i });
return screen.queryByRole("region", { name: /AI sessions (in progress, )?needing input,? or failed/i });
}
function unavailableEngineHealth(): DashboardBannersProps["dashboardHealth"] {
@@ -359,8 +359,11 @@ describe("DashboardBanners engine remediation visibility", () => {
describe("DashboardBanners session notification visibility", () => {
/*
FNXC:SessionBanner 2026-06-25-00:00:
FN-7020 visibility surface enumeration: DashboardBanners.tsx is the only app-shell mount point for SessionNotificationBanner (grep-confirmed; other references are tests). The guard must remain viewMode === "project", currentProject present, taskView !== "missions", !modalManager.isPlanningOpen, and !sessionBannersHidden so Missions interviews and the Planning modal own their active AI-session UX while board/list surfaces still show needs-input sessions.
FNXC:SessionBanner 2026-07-16-21:10:
FN-8229 makes DashboardBanners the only app-shell entry point after removing
the footer AI pill. Missions and the Planning modal retain their dedicated
surfaces, while active non-planning sessions override the hidden-banner
preference so background work always remains reachable on board/list views.
*/
it("does not render the session notification banner on the missions view", () => {
render(<DashboardBanners {...buildProps({ taskView: "missions" })} />);
@@ -394,8 +397,25 @@ describe("DashboardBanners session notification visibility", () => {
expect(screen.getByText("Needs attention")).toBeInTheDocument();
});
it("does not render when the appearance setting hides session banners", () => {
render(<DashboardBanners {...buildProps({ sessionBannersHidden: true })} />);
it("keeps active sessions reachable when the appearance setting hides idle banners", () => {
const handleOpenBackgroundSession = vi.fn();
render(
<DashboardBanners
{...buildProps({
sessionBannersHidden: true,
sessionsNeedingInput: [buildSession({ id: "background-subtask", type: "subtask", status: "generating", title: "Background subtask" })],
handleOpenBackgroundSession,
})}
/>,
);
expect(querySessionBanner()).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /resume/i }));
expect(handleOpenBackgroundSession).toHaveBeenCalledWith(expect.objectContaining({ id: "background-subtask" }));
});
it("does not render when banners are hidden and there are no active sessions", () => {
render(<DashboardBanners {...buildProps({ sessionBannersHidden: true, sessionsNeedingInput: [] })} />);
expect(querySessionBanner()).not.toBeInTheDocument();
});

View File

@@ -76,6 +76,7 @@ describe("useBackgroundSessions", () => {
await waitFor(() => {
expect(result.current.sessions.map((session) => session.id).sort()).toEqual([
"s-awaiting",
"s-error",
"s-generating",
"s-needs-attention",
"s-waiting",
@@ -196,7 +197,7 @@ describe("useBackgroundSessions", () => {
});
});
it("removes session when SSE delivers error status", async () => {
it("retains session when SSE delivers error status until completion or dismissal", async () => {
const { result } = renderHook(() => useBackgroundSessions());
await waitFor(() => {
@@ -235,10 +236,14 @@ describe("useBackgroundSessions", () => {
});
await waitFor(() => {
expect(result.current.sessions).toEqual([]);
expect(result.current.planningSessions).toEqual([]);
expect(result.current.sessions.map((session) => session.id)).toEqual(["terminal-error"]);
expect(result.current.sessions[0]?.status).toBe("error");
expect(result.current.planningSessions.map((session) => session.id)).toEqual(["terminal-error"]);
expect(result.current.generating).toBe(0);
});
act(() => eventSource._emit("ai_session:updated", makeSession({ id: "terminal-error", type: "planning", status: "complete", updatedAt: "2026-04-08T00:00:03.000Z" })));
await waitFor(() => expect(result.current.sessions).toEqual([]));
});
it("removes sessions when ai_session:deleted SSE event arrives", async () => {
@@ -563,10 +568,10 @@ describe("useBackgroundSessions", () => {
});
/*
* FNXC:BackgroundTasks 2026-07-11-19:08:
* FNXC:SessionSync 2026-07-11-19:08:
* A sibling tab can respond after the server has removed its planning
* session. Background Tasks must then remain empty because Planning reads
* the authoritative server list and cannot resume that stale cache entry.
* session. The retained session model must remain empty because Planning
* reads the authoritative server list and cannot resume stale cache.
*/
act(() => {
syncResult.current.broadcastUpdate({
@@ -638,11 +643,13 @@ describe("useBackgroundSessions", () => {
await waitFor(() => {
expect(result.current.generating).toBe(1);
expect(result.current.needsInput).toBe(1);
expect(result.current.planningSessions.map((session) => session.id)).toEqual([
expect(result.current.planningSessions.map((session) => session.id).sort()).toEqual([
"count-error-plan",
"count-generating",
]);
expect(result.current.sessions.map((session) => session.id).sort()).toEqual([
"count-awaiting",
"count-error-plan",
"count-generating",
]);
});

View File

@@ -29,21 +29,24 @@ function parseTimestamp(updatedAt: string | undefined): number {
function shouldIncludeSession(session: AiSessionSummary): boolean {
/*
* FNXC:SessionBanner 2026-06-14-19:32:
* Background session consumers need CLI agent `waiting_on_input` and `needs_attention` rows available so App can route them to SessionNotificationBanner. Counts below remain scoped to their legacy meanings, so BackgroundTasksIndicator panels are not reclassified by this inclusion.
* FNXC:SessionBanner 2026-07-16-20:55:
* FN-8229 removes the redundant footer AI pill, so this retained session model
* must feed the notification banner and Planning badge for every non-terminal
* status, including errors. Legacy counts remain scoped to their meanings.
*/
return (
session.status === "generating" ||
session.status === "awaiting_input" ||
session.status === "waiting_on_input" ||
session.status === "needs_attention"
session.status === "needs_attention" ||
session.status === "error"
);
}
function isTerminalStatus(
status: AiSessionSummary["status"],
): status is Extract<AiSessionSummary["status"], "complete" | "error"> {
return status === "complete" || status === "error";
): status is Extract<AiSessionSummary["status"], "complete"> {
return status === "complete";
}
export function useBackgroundSessions(projectId?: string): UseBackgroundSessionsResult {
@@ -85,7 +88,7 @@ export function useBackgroundSessions(projectId?: string): UseBackgroundSessions
}
/*
* FNXC:BackgroundTasks 2026-07-11-19:08:
* FNXC:SessionSync 2026-07-11-19:08:
* Planning must never advertise a cached cross-tab session that the
* server's current project-scoped list does not contain. A late sync
* response is only a latency aid, so suppress updates older than this
@@ -256,7 +259,7 @@ export function useBackgroundSessions(projectId?: string): UseBackgroundSessions
timestamp: eventTimestamp,
});
if (isTerminalStatus(updated.status)) {
if (updated.status === "complete") {
broadcastCompleted({
sessionId: updated.id,
status: updated.status,
@@ -303,8 +306,8 @@ export function useBackgroundSessions(projectId?: string): UseBackgroundSessions
},
// When the SSE channel reconnects after a drop, pull the authoritative
// list. Without this, terminal events (deleted/completed) emitted while
// the channel was down stay invisible to this hook and the AI pill
// count gets stuck on stale sessions.
// the channel was down stay invisible to this hook and the retained
// session model gets stuck on stale entries.
onReconnect: () => {
recordResumeEvent({
view: "useBackgroundSessions",

View File

@@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest";
import type { AiSessionSummary } from "../../api";
import {
buildRemoteDashboardUrl,
isPlanningAwaitingInput,
shouldShowSessionInBanner,
isSessionNeedingInputForBanner,
resolveDesktopShellRedirectTarget,
} from "../appLifecycle";
@@ -20,55 +20,26 @@ function makeSession(overrides: Partial<AiSessionSummary> & Pick<AiSessionSummar
}
/*
FNXC:SessionBanner 2026-07-05-00:00:
Symptom Verification (FN-7614): planning `awaiting_input` sessions must be excluded from the banner feed
(`isSessionNeedingInputForBanner(s) && !isPlanningAwaitingInput(s)`), while planning `error` and non-planning
awaiting-input sessions must remain — this is the invariant the App.tsx `sessionsNeedingInput` filter relies on.
FNXC:SessionBanner 2026-07-16-20:55:
FN-8229 replaces the footer AI pill with the banner for non-planning progress
and actionable states. Planning remains visible only through its docked view
and navigation badge, including retained error sessions.
*/
describe("isPlanningAwaitingInput", () => {
it("is true only for planning sessions awaiting input", () => {
expect(isPlanningAwaitingInput(makeSession({ id: "p1", type: "planning", status: "awaiting_input" }))).toBe(true);
describe("shouldShowSessionInBanner", () => {
it("includes non-planning generating, needs-input, and error sessions", () => {
for (const status of ["generating", "awaiting_input", "error"] as const) {
expect(shouldShowSessionInBanner(makeSession({ id: status, type: "subtask", status }))).toBe(true);
}
});
it("is false for planning sessions in other statuses", () => {
expect(isPlanningAwaitingInput(makeSession({ id: "p2", type: "planning", status: "generating" }))).toBe(false);
expect(isPlanningAwaitingInput(makeSession({ id: "p3", type: "planning", status: "error" }))).toBe(false);
it("excludes planning sessions at every status", () => {
for (const status of ["generating", "awaiting_input", "error"] as const) {
expect(shouldShowSessionInBanner(makeSession({ id: `planning-${status}`, type: "planning", status }))).toBe(false);
}
});
it("is false for non-planning sessions even when awaiting input", () => {
expect(isPlanningAwaitingInput(makeSession({ id: "c1", type: "cli-agent", status: "awaiting_input" }))).toBe(false);
});
});
describe("sessionsNeedingInput banner filter (isSessionNeedingInputForBanner + !isPlanningAwaitingInput)", () => {
function bannerFilter(sessions: AiSessionSummary[]): AiSessionSummary[] {
return sessions.filter((s) => isSessionNeedingInputForBanner(s) && !isPlanningAwaitingInput(s));
}
it("excludes a lone planning awaiting_input session from the banner feed", () => {
const sessions = [makeSession({ id: "p1", type: "planning", status: "awaiting_input" })];
expect(bannerFilter(sessions)).toEqual([]);
});
it("keeps planning error sessions in the banner feed", () => {
const errorSession = makeSession({ id: "p2", type: "planning", status: "error" });
expect(bannerFilter([errorSession])).toEqual([errorSession]);
});
it("keeps non-planning awaiting-input sessions in the banner feed", () => {
const cliSession = makeSession({ id: "c1", type: "cli-agent", status: "awaiting_input" });
expect(bannerFilter([cliSession])).toEqual([cliSession]);
});
it("excludes planning-awaiting-input while keeping a mixed non-planning awaiting-input session", () => {
const planningAwaiting = makeSession({ id: "p3", type: "planning", status: "awaiting_input" });
const cliAwaiting = makeSession({ id: "c2", type: "cli-agent", status: "awaiting_input" });
expect(bannerFilter([planningAwaiting, cliAwaiting])).toEqual([cliAwaiting]);
});
it("excludes generating planning sessions (never in the banner or badge input)", () => {
const generating = makeSession({ id: "p4", type: "planning", status: "generating" });
expect(bannerFilter([generating])).toEqual([]);
it("keeps the needs-input predicate separate from generating progress", () => {
expect(isSessionNeedingInputForBanner(makeSession({ id: "generating", type: "subtask", status: "generating" }))).toBe(false);
});
});

View File

@@ -177,15 +177,15 @@ export function isSessionNeedingInputForBanner(session: AiSessionSummary): boole
}
/*
FNXC:SessionBanner 2026-07-05-00:00:
Planning-Mode "awaiting input" is no longer a SessionNotificationBanner entry: the banner's Resume button did not
reliably redirect into the Planning Mode interface, so awaiting-input planning sessions are now surfaced as a yellow
`status-dot--pending` badge on the Planning nav destination instead (LeftSidebarNav + MobileNavBar), whose click
target is the already-correct `planning` view navigation. Planning sessions in `error` status are unaffected and
still render in the banner via `isSessionNeedingInputForBanner` above.
FNXC:SessionBanner 2026-07-16-20:55:
FN-8229 removes the redundant footer AI pill. Planning has its own docked view
and navigation badge, so it stays out of the banner at every status, including
retained errors. The banner is the replacement progress surface for all other
in-progress and actionable sessions.
*/
export function isPlanningAwaitingInput(session: AiSessionSummary): boolean {
return session.type === "planning" && session.status === "awaiting_input";
export function shouldShowSessionInBanner(session: AiSessionSummary): boolean {
if (session.type === "planning") return false;
return isSessionNeedingInputForBanner(session) || session.status === "generating";
}
export function getCliActionDisabledReasonForBanner(session: AiSessionSummary, action: CliActionId): string | null {

View File

@@ -4152,7 +4152,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
res.status(204).send();
});
// ── AI Session Routes (Background Tasks) ─────────────────────────────────
// ── AI Session Routes ─────────────────────────────────────────────────────
/**
* GET /api/ai-sessions

View File

@@ -1176,29 +1176,6 @@
"retryConnection": "Retry Connection",
"retrying": "Retrying…"
},
"backgroundTasks": {
"dismissButton": "Dismiss",
"pillLabel_one": "AI {{count}}",
"pillLabel_other": "AI {{count}}",
"pillTitle_one": "{{count}} background AI task",
"pillTitle_other": "{{count}} background AI task",
"pillTitleWithInput_one": "{{count}} background AI task ({{needsInput}} needs input)",
"pillTitleWithInput_other": "{{count}} background AI task ({{needsInput}} needs input)",
"popoverHeader": "Background Tasks",
"status": {
"failed": "Failed",
"generating": "generating...",
"needsInput": "needs input"
},
"typeLabel": {
"cliAgent": "CLI Agent",
"milestoneInterview": "Milestone Interview",
"missionInterview": "Mission Interview",
"planning": "Planning",
"sliceInterview": "Slice Interview",
"subtask": "Subtask Breakdown"
}
},
"board": {
"archived": "Archived",
"done": "Done",
@@ -5665,9 +5642,13 @@
"headerErrorPlural_other": "",
"headerErrorSingular_one": "",
"headerErrorSingular_other": "",
"regionLabel": "AI sessions needing input or failed",
"regionLabel": "AI sessions in progress, needing input, or failed",
"resume": "Resume",
"retry": "Retry"
"retry": "Retry",
"headerGeneratingSingular": "{{count}} AI session in progress",
"headerGeneratingPlural": "{{count}} AI sessions in progress",
"headerMixed": "{{generatingCount}} AI sessions in progress, {{actionableCount}} need attention",
"inProgress": "In progress"
},
"settings": {
"actions": {
@@ -7311,7 +7292,7 @@
"generatingSubtasks": "AI is generating subtasks...",
"groupedOnSharedBranch": "Grouped on shared branch",
"hideThinking": "Hide thinking",
"keepSessionMessage": "Close this modal and keep the subtask session available from Background Tasks? Edited fields that have not been saved to the session may be reset when you resume.",
"keepSessionMessage": "Close this modal and keep the subtask session available from the session notification banner? Edited fields that have not been saved to the session may be reset when you resume.",
"keepSessionTitle": "Keep subtask session available?",
"modalTitle": "Subtask Breakdown",
"moveDown": "Move down",

View File

@@ -1166,29 +1166,6 @@
"retryConnection": "Reintentar conexión",
"retrying": "Reintentando…"
},
"backgroundTasks": {
"dismissButton": "Descartar",
"pillLabel_one": "",
"pillLabel_other": "",
"pillTitle_one": "",
"pillTitle_other": "",
"pillTitleWithInput_one": "",
"pillTitleWithInput_other": "",
"popoverHeader": "Tareas de fondo",
"status": {
"failed": "Fallido",
"generating": "generando...",
"needsInput": "necesita entrada"
},
"typeLabel": {
"cliAgent": "",
"milestoneInterview": "Entrevista de hito",
"missionInterview": "Entrevista de misión",
"planning": "Planificación",
"sliceInterview": "Entrevista de segmento",
"subtask": "Desglose de subtareas"
}
},
"board": {
"archived": "Archivado",
"done": "Hecho",
@@ -3322,7 +3299,8 @@
"unpauseUnavailable": "La acción de reanudar no está disponible",
"useProjectDefault": "Usar predeterminado del proyecto",
"viewOptions": "Opciones de vista",
"workflowLabel": ""
"workflowLabel": "",
"statusReplan": ""
},
"mailbox": {
"agent": "Agente",
@@ -5656,7 +5634,11 @@
"headerErrorSingular_other": "",
"regionLabel": "Sesiones de IA que necesitan entrada o han fallado",
"resume": "Reanudar",
"retry": "Reintentar"
"retry": "Reintentar",
"headerGeneratingSingular": "",
"headerGeneratingPlural": "",
"headerMixed": "",
"inProgress": ""
},
"settings": {
"actions": {
@@ -5906,7 +5888,14 @@
"restartFailed": "",
"restartNow": "",
"restartUnavailable": "",
"restarting": ""
"restarting": "",
"mobileNavPrimaryItems": "",
"mobileNavPrimaryItemsHint": "",
"addNavItem": "",
"selectNavItem": "",
"removeNavItem": "",
"moveNavItemEarlier": "",
"moveNavItemLater": ""
},
"globalGeneral": {
"andShowsUpdateNoticesInTheCLIAnd": "",
@@ -8381,7 +8370,8 @@
"costBadgeAriaLabel": "",
"awaitingApprovalPlanReviewReplanCapTitle": "",
"reviewBudgetExhausted": "",
"awaitingApprovalTitle": ""
"awaitingApprovalTitle": "",
"statusReplan": ""
},
"terminal": {
"arrowKeysLabel": "",

View File

@@ -1166,29 +1166,6 @@
"retryConnection": "Réessayer la connexion",
"retrying": "Nouvelle tentative…"
},
"backgroundTasks": {
"dismissButton": "Rejeter",
"pillLabel_one": "",
"pillLabel_other": "",
"pillTitle_one": "",
"pillTitle_other": "",
"pillTitleWithInput_one": "",
"pillTitleWithInput_other": "",
"popoverHeader": "Tâches de fond",
"status": {
"failed": "Échec",
"generating": "génération...",
"needsInput": "nécessite une entrée"
},
"typeLabel": {
"cliAgent": "",
"milestoneInterview": "Entretien Jalon",
"missionInterview": "Entretien de mission",
"planning": "Planification",
"sliceInterview": "Entrevue de tranche",
"subtask": "Décomposition des sous-tâches"
}
},
"board": {
"archived": "Archivé",
"done": "Terminé",
@@ -3322,7 +3299,8 @@
"unpauseUnavailable": "L'action Reprendre est indisponible",
"useProjectDefault": "Utiliser le défaut du projet",
"viewOptions": "Options d'affichage",
"workflowLabel": ""
"workflowLabel": "",
"statusReplan": ""
},
"mailbox": {
"agent": "Agent",
@@ -5656,7 +5634,11 @@
"headerErrorSingular_other": "",
"regionLabel": "Sessions IA nécessitant une entrée ou ayant échoué",
"resume": "Reprendre",
"retry": "Réessayer"
"retry": "Réessayer",
"headerGeneratingSingular": "",
"headerGeneratingPlural": "",
"headerMixed": "",
"inProgress": ""
},
"settings": {
"actions": {
@@ -5906,7 +5888,14 @@
"restartFailed": "",
"restartNow": "",
"restartUnavailable": "",
"restarting": ""
"restarting": "",
"mobileNavPrimaryItems": "",
"mobileNavPrimaryItemsHint": "",
"addNavItem": "",
"selectNavItem": "",
"removeNavItem": "",
"moveNavItemEarlier": "",
"moveNavItemLater": ""
},
"globalGeneral": {
"andShowsUpdateNoticesInTheCLIAnd": "",
@@ -8381,7 +8370,8 @@
"costBadgeAriaLabel": "",
"awaitingApprovalPlanReviewReplanCapTitle": "",
"reviewBudgetExhausted": "",
"awaitingApprovalTitle": ""
"awaitingApprovalTitle": "",
"statusReplan": ""
},
"terminal": {
"arrowKeysLabel": "",

View File

@@ -1166,29 +1166,6 @@
"retryConnection": "연결 재시도",
"retrying": "재시도 중…"
},
"backgroundTasks": {
"dismissButton": "닫기",
"pillLabel_one": "",
"pillLabel_other": "",
"pillTitle_one": "",
"pillTitle_other": "",
"pillTitleWithInput_one": "",
"pillTitleWithInput_other": "",
"popoverHeader": "백그라운드 작업",
"status": {
"failed": "실패",
"generating": "생성 중...",
"needsInput": "입력 필요"
},
"typeLabel": {
"cliAgent": "",
"milestoneInterview": "마일스톤 인터뷰",
"missionInterview": "미션 인터뷰",
"planning": "계획",
"sliceInterview": "슬라이스 인터뷰",
"subtask": "하위 작업 분류"
}
},
"board": {
"archived": "보관됨",
"done": "완료",
@@ -3322,7 +3299,8 @@
"unpauseUnavailable": "재개 작업을 사용할 수 없습니다",
"useProjectDefault": "프로젝트 기본값 사용",
"viewOptions": "보기 옵션",
"workflowLabel": ""
"workflowLabel": "",
"statusReplan": ""
},
"mailbox": {
"agent": "에이전트",
@@ -5656,7 +5634,11 @@
"headerErrorSingular_other": "",
"regionLabel": "입력 대기 중이거나 실패한 AI 세션",
"resume": "재개",
"retry": "재시도"
"retry": "재시도",
"headerGeneratingSingular": "",
"headerGeneratingPlural": "",
"headerMixed": "",
"inProgress": ""
},
"settings": {
"actions": {
@@ -5906,7 +5888,14 @@
"restartFailed": "",
"restartNow": "",
"restartUnavailable": "",
"restarting": ""
"restarting": "",
"mobileNavPrimaryItems": "",
"mobileNavPrimaryItemsHint": "",
"addNavItem": "",
"selectNavItem": "",
"removeNavItem": "",
"moveNavItemEarlier": "",
"moveNavItemLater": ""
},
"globalGeneral": {
"andShowsUpdateNoticesInTheCLIAnd": "",
@@ -8381,7 +8370,8 @@
"costBadgeAriaLabel": "",
"awaitingApprovalPlanReviewReplanCapTitle": "",
"reviewBudgetExhausted": "",
"awaitingApprovalTitle": ""
"awaitingApprovalTitle": "",
"statusReplan": ""
},
"terminal": {
"arrowKeysLabel": "",

View File

@@ -1166,29 +1166,6 @@
"retryConnection": "重新连接",
"retrying": "重试中…"
},
"backgroundTasks": {
"dismissButton": "关闭",
"pillLabel_one": "",
"pillLabel_other": "",
"pillTitle_one": "",
"pillTitle_other": "",
"pillTitleWithInput_one": "",
"pillTitleWithInput_other": "",
"popoverHeader": "后台任务",
"status": {
"failed": "失败",
"generating": "生成中...",
"needsInput": "需要输入"
},
"typeLabel": {
"cliAgent": "",
"milestoneInterview": "里程碑采访",
"missionInterview": "任务采访",
"planning": "规划",
"sliceInterview": "切片采访",
"subtask": "子任务分解"
}
},
"board": {
"archived": "已归档",
"done": "完成",
@@ -3322,7 +3299,8 @@
"unpauseUnavailable": "恢复操作不可用",
"useProjectDefault": "使用项目默认",
"viewOptions": "视图选项",
"workflowLabel": ""
"workflowLabel": "",
"statusReplan": ""
},
"mailbox": {
"agent": "代理",
@@ -5656,7 +5634,11 @@
"headerErrorSingular_other": "",
"regionLabel": "需要输入或已失败的 AI 会话",
"resume": "继续",
"retry": "重试"
"retry": "重试",
"headerGeneratingSingular": "",
"headerGeneratingPlural": "",
"headerMixed": "",
"inProgress": ""
},
"settings": {
"actions": {
@@ -5906,7 +5888,14 @@
"restartFailed": "",
"restartNow": "",
"restartUnavailable": "",
"restarting": ""
"restarting": "",
"mobileNavPrimaryItems": "",
"mobileNavPrimaryItemsHint": "",
"addNavItem": "",
"selectNavItem": "",
"removeNavItem": "",
"moveNavItemEarlier": "",
"moveNavItemLater": ""
},
"globalGeneral": {
"andShowsUpdateNoticesInTheCLIAnd": "",
@@ -8381,7 +8370,8 @@
"costBadgeAriaLabel": "",
"awaitingApprovalPlanReviewReplanCapTitle": "",
"reviewBudgetExhausted": "",
"awaitingApprovalTitle": ""
"awaitingApprovalTitle": "",
"statusReplan": ""
},
"terminal": {
"arrowKeysLabel": "",

View File

@@ -1166,29 +1166,6 @@
"retryConnection": "重新連接",
"retrying": "重試中…"
},
"backgroundTasks": {
"dismissButton": "關閉",
"pillLabel_one": "",
"pillLabel_other": "",
"pillTitle_one": "",
"pillTitle_other": "",
"pillTitleWithInput_one": "",
"pillTitleWithInput_other": "",
"popoverHeader": "背景任務",
"status": {
"failed": "失敗",
"generating": "生成中...",
"needsInput": "需要輸入"
},
"typeLabel": {
"cliAgent": "",
"milestoneInterview": "里程碑訪談",
"missionInterview": "任務訪談",
"planning": "規劃",
"sliceInterview": "切片訪談",
"subtask": "子任務分解"
}
},
"board": {
"archived": "已歸檔",
"done": "完成",
@@ -3322,7 +3299,8 @@
"unpauseUnavailable": "恢復操作無法使用",
"useProjectDefault": "使用專案預設",
"viewOptions": "檢視選項",
"workflowLabel": ""
"workflowLabel": "",
"statusReplan": ""
},
"mailbox": {
"agent": "代理",
@@ -5656,7 +5634,11 @@
"headerErrorSingular_other": "",
"regionLabel": "需要輸入或已失敗的 AI 工作階段",
"resume": "繼續",
"retry": "重試"
"retry": "重試",
"headerGeneratingSingular": "",
"headerGeneratingPlural": "",
"headerMixed": "",
"inProgress": ""
},
"settings": {
"actions": {
@@ -5906,7 +5888,14 @@
"restartFailed": "",
"restartNow": "",
"restartUnavailable": "",
"restarting": ""
"restarting": "",
"mobileNavPrimaryItems": "",
"mobileNavPrimaryItemsHint": "",
"addNavItem": "",
"selectNavItem": "",
"removeNavItem": "",
"moveNavItemEarlier": "",
"moveNavItemLater": ""
},
"globalGeneral": {
"andShowsUpdateNoticesInTheCLIAnd": "",
@@ -8381,7 +8370,8 @@
"costBadgeAriaLabel": "",
"awaitingApprovalPlanReviewReplanCapTitle": "",
"reviewBudgetExhausted": "",
"awaitingApprovalTitle": ""
"awaitingApprovalTitle": "",
"statusReplan": ""
},
"terminal": {
"arrowKeysLabel": "",

View File

@@ -1168,29 +1168,6 @@ export default interface Resources {
"retryConnection": "Retry Connection",
"retrying": "Retrying…"
},
"backgroundTasks": {
"dismissButton": "Dismiss",
"pillLabel_one": "AI {{count}}",
"pillLabel_other": "AI {{count}}",
"pillTitleWithInput_one": "{{count}} background AI task ({{needsInput}} needs input)",
"pillTitleWithInput_other": "{{count}} background AI task ({{needsInput}} needs input)",
"pillTitle_one": "{{count}} background AI task",
"pillTitle_other": "{{count}} background AI task",
"popoverHeader": "Background Tasks",
"status": {
"failed": "Failed",
"generating": "generating...",
"needsInput": "needs input"
},
"typeLabel": {
"cliAgent": "CLI Agent",
"milestoneInterview": "Milestone Interview",
"missionInterview": "Mission Interview",
"planning": "Planning",
"sliceInterview": "Slice Interview",
"subtask": "Subtask Breakdown"
}
},
"board": {
"archived": "Archived",
"done": "Done",
@@ -2479,12 +2456,9 @@ export default interface Resources {
"taskMatches": "Task matches"
},
"git": {
"add": "Add",
"addComment": "Add comment",
"addCommentPlaceholder": "Write a comment…",
"commentAuthorYou": "You",
"commentPosted": "Comment posted",
"failedToPostComment": "Failed to post comment",
"add": "Add",
"addRemote": "Add Remote",
"advancesHelpFix": "<strong>Fix:</strong> Fusion only shows <em>Sync working tree</em> when at least one advance is genuinely <code>pending</code> and HEAD is not aligned with the integration tip. If entries are already handled (subsumed/orphaned/reachable/superseded), no sync action is offered.",
"advancesHelpIntro": "Each entry is a Fusion task whose squash commit advanced the integration branch ref (<branchCode>{{integrationBranch}}</branchCode>). The <em>auto-sync outcome</em> says whether your working tree was also fast-forwarded to that new tip.",
@@ -2522,7 +2496,9 @@ export default interface Resources {
"clickToViewDiff": "Click to view diff",
"close": "Close",
"closeModalAriaLabel": "Close import modal",
"commentAuthorYou": "You",
"commentLast": "Last:",
"commentPosted": "Comment posted",
"commit": "Commit",
"commitMessagePlaceholder": "Commit message...",
"commitStagedChanges": "Commit staged changes",
@@ -2597,6 +2573,7 @@ export default interface Resources {
"failedToLoadRemoteCommits": "Failed to load remote commits",
"failedToLoadRemotes": "Failed to load remotes",
"failedToLoadStashDiff": "Failed to load stash diff",
"failedToPostComment": "Failed to post comment",
"failedToRemoveRemote": "Failed to remove remote",
"failedToRenameRemote": "Failed to rename remote",
"failedToStageFiles": "Failed to stage files",
@@ -5667,7 +5644,11 @@ export default interface Resources {
"headerErrorPlural_other": "",
"headerErrorSingular_one": "",
"headerErrorSingular_other": "",
"regionLabel": "AI sessions needing input or failed",
"headerGeneratingPlural": "{{count}} AI sessions in progress",
"headerGeneratingSingular": "{{count}} AI session in progress",
"headerMixed": "{{generatingCount}} AI sessions in progress, {{actionableCount}} need attention",
"inProgress": "In progress",
"regionLabel": "AI sessions in progress, needing input, or failed",
"resume": "Resume",
"retry": "Retry"
},
@@ -5842,6 +5823,7 @@ export default interface Resources {
"60Days": "60 days",
"7Days": "7 days",
"90Days": "90 days",
"addNavItem": "Add quick action",
"agentsToRunEachTaskWhenDisabledOnly": " agents to run each task. When disabled, only permanent agents execute tasks and the scheduler auto-assigns work using the agent reporting chain. Tasks with no eligible permanent agent stay queued. ",
"aiUndoTaskWorkflow": "AI-undo task workflow",
"aiUndoTaskWorkflowHelp": "Workflow assigned to AI-undo (revert) tasks, which reverse already-shipped code and warrant stricter review. Choose \"Inherit project default workflow\" to leave them on the project default. Default: review-heavy.",
@@ -5875,6 +5857,10 @@ export default interface Resources {
"hardCapOnTheSynthesizedEarlierRoomContext": "Hard cap on the synthesized \"Earlier room context\" summary block. Default: 3000.",
"learnMore": "Learn more",
"loweringThisWindowMeansReliabilityMetricsChartsAnd": " Lowering this window means Reliability metrics/charts and the Activity feed will not show history older than the selected range. Per-task task detail history is unaffected. Default: 30 days. ",
"mobileNavPrimaryItems": "Mobile footer quick actions",
"mobileNavPrimaryItemsHint": "Default: Dashboard, Tasks, Agents, Missions, Chat, Mailbox. Add eligible destinations; unselected destinations remain in More.",
"moveNavItemEarlier": "Move {{item}} earlier",
"moveNavItemLater": "Move {{item}} later",
"newTasksInheritThisCustomWorkflowsStepsOverridable": "New tasks inherit this custom workflow's steps (overridable per task). No default — unset (built-in default workflow).",
"numberOfMostRecentChatRoomMessagesKept": "Number of most-recent chat-room messages kept verbatim in the responder transcript. Older messages are compacted into a summary block. Default: 25.",
"off": "Off",
@@ -5888,6 +5874,7 @@ export default interface Resources {
"quickChatCloseOnOutsideClickHint": "When enabled, clicking outside the Quick Chat window closes it. Disable to keep it open until you close it explicitly. Default: enabled.",
"quickChatLauncherHint": "Choose whether Quick Chat opens from the draggable floating button, a footer button beside Terminal, or stays hidden. Default: off (hidden).",
"recentVerbatimRoomMessages": "Recent verbatim room messages",
"removeNavItem": "Remove {{item}}",
"requireChangelogUpdateExistingChangelog": "Require changelog update (existing changelog)",
"requireChangesetChangesetMd": "Require changeset (.changeset/*.md)",
"restartFailed": "Restart could not be scheduled. Try restarting Fusion manually.",
@@ -5897,6 +5884,7 @@ export default interface Resources {
"roomCompactionFetchLimit": "Room compaction fetch limit",
"roomSummaryMaxCharacters": "Room summary max characters",
"searchTheTrackingRepoForLikelyDuplicatesBefore": " Search the tracking repo for likely duplicates before opening a new issue ",
"selectNavItem": "Choose a destination",
"settingsSaved": "Settings saved",
"showCapacityRiskBanner": " Show capacity risk banner ",
"showQuickChatButton": " Show quick chat button ",
@@ -7306,7 +7294,7 @@ export default interface Resources {
"generatingSubtasks": "AI is generating subtasks...",
"groupedOnSharedBranch": "Grouped on shared branch",
"hideThinking": "Hide thinking",
"keepSessionMessage": "Close this modal and keep the subtask session available from Background Tasks? Edited fields that have not been saved to the session may be reset when you resume.",
"keepSessionMessage": "Close this modal and keep the subtask session available from the session notification banner? Edited fields that have not been saved to the session may be reset when you resume.",
"keepSessionTitle": "Keep subtask session available?",
"modalTitle": "Subtask Breakdown",
"moveDown": "Move down",
@@ -7758,9 +7746,9 @@ export default interface Resources {
"copy": "This task appears to be a near-duplicate of",
"deleteBtn": "Delete",
"deleteConfirm": "Delete",
"deleted": "Deleted {{id}}",
"deleteMessage": "Delete {{id}} as a duplicate of {{duplicateOf}}?",
"deleteTitle": "Delete duplicate task",
"deleted": "Deleted {{id}}",
"headline": "Potential duplicate detected",
"keepBtn": "Keep",
"kept": "Kept {{id}} and dismissed duplicate warning",