From 775a1f8e9aa1e5a27b0d2e2b415a7fd1a0197ac2 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 25 Jun 2026 19:04:46 -0700 Subject: [PATCH] FN-7020: compact AI session input banner Make the dashboard AI session needs-input banner less prominent while preserving visibility rules. - Reduce the session notification banner spacing, text scale, max heights, and mobile density. - Cover banner visibility on missions, planning, hidden, empty, and board-session states. - Add a patch changeset for the published CLI package. Files changed: .changeset/fn-7020-session-banner-compact.md | 7 + .../app/components/SessionNotificationBanner.css | 47 +++-- .../dashboard/__tests__/DashboardBanners.test.tsx | 227 +++++++++++++++++++++ 3 files changed, 263 insertions(+), 18 deletions(-) Fusion-Task-Id: FN-7020 Fusion-Task-Lineage: bd48da60-29ef-4871-ae27-8a1a48f744ea --- .changeset/fn-7020-session-banner-compact.md | 7 + .../components/SessionNotificationBanner.css | 47 ++-- .../__tests__/DashboardBanners.test.tsx | 227 ++++++++++++++++++ 3 files changed, 263 insertions(+), 18 deletions(-) create mode 100644 .changeset/fn-7020-session-banner-compact.md create mode 100644 packages/dashboard/app/components/dashboard/__tests__/DashboardBanners.test.tsx diff --git a/.changeset/fn-7020-session-banner-compact.md b/.changeset/fn-7020-session-banner-compact.md new file mode 100644 index 0000000000..addcb2f53f --- /dev/null +++ b/.changeset/fn-7020-session-banner-compact.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Make the AI session needs-input banner compact and hide it on Missions or Planning. +category: fix +dev: Shrinks SessionNotificationBanner CSS and tests the DashboardBanners visibility guard. diff --git a/packages/dashboard/app/components/SessionNotificationBanner.css b/packages/dashboard/app/components/SessionNotificationBanner.css index deb484708e..48c9befc2c 100644 --- a/packages/dashboard/app/components/SessionNotificationBanner.css +++ b/packages/dashboard/app/components/SessionNotificationBanner.css @@ -1,17 +1,21 @@ /* === Session Notification Banner === */ +/* +FNXC:SessionBanner 2026-06-25-00:00: +FN-7020 requires the needs-input banner to stay visible when relevant without competing with primary dashboard content. Keep the desktop and mobile layouts compact by reducing padding, gaps, list heights, and text scale while preserving token-based touch targets. +*/ .session-notification-banner { - --session-notification-banner-max-height: min(60vh, calc(var(--space-2xl) * 13)); - --session-notification-list-max-height: min(48vh, calc(var(--space-2xl) * 9)); - --session-notification-touch-size: calc(var(--space-xl) + var(--space-md)); + --session-notification-banner-max-height: min(52vh, calc(var(--space-2xl) * 10)); + --session-notification-list-max-height: min(38vh, calc(var(--space-2xl) * 7)); + --session-notification-touch-size: calc(var(--space-xl) + var(--space-xs)); position: sticky; top: 0; z-index: 30; display: flex; flex-direction: column; - gap: var(--space-sm); - padding: var(--space-sm) var(--space-lg); + gap: var(--space-xs); + padding: var(--space-xs) var(--space-md); border-bottom: var(--btn-border-width) solid var(--border); border-left: var(--space-xs) solid var(--triage); background: var(--surface); @@ -44,6 +48,7 @@ display: inline-flex; align-items: center; gap: var(--space-xs); + font-size: 0.875rem; font-weight: 600; color: var(--text); } @@ -56,12 +61,12 @@ .session-notification-banner__dismiss-all { display: inline-flex; align-items: center; - gap: var(--space-sm); + gap: var(--space-xs); min-height: var(--session-notification-touch-size); border: var(--btn-border-width) solid transparent; background: transparent; color: var(--text-muted); - font-size: 0.75rem; + font-size: 0.6875rem; font-weight: 600; border-radius: var(--radius-sm); padding: var(--space-xs) var(--space-sm); @@ -85,7 +90,7 @@ .session-notification-banner__list { display: flex; flex-direction: column; - gap: var(--space-sm); + gap: var(--space-xs); overflow-y: auto; max-height: var(--session-notification-list-max-height); } @@ -94,8 +99,8 @@ display: flex; align-items: center; justify-content: space-between; - gap: var(--space-sm); - padding: var(--space-sm) var(--space-md); + gap: var(--space-xs); + padding: var(--space-xs) var(--space-sm); border: var(--btn-border-width) solid var(--border); border-radius: var(--radius-sm); background: color-mix(in srgb, var(--triage) 8%, var(--surface)); @@ -109,7 +114,7 @@ .session-notification-banner__item-main { display: flex; align-items: center; - gap: var(--space-sm); + gap: var(--space-xs); min-width: 0; flex: 1; } @@ -129,7 +134,7 @@ .session-notification-banner__title { margin: 0; - font-size: 0.8125rem; + font-size: 0.75rem; font-weight: 600; color: var(--text); white-space: nowrap; @@ -139,14 +144,14 @@ .session-notification-banner__meta { margin: var(--space-xs) 0 0; - font-size: 0.75rem; + font-size: 0.6875rem; color: var(--text-muted); } .session-notification-banner__actions { display: inline-flex; align-items: center; - gap: var(--space-sm); + gap: var(--space-xs); } .session-notification-banner__resume { @@ -155,9 +160,9 @@ background: transparent; color: var(--triage); border-radius: var(--radius-sm); - font-size: 0.75rem; + font-size: 0.6875rem; font-weight: 600; - padding: var(--space-xs) var(--space-md); + padding: var(--space-xs) var(--space-sm); cursor: pointer; transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast); } @@ -214,8 +219,12 @@ @media (max-width: 768px) { .session-notification-banner { - padding: var(--space-sm) var(--space-md); - gap: var(--space-sm); + --session-notification-banner-max-height: min(46vh, calc(var(--space-2xl) * 9)); + --session-notification-list-max-height: min(32vh, calc(var(--space-2xl) * 6)); + --session-notification-touch-size: calc(var(--space-xl) + var(--space-md)); + + padding: var(--space-xs) var(--space-sm); + gap: var(--space-xs); } .session-notification-banner__header { @@ -226,6 +235,8 @@ .session-notification-banner__item { flex-direction: column; align-items: flex-start; + gap: var(--space-xs); + padding: var(--space-xs); } .session-notification-banner__item-main { diff --git a/packages/dashboard/app/components/dashboard/__tests__/DashboardBanners.test.tsx b/packages/dashboard/app/components/dashboard/__tests__/DashboardBanners.test.tsx new file mode 100644 index 0000000000..f3ad6c7cde --- /dev/null +++ b/packages/dashboard/app/components/dashboard/__tests__/DashboardBanners.test.tsx @@ -0,0 +1,227 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { AiSessionSummary } from "../../../api"; +import type { ModalManager } from "../../../hooks/useModalManager"; +import type { DashboardBannersProps } from "../types"; + +vi.mock("../../TestModeBanner", () => ({ TestModeBanner: () => null })); +vi.mock("../../EngineUnavailableBanner", () => ({ EngineUnavailableBanner: () => null })); +vi.mock("../../EngineStatusBanner", () => ({ EngineStatusBanner: () => null })); +vi.mock("../../OAuthReloginBanner", () => ({ OAuthReloginBanner: () => null })); +vi.mock("../../CliBinaryInstallBanner", () => ({ CliBinaryInstallBanner: () => null })); +vi.mock("../../OnboardingResumeCard", () => ({ OnboardingResumeCard: () => null })); +vi.mock("../../PostOnboardingRecommendations", () => ({ PostOnboardingRecommendations: () => null })); +vi.mock("../../UpdateAvailableBanner", () => ({ UpdateAvailableBanner: () => null })); +vi.mock("../../MergeAdvanceNotice", () => ({ default: () => null })); +vi.mock("../../TaskIdIntegrityBanner", () => ({ TaskIdIntegrityBanner: () => null })); +vi.mock("../../DbCorruptionBanner", () => ({ DbCorruptionBanner: () => null })); +vi.mock("../../SetupWarningBanner", () => ({ SetupWarningBanner: () => null })); +vi.mock("../../ApprovalNotificationBanner", () => ({ ApprovalNotificationBanner: () => null })); +vi.mock("../../GitHubStarPrompt", () => ({ GitHubStarPrompt: () => null })); + +import { DashboardBanners } from "../DashboardBanners"; + +function buildSession(overrides: Partial = {}): AiSessionSummary { + return { + id: overrides.id ?? "session-1", + type: overrides.type ?? "planning", + status: overrides.status ?? "awaiting_input", + title: overrides.title ?? "Draft implementation plan", + projectId: overrides.projectId ?? "proj-1", + lockedByTab: overrides.lockedByTab ?? null, + updatedAt: overrides.updatedAt ?? "2026-06-25T00:00:00.000Z", + ...overrides, + }; +} + +function buildModalManager(overrides: Partial = {}): ModalManager { + const noop = vi.fn(); + return { + newTaskModalOpen: false, + newTaskInitialDescription: null, + isPlanningOpen: false, + planningInitialPlan: null, + planningResumeSessionId: undefined, + planningWorkflowId: undefined, + isSubtaskOpen: false, + subtaskInitialDescription: null, + subtaskResumeSessionId: undefined, + subtaskWorkflowId: undefined, + detailTask: null, + detailTaskInitialTab: "chat", + detailTaskOrigin: null, + groupModalGroupId: null, + settingsOpen: false, + settingsInitialSection: undefined, + schedulesOpen: false, + githubImportOpen: false, + usageOpen: false, + usageAnchorRect: null, + terminalOpen: false, + terminalInitialCommand: undefined, + terminalInitialCommandGeneration: 0, + filesOpen: false, + fileBrowserWorkspace: "project", + fileBrowserInitialFile: null, + activityLogOpen: false, + gitManagerOpen: false, + workflowEditorOpen: false, + workflowEditorInitialPanel: undefined, + workflowEditorInitialAction: undefined, + workflowEditorInitialWorkflowId: undefined, + agentsOpen: false, + scriptsOpen: false, + setupWizardOpen: false, + modelOnboardingOpen: false, + anyModalOpen: false, + openNewTask: noop, + openNewTaskWithDescription: noop, + closeNewTask: noop, + openPlanning: noop, + openPlanningWithInitialPlan: noop, + resumePlanning: noop, + openPlanningWithSession: noop, + closePlanning: noop, + openSubtaskBreakdown: noop, + openSubtaskWithSession: noop, + closeSubtask: noop, + openDetailTask: noop, + openDetailWithChangesTab: noop, + updateDetailTask: noop, + closeDetailTask: noop, + openGroupModal: noop, + closeGroupModal: noop, + openSettings: noop, + setSettingsSection: noop, + closeSettings: noop, + openSchedules: noop, + closeSchedules: noop, + openGitHubImport: noop, + closeGitHubImport: noop, + openUsage: noop, + closeUsage: noop, + toggleTerminal: noop, + closeTerminal: noop, + openFiles: noop, + closeFiles: noop, + setFileWorkspace: noop, + openActivityLog: noop, + closeActivityLog: noop, + openGitManager: noop, + closeGitManager: noop, + openWorkflowEditor: noop, + closeWorkflowEditor: noop, + openAgents: noop, + closeAgents: noop, + openScripts: noop, + closeScripts: noop, + runScript: vi.fn().mockResolvedValue(undefined), + openSetupWizard: noop, + closeSetupWizard: noop, + openModelOnboarding: noop, + closeModelOnboarding: noop, + onPlanningTaskCreated: noop, + onPlanningTasksCreated: noop, + onSubtaskTasksCreated: noop, + ...overrides, + }; +} + +function buildProps(overrides: Partial = {}): DashboardBannersProps { + return { + viewMode: "project", + currentProject: { id: "proj-1", name: "Project", path: "/tmp/project" } as DashboardBannersProps["currentProject"], + isTestMode: false, + dashboardHealth: null, + setDashboardHealth: vi.fn(), + taskView: "board", + modalManager: buildModalManager(), + sessionBannersHidden: false, + sessionsNeedingInput: [buildSession()], + handleOpenBackgroundSession: vi.fn(), + handleDismissNeedingInputSession: vi.fn(), + handleDismissAllNeedingInputSessions: vi.fn(), + handleCliAction: vi.fn().mockResolvedValue(undefined), + getCliActionDisabledReasonForBanner: vi.fn(() => null), + openSettingsWithNav: vi.fn(), + showOnboardingResumeCard: false, + showPostOnboardingRecommendations: false, + updateAvailable: false, + latestVersion: null, + currentVersion: null, + updateBannerDismissed: false, + dismissUpdateBanner: vi.fn(), + refreshDbCorruptionHealth: vi.fn().mockResolvedValue(undefined), + dbCorruptionRefreshing: false, + dbCorruptionRefreshError: null, + setupReadinessLoading: false, + hasWarnings: false, + setupWarningDismissed: false, + handleDismissSetupWarning: vi.fn(), + hasAiProvider: true, + hasGithub: true, + approvalBannerCandidate: null, + dismissApproval: vi.fn(), + mailboxPendingApprovalCount: 0, + handleTaskViewChange: vi.fn(), + showGitHubStarPrompt: false, + gitHubStarPromptShown: false, + markGitHubStarPromptShown: vi.fn(), + setShowGitHubStarPrompt: vi.fn(), + ...overrides, + }; +} + +function querySessionBanner(): HTMLElement | null { + return screen.queryByRole("region", { name: /AI sessions needing input or failed/i }); +} + +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. + */ + it("does not render the session notification banner on the missions view", () => { + render(); + + expect(querySessionBanner()).not.toBeInTheDocument(); + expect(screen.queryByText("Draft implementation plan")).not.toBeInTheDocument(); + }); + + it("does not render the session notification banner while planning is open", () => { + render(); + + expect(querySessionBanner()).not.toBeInTheDocument(); + expect(screen.queryByText("Draft implementation plan")).not.toBeInTheDocument(); + }); + + it("renders the session notification banner on board views when sessions need input", () => { + render( + , + ); + + expect(querySessionBanner()).toBeInTheDocument(); + expect(screen.getByText("Awaiting input")).toBeInTheDocument(); + expect(screen.getByText("Needs attention")).toBeInTheDocument(); + }); + + it("does not render when the appearance setting hides session banners", () => { + render(); + + expect(querySessionBanner()).not.toBeInTheDocument(); + }); + + it("does not render when there are zero sessions needing input", () => { + render(); + + expect(querySessionBanner()).not.toBeInTheDocument(); + }); +});