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:
7
.changeset/fn-8229-remove-ai-background-tasks-pill.md
Normal file
7
.changeset/fn-8229-remove-ai-background-tasks-pill.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
48
packages/i18n/src/resources.d.ts
vendored
48
packages/i18n/src/resources.d.ts
vendored
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user