FN-6458: wire CLI session banner actions
Wire the dashboard session banner to execute supported CLI session actions and disable unsupported ones. - Route advance, retry, cancel, and reauthenticate banner actions through existing dashboard API/task/settings flows. - Include CLI waiting and needs-attention sessions in background session filtering for banner visibility. - Disable unavailable CLI banner actions with accessible labels and disabled styling. - Cover action wiring, disabled states, and CLI session inclusion with dashboard tests. Files changed: .changeset/fn-6458-cli-banner-actions.md | 5 ++ packages/dashboard/app/App.tsx | 95 ++++++++++++++++++++- .../app/__tests__/app-cli-action-wiring.test.tsx | 98 ++++++++++++++++++++++ .../app/components/SessionNotificationBanner.css | 19 ++++- .../app/components/SessionNotificationBanner.tsx | 60 ++++++++----- .../__tests__/SessionNotificationBanner.test.tsx | 81 +++++++++++++++++- .../hooks/__tests__/useBackgroundSessions.test.ts | 5 ++ .../dashboard/app/hooks/useBackgroundSessions.ts | 15 +++- 8 files changed, 347 insertions(+), 31 deletions(-) Fusion-Task-Id: FN-6458 Fusion-Task-Lineage: 5f6c57a3-07e2-4353-89e3-c7de20984813
This commit is contained in:
5
.changeset/fn-6458-cli-banner-actions.md
Normal file
5
.changeset/fn-6458-cli-banner-actions.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Wire dashboard CLI session banner actions so needs-attention sessions surface, supported actions call existing routes/settings flows, and unsupported actions render disabled instead of silently doing nothing.
|
||||
@@ -22,7 +22,7 @@ import { BackendConnectionErrorPage } from "./components/BackendConnectionErrorP
|
||||
import { DashboardLoader, type DashboardLoaderStage } from "./components/DashboardLoader";
|
||||
import { TopProgressBar } from "./components/TopProgressBar";
|
||||
import { ExecutorStatusBar } from "./components/ExecutorStatusBar";
|
||||
import { SessionNotificationBanner } from "./components/SessionNotificationBanner";
|
||||
import { SessionNotificationBanner, type CliActionId } from "./components/SessionNotificationBanner";
|
||||
import { CliBinaryInstallBanner } from "./components/CliBinaryInstallBanner";
|
||||
import { SetupWarningBanner } from "./components/SetupWarningBanner";
|
||||
import { CapacityRiskBanner } from "./components/CapacityRiskBanner";
|
||||
@@ -246,6 +246,77 @@ export function shouldShowFirstEverBootLoader(projectsLoading: boolean, projectC
|
||||
return projectsLoading && projectCount === 0;
|
||||
}
|
||||
|
||||
export function isSessionNeedingInputForBanner(session: AiSessionSummary): boolean {
|
||||
return (
|
||||
session.status === "awaiting_input" ||
|
||||
session.status === "error" ||
|
||||
session.status === "waiting_on_input" ||
|
||||
session.status === "needs_attention"
|
||||
);
|
||||
}
|
||||
|
||||
export function getCliActionDisabledReasonForBanner(session: AiSessionSummary, action: CliActionId): string | null {
|
||||
if (action === "advance" && !session.cliSessionId) {
|
||||
return "CLI session id is missing.";
|
||||
}
|
||||
if (action === "relaunch") {
|
||||
return "Relaunch is not supported by the dashboard yet.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface CliActionDeps {
|
||||
currentProjectId?: string;
|
||||
retryTask: (id: string) => Promise<unknown>;
|
||||
moveTask: (id: string, column: "todo") => Promise<unknown>;
|
||||
openAuthenticationSettings: () => void;
|
||||
addToast: (message: string, type: "error") => void;
|
||||
apiClient?: typeof api;
|
||||
}
|
||||
|
||||
export async function executeCliSessionBannerAction(
|
||||
session: AiSessionSummary,
|
||||
action: CliActionId,
|
||||
deps: CliActionDeps,
|
||||
): Promise<void> {
|
||||
try {
|
||||
/*
|
||||
* FNXC:SessionBanner 2026-06-14-19:32:
|
||||
* CLI banner verbs must either call an existing dashboard route/flow or be disabled by the banner. `advance` confirms the CLI session, `retry` and `cancel` reuse task operations keyed by the session id until summaries expose a distinct task id, and `reauthenticate` opens the existing authentication settings flow.
|
||||
*/
|
||||
if (action === "advance") {
|
||||
if (!session.cliSessionId) {
|
||||
throw new Error("CLI session id is required to advance this session.");
|
||||
}
|
||||
await (deps.apiClient ?? api)(`/cli-sessions/${encodeURIComponent(session.cliSessionId)}/confirm-advance`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ decision: "advance", ...(deps.currentProjectId ? { projectId: deps.currentProjectId } : {}) }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "retry") {
|
||||
await deps.retryTask(session.id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "cancel") {
|
||||
await deps.moveTask(session.id, "todo");
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "reauthenticate") {
|
||||
deps.openAuthenticationSettings();
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error("This CLI action is not supported yet.");
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "CLI action failed";
|
||||
deps.addToast(message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
function AppInner() {
|
||||
const { t } = useTranslation("app");
|
||||
const { toasts, addToast, removeToast } = useToast();
|
||||
@@ -372,9 +443,11 @@ function AppInner() {
|
||||
|
||||
// Background AI sessions - required before useModalManager
|
||||
const { sessions: bgSessions, generating: bgGenerating, needsInput: bgNeedsInput, planningSessions: bgPlanningSessions, dismissSession: bgDismiss } = useBackgroundSessions(currentProject?.id);
|
||||
const sessionsNeedingInput = bgSessions.filter(
|
||||
(session) => session.status === "awaiting_input" || session.status === "error"
|
||||
);
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
const sessionsNeedingInput = bgSessions.filter(isSessionNeedingInputForBanner);
|
||||
const sessionBannersHidden = useSessionBannersHidden();
|
||||
|
||||
// Modal state/handlers - required before useViewState
|
||||
@@ -1347,6 +1420,18 @@ function AppInner() {
|
||||
// intentional no-op
|
||||
}, []);
|
||||
|
||||
const handleCliAction = useCallback(
|
||||
(session: AiSessionSummary, action: CliActionId) =>
|
||||
executeCliSessionBannerAction(session, action, {
|
||||
currentProjectId: currentProject?.id,
|
||||
retryTask,
|
||||
moveTask,
|
||||
openAuthenticationSettings: () => modalManager.openSettings("authentication" as SectionId),
|
||||
addToast,
|
||||
}),
|
||||
[addToast, currentProject?.id, modalManager, moveTask, retryTask],
|
||||
);
|
||||
|
||||
const [shellOnboardingComplete, setShellOnboardingComplete] = useState(false);
|
||||
const [shellConnectionManagerOpen, setShellConnectionManagerOpen] = useState(false);
|
||||
const [shellConnectionStatus, setShellConnectionStatus] = useState<ShellConnectionNativeResult | null>(null);
|
||||
@@ -1948,6 +2033,8 @@ function AppInner() {
|
||||
onResumeSession={handleOpenBackgroundSession}
|
||||
onDismissSession={handleDismissNeedingInputSession}
|
||||
onDismissAll={handleDismissAllNeedingInputSessions}
|
||||
onCliAction={handleCliAction}
|
||||
getCliActionDisabledReason={getCliActionDisabledReasonForBanner}
|
||||
/>
|
||||
)}
|
||||
{viewMode === "project" && currentProject && (
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { AiSessionSummary } from "../api";
|
||||
import {
|
||||
executeCliSessionBannerAction,
|
||||
getCliActionDisabledReasonForBanner,
|
||||
isSessionNeedingInputForBanner,
|
||||
} from "../App";
|
||||
import type { CliActionId } from "../components/SessionNotificationBanner";
|
||||
|
||||
function cliSession(overrides: Partial<AiSessionSummary> = {}): AiSessionSummary {
|
||||
return {
|
||||
id: overrides.id ?? "FN-6458",
|
||||
type: "cli-agent",
|
||||
status: overrides.status ?? "needs_attention",
|
||||
title: overrides.title ?? "CLI session needs attention",
|
||||
projectId: overrides.projectId ?? "proj-1",
|
||||
lockedByTab: overrides.lockedByTab ?? null,
|
||||
updatedAt: overrides.updatedAt ?? "2026-06-14T19:32:00.000Z",
|
||||
cliVariant: overrides.cliVariant ?? "userExited",
|
||||
cliSessionId: Object.prototype.hasOwnProperty.call(overrides, "cliSessionId")
|
||||
? overrides.cliSessionId
|
||||
: "cli-session-1",
|
||||
};
|
||||
}
|
||||
|
||||
describe("App CLI session banner wiring", () => {
|
||||
it("surfaces cli-agent needs_attention and waiting_on_input sessions through the App banner filter", () => {
|
||||
expect(isSessionNeedingInputForBanner(cliSession({ status: "needs_attention" }))).toBe(true);
|
||||
expect(isSessionNeedingInputForBanner(cliSession({ status: "waiting_on_input" }))).toBe(true);
|
||||
expect(isSessionNeedingInputForBanner(cliSession({ status: "awaiting_input" }))).toBe(true);
|
||||
expect(isSessionNeedingInputForBanner(cliSession({ status: "error" }))).toBe(true);
|
||||
expect(isSessionNeedingInputForBanner(cliSession({ status: "generating" }))).toBe(false);
|
||||
expect(isSessionNeedingInputForBanner(cliSession({ status: "complete" }))).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["advance", "api"],
|
||||
["retry", "retryTask"],
|
||||
["cancel", "moveTask"],
|
||||
["reauthenticate", "openSettings"],
|
||||
] as const)("maps %s to an observable existing route or flow", async (action, expected) => {
|
||||
const apiClient = vi.fn().mockResolvedValue({ ok: true });
|
||||
const retryTask = vi.fn().mockResolvedValue({ id: "FN-6458" });
|
||||
const moveTask = vi.fn().mockResolvedValue({ id: "FN-6458" });
|
||||
const openAuthenticationSettings = vi.fn();
|
||||
const addToast = vi.fn();
|
||||
|
||||
await executeCliSessionBannerAction(cliSession(), action, {
|
||||
currentProjectId: "proj-1",
|
||||
retryTask,
|
||||
moveTask,
|
||||
openAuthenticationSettings,
|
||||
addToast,
|
||||
apiClient,
|
||||
});
|
||||
|
||||
if (expected === "api") {
|
||||
expect(apiClient).toHaveBeenCalledWith(
|
||||
"/cli-sessions/cli-session-1/confirm-advance",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({ decision: "advance", projectId: "proj-1" }),
|
||||
}),
|
||||
);
|
||||
} else if (expected === "retryTask") {
|
||||
expect(retryTask).toHaveBeenCalledWith("FN-6458");
|
||||
} else if (expected === "moveTask") {
|
||||
expect(moveTask).toHaveBeenCalledWith("FN-6458", "todo");
|
||||
} else {
|
||||
expect(openAuthenticationSettings).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
expect(addToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("marks unsupported or missing-id actions disabled so visible buttons are not silent no-ops", () => {
|
||||
const actions: CliActionId[] = ["advance", "retry", "cancel", "reauthenticate", "relaunch"];
|
||||
const missingId = cliSession({ cliSessionId: undefined });
|
||||
const withId = cliSession();
|
||||
|
||||
const disabled = new Map(actions.map((action) => [action, getCliActionDisabledReasonForBanner(withId, action)]));
|
||||
expect(disabled.get("relaunch")).toMatch(/not supported/i);
|
||||
expect(disabled.get("advance")).toBeNull();
|
||||
expect(getCliActionDisabledReasonForBanner(missingId, "advance")).toMatch(/missing/i);
|
||||
});
|
||||
|
||||
it("toasts instead of silently failing if an enabled CLI action route rejects", async () => {
|
||||
const addToast = vi.fn();
|
||||
await executeCliSessionBannerAction(cliSession(), "retry", {
|
||||
retryTask: vi.fn().mockRejectedValue(new Error("retry failed")),
|
||||
moveTask: vi.fn(),
|
||||
openAuthenticationSettings: vi.fn(),
|
||||
addToast,
|
||||
apiClient: vi.fn(),
|
||||
});
|
||||
|
||||
expect(addToast).toHaveBeenCalledWith("retry failed", "error");
|
||||
});
|
||||
});
|
||||
@@ -162,17 +162,32 @@
|
||||
transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.session-notification-banner__resume:hover {
|
||||
.session-notification-banner__resume:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--triage) 14%, transparent);
|
||||
border-color: var(--triage);
|
||||
}
|
||||
|
||||
.session-notification-banner__resume:disabled,
|
||||
.session-notification-banner__resume--disabled {
|
||||
border-color: var(--border);
|
||||
color: var(--text-muted);
|
||||
background: var(--surface-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.session-notification-banner__item--error .session-notification-banner__resume {
|
||||
border-color: color-mix(in srgb, var(--color-error) 60%, var(--border));
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.session-notification-banner__item--error .session-notification-banner__resume:hover {
|
||||
.session-notification-banner__item--error .session-notification-banner__resume:disabled,
|
||||
.session-notification-banner__item--error .session-notification-banner__resume--disabled {
|
||||
border-color: var(--border);
|
||||
color: var(--text-muted);
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.session-notification-banner__item--error .session-notification-banner__resume:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--color-error) 14%, transparent);
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { AlertCircle, Lightbulb, Layers, Target, Terminal, X } from "lucide-react";
|
||||
import type { AiSessionSummary, CliNeedsAttentionVariant } from "../api";
|
||||
|
||||
type CliActionId = "advance" | "retry" | "cancel" | "reauthenticate" | "relaunch";
|
||||
export type CliActionId = "advance" | "retry" | "cancel" | "reauthenticate" | "relaunch";
|
||||
|
||||
interface SessionNotificationBannerProps {
|
||||
sessions: AiSessionSummary[];
|
||||
@@ -13,11 +13,11 @@ interface SessionNotificationBannerProps {
|
||||
onDismissAll: () => void;
|
||||
/**
|
||||
* CLI agent needs-attention / confirm-advance actions (CLI Agent Executor,
|
||||
* U11). `advance` wires the userExited "Advance" verb + generic-tier
|
||||
* confirm-advance; the others map to existing endpoints where present, else
|
||||
* are no-op callbacks marked TODO-wire by the caller.
|
||||
* U11). Every enabled CLI action must have an observable effect in the host;
|
||||
* unsupported actions should be returned from `getCliActionDisabledReason`.
|
||||
*/
|
||||
onCliAction?: (session: AiSessionSummary, action: CliActionId) => void;
|
||||
getCliActionDisabledReason?: (session: AiSessionSummary, action: CliActionId) => string | null | undefined;
|
||||
}
|
||||
|
||||
// `cli-agent` extends the previously-closed union: a SINGLE Terminal icon for
|
||||
@@ -139,6 +139,7 @@ export function SessionNotificationBanner({
|
||||
onDismissSession,
|
||||
onDismissAll,
|
||||
onCliAction,
|
||||
getCliActionDisabledReason,
|
||||
}: SessionNotificationBannerProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [dismissRevision, setDismissRevision] = useState(0);
|
||||
@@ -300,24 +301,39 @@ export function SessionNotificationBanner({
|
||||
</div>
|
||||
|
||||
<div className="session-notification-banner__actions">
|
||||
{variantSpec.actions.map((action) => (
|
||||
<button
|
||||
key={action}
|
||||
className="session-notification-banner__resume"
|
||||
data-cli-action={action}
|
||||
onClick={() => {
|
||||
// "advance" wires confirm-advance; other verbs hit
|
||||
// existing endpoints or remain TODO-wire no-ops upstream.
|
||||
onCliAction?.(session, action);
|
||||
if (action === "cancel" || action === "advance") {
|
||||
dismissLocally(session);
|
||||
onDismissSession(session.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t(CLI_ACTION_LABELS[action].key, CLI_ACTION_LABELS[action].defaultVal)}
|
||||
</button>
|
||||
))}
|
||||
{variantSpec.actions.map((action) => {
|
||||
const label = t(CLI_ACTION_LABELS[action].key, CLI_ACTION_LABELS[action].defaultVal);
|
||||
const disabledReason = !onCliAction
|
||||
? t("sessionBanner.cli.actionUnavailable", "Action unavailable")
|
||||
: getCliActionDisabledReason?.(session, action);
|
||||
const disabled = Boolean(disabledReason);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={action}
|
||||
className={`session-notification-banner__resume${disabled ? " session-notification-banner__resume--disabled" : ""}`}
|
||||
data-cli-action={action}
|
||||
data-cli-action-disabled={disabled ? "true" : undefined}
|
||||
disabled={disabled}
|
||||
aria-label={disabled ? `${label} unavailable: ${disabledReason}` : undefined}
|
||||
title={disabledReason ?? undefined}
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
/*
|
||||
* FNXC:SessionBanner 2026-06-14-19:32:
|
||||
* Enabled CLI action buttons must call the host handler; unsupported or missing-id actions render disabled instead so no visible action can fall through to a silent no-op. Advance and cancel preserve the banner's local-dismiss contract after firing the observable host action.
|
||||
*/
|
||||
onCliAction?.(session, action);
|
||||
if (action === "cancel" || action === "advance") {
|
||||
dismissLocally(session);
|
||||
onDismissSession(session.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
className="session-notification-banner__dismiss"
|
||||
onClick={() => {
|
||||
|
||||
@@ -327,7 +327,7 @@ function buildCliSession(overrides: Partial<AiSessionSummary>): AiSessionSummary
|
||||
lockedByTab: null,
|
||||
updatedAt: overrides.updatedAt ?? new Date().toISOString(),
|
||||
cliVariant: overrides.cliVariant,
|
||||
cliSessionId: overrides.cliSessionId ?? "cli-1",
|
||||
cliSessionId: Object.prototype.hasOwnProperty.call(overrides, "cliSessionId") ? overrides.cliSessionId : "cli-1",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -419,4 +419,83 @@ describe("SessionNotificationBanner — cli-agent (U11)", () => {
|
||||
expect(screen.getByText("Relaunch fresh")).toBeInTheDocument();
|
||||
expect(screen.getByText("Cancel task")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["userExited", ["advance", "retry", "cancel"]],
|
||||
["authFailed", ["reauthenticate", "retry"]],
|
||||
["resume-exhausted", ["relaunch", "cancel"]],
|
||||
] as const)("makes every %s action observable or disabled", (cliVariant, actions) => {
|
||||
for (const action of actions) {
|
||||
dismissedIds.clear();
|
||||
const onCliAction = vi.fn();
|
||||
const onDismissSession = vi.fn();
|
||||
const { unmount } = render(
|
||||
<SessionNotificationBanner
|
||||
sessions={[buildCliSession({ status: "needs_attention", cliVariant })]}
|
||||
onResumeSession={vi.fn()}
|
||||
onDismissSession={onDismissSession}
|
||||
onDismissAll={vi.fn()}
|
||||
onCliAction={onCliAction}
|
||||
getCliActionDisabledReason={(_session, candidate) =>
|
||||
candidate === "relaunch" ? "Relaunch is not supported by the dashboard yet." : null
|
||||
}
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = document.querySelector<HTMLButtonElement>(`[data-cli-action="${action}"]`);
|
||||
expect(button).toBeTruthy();
|
||||
if (button?.disabled) {
|
||||
expect(button).toHaveAccessibleName(/unavailable:/i);
|
||||
expect(onCliAction).not.toHaveBeenCalled();
|
||||
} else {
|
||||
fireEvent.click(button!);
|
||||
expect(onCliAction).toHaveBeenCalledWith(expect.objectContaining({ id: "cli-1" }), action);
|
||||
if (action === "advance" || action === "cancel") {
|
||||
expect(onDismissSession).toHaveBeenCalledWith("cli-1");
|
||||
} else {
|
||||
expect(onDismissSession).not.toHaveBeenCalled();
|
||||
}
|
||||
}
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("disables CLI actions when the host provides no action handler", () => {
|
||||
render(
|
||||
<SessionNotificationBanner
|
||||
sessions={[buildCliSession({ status: "needs_attention", cliVariant: "authFailed" })]}
|
||||
onResumeSession={vi.fn()}
|
||||
onDismissSession={vi.fn()}
|
||||
onDismissAll={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
for (const button of screen.getAllByRole("button").filter((node) => node.hasAttribute("data-cli-action"))) {
|
||||
expect(button).toBeDisabled();
|
||||
expect(button).toHaveAccessibleName(/unavailable:/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("disables actions that require a missing cliSessionId without leaving an empty click target", () => {
|
||||
const onCliAction = vi.fn();
|
||||
render(
|
||||
<SessionNotificationBanner
|
||||
sessions={[buildCliSession({ status: "needs_attention", cliVariant: "userExited", cliSessionId: undefined })]}
|
||||
onResumeSession={vi.fn()}
|
||||
onDismissSession={vi.fn()}
|
||||
onDismissAll={vi.fn()}
|
||||
onCliAction={onCliAction}
|
||||
getCliActionDisabledReason={(session, action) =>
|
||||
action === "advance" && !session.cliSessionId ? "CLI session id is missing." : null
|
||||
}
|
||||
/>,
|
||||
);
|
||||
|
||||
const advance = screen.getByRole("button", { name: /advance unavailable: cli session id is missing/i });
|
||||
expect(advance).toBeDisabled();
|
||||
expect(advance).toHaveAttribute("data-cli-action-disabled", "true");
|
||||
expect(advance).toHaveTextContent("Advance");
|
||||
fireEvent.click(advance);
|
||||
expect(onCliAction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,6 +61,8 @@ describe("useBackgroundSessions", () => {
|
||||
mockFetchAiSessions.mockResolvedValueOnce([
|
||||
makeSession({ id: "s-generating", status: "generating" }),
|
||||
makeSession({ id: "s-awaiting", status: "awaiting_input" }),
|
||||
makeSession({ id: "s-waiting", type: "cli-agent", status: "waiting_on_input" }),
|
||||
makeSession({ id: "s-needs-attention", type: "cli-agent", status: "needs_attention", cliVariant: "authFailed" }),
|
||||
makeSession({ id: "s-complete", status: "complete" }),
|
||||
makeSession({ id: "s-error", status: "error" }),
|
||||
makeSession({ id: "s-ignored", status: "paused" as any }),
|
||||
@@ -76,7 +78,10 @@ describe("useBackgroundSessions", () => {
|
||||
expect(result.current.sessions.map((session) => session.id).sort()).toEqual([
|
||||
"s-awaiting",
|
||||
"s-generating",
|
||||
"s-needs-attention",
|
||||
"s-waiting",
|
||||
]);
|
||||
expect(result.current.needsInput).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -29,7 +29,16 @@ function parseTimestamp(updatedAt: string | undefined): number {
|
||||
}
|
||||
|
||||
function shouldIncludeSession(session: AiSessionSummary): boolean {
|
||||
return session.status === "generating" || session.status === "awaiting_input";
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
return (
|
||||
session.status === "generating" ||
|
||||
session.status === "awaiting_input" ||
|
||||
session.status === "waiting_on_input" ||
|
||||
session.status === "needs_attention"
|
||||
);
|
||||
}
|
||||
|
||||
function isTerminalStatus(
|
||||
@@ -388,7 +397,9 @@ export function useBackgroundSessions(projectId?: string): UseBackgroundSessions
|
||||
return {
|
||||
sessions: active,
|
||||
generating: active.filter((session) => session.status === "generating").length,
|
||||
needsInput: active.filter((session) => session.status === "awaiting_input").length,
|
||||
needsInput: active.filter(
|
||||
(session) => session.status === "awaiting_input" || session.status === "waiting_on_input",
|
||||
).length,
|
||||
planningSessions,
|
||||
dismissSession,
|
||||
refresh,
|
||||
|
||||
Reference in New Issue
Block a user