From f01a48690dac82d2c277631af8bdc3702a4f8d7c Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 23 Jun 2026 19:17:40 -0700 Subject: [PATCH] refactor(dashboard): extract App.tsx lifecycle helpers to utils/appLifecycle Move the module-level pure functions (approval-banner dedupe helpers, CLI banner actions, boot-loader/shell-onboarding guards, remote-dashboard URL builder) and storage-key constants out of App.tsx into app/utils/appLifecycle.ts. App.tsx re-exports the seven unit-tested symbols so existing `from "../../App"` test imports resolve unchanged. Behavior-preserving; no functional change. Verified: dashboard typecheck (both passes), eslint clean on both files, 16 pure-function unit tests pass, App.test.tsx identical to pristine (5 pre-existing experimental-flag failures, none introduced by this change). U1 of the App.tsx module-breakup plan (docs/plans/2026-06-24-001-refactor-dashboard-app-tsx-module-breakup-plan.md). --- packages/dashboard/app/App.tsx | 204 ++++--------------- packages/dashboard/app/utils/appLifecycle.ts | 177 ++++++++++++++++ 2 files changed, 211 insertions(+), 170 deletions(-) create mode 100644 packages/dashboard/app/utils/appLifecycle.ts diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 4b03a187dd..8add720341 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -100,8 +100,41 @@ import { NativeShellConnectionManager } from "./components/NativeShellConnection import { ShellConnectionStatus } from "./components/ShellConnectionStatus"; import { getShellConnectionNativeResult, type ShellConnectionNativeResult } from "./shell-native"; import type { AiSessionSummary, DashboardHealthResponse, PluginDashboardViewEntry } from "./api"; -import { api, fetchDashboardHealth, fetchUnreadCount, fetchTaskDetail, fetchWorkflowSteps, refreshDashboardHealth, relaunchCliSession } from "./api"; +import { api, fetchDashboardHealth, fetchUnreadCount, fetchTaskDetail, fetchWorkflowSteps, refreshDashboardHealth } from "./api"; import { getScopedItem, removeScopedItem, setScopedItem } from "./utils/projectStorage"; +import { + SETUP_WARNING_DISMISSED_KEY, + WORKING_BRANCH_FILTER_STORAGE_KEY, + BASE_BRANCH_FILTER_STORAGE_KEY, + NO_BRANCH_FILTER_VALUE, + CAPACITY_RISK_DISMISSED_KEY, + RETRY_WARNING_RATIO, + parseDateMs, + loadApprovalBannerDismissals, + persistApprovalBannerDismissals, + buildRemoteDashboardUrl, + didEnterAwaitingApproval, + didEnterDone, + requiresNativeShellOnboarding, + shouldShowFirstEverBootLoader, + isSessionNeedingInputForBanner, + getCliActionDisabledReasonForBanner, + executeCliSessionBannerAction, + type ApprovalBannerCandidate, +} from "./utils/appLifecycle"; +// Re-export the unit-tested lifecycle helpers so existing `from "./App"` / +// `from "../../App"` imports keep resolving after the bodies moved to utils. +export { + didEnterAwaitingApproval, + didEnterDone, + requiresNativeShellOnboarding, + shouldShowFirstEverBootLoader, + isSessionNeedingInputForBanner, + getCliActionDisabledReasonForBanner, + executeCliSessionBannerAction, + type ApprovalBannerCandidate, + type CliActionDeps, +} from "./utils/appLifecycle"; import { subscribeSse } from "./sse-bus"; import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } from "./auth"; import { AuthTokenRecoveryDialog } from "./components/AuthTokenRecoveryDialog"; @@ -180,175 +213,6 @@ function prefetchLazyViews() { registerBundledPluginViews(); -const SETUP_WARNING_DISMISSED_KEY = "kb-setup-warning-dismissed"; -const WORKING_BRANCH_FILTER_STORAGE_KEY = "kb-dashboard-working-branch-filter"; -const BASE_BRANCH_FILTER_STORAGE_KEY = "kb-dashboard-base-branch-filter"; -const NO_BRANCH_FILTER_VALUE = "__fusion:no-branch__"; -const APPROVAL_BANNER_DISMISSED_STORAGE_KEY = "fusion:approval-banner-dismissed"; -const CAPACITY_RISK_DISMISSED_KEY = "kb-capacity-risk-banner-dismissed"; -const RETRY_WARNING_RATIO = 0.8; - -interface ApprovalBannerCandidate { - dedupeKey: string; - updatedAtMs: number; -} - -export function didEnterAwaitingApproval(nextStatus: string | undefined, previousStatus: string | undefined): boolean { - return nextStatus === "awaiting-approval" && previousStatus !== "awaiting-approval"; -} - -export function didEnterDone(nextStatus: string | undefined, previousStatus: string | undefined): boolean { - return nextStatus === "done" && previousStatus !== undefined && previousStatus !== "done"; -} - -function parseDateMs(value: string | undefined): number { - if (!value) return 0; - const parsed = Date.parse(value); - return Number.isFinite(parsed) ? parsed : 0; -} - -function loadApprovalBannerDismissals(): Map { - if (typeof window === "undefined") return new Map(); - try { - const raw = window.localStorage.getItem(APPROVAL_BANNER_DISMISSED_STORAGE_KEY); - if (!raw) return new Map(); - const parsed = JSON.parse(raw) as Record; - const map = new Map(); - for (const [key, value] of Object.entries(parsed)) { - if (typeof value === "number" && Number.isFinite(value)) { - map.set(key, value); - } - } - return map; - } catch { - return new Map(); - } -} - -function persistApprovalBannerDismissals(map: Map): void { - if (typeof window === "undefined") return; - try { - const data: Record = {}; - for (const [key, value] of map) { - data[key] = value; - } - window.localStorage.setItem(APPROVAL_BANNER_DISMISSED_STORAGE_KEY, JSON.stringify(data)); - } catch { - // no-op - } -} - -function buildRemoteDashboardUrl(serverUrl: string, authToken?: string | null): string { - const url = new URL(serverUrl); - if (authToken) { - url.searchParams.set("rt", authToken); - } - return url.toString(); -} - -export function requiresNativeShellOnboarding( - shellState: { host: "web" | "mobile-shell" | "desktop-shell"; desktopMode?: "local" | "remote"; activeProfileId: string | null }, - shellReady: boolean, - shellOnboardingComplete: boolean, -): boolean { - if (!shellReady || shellOnboardingComplete || shellState.host === "web") { - return false; - } - - if (shellState.host === "mobile-shell") { - return !shellState.activeProfileId; - } - - if (shellState.desktopMode === "local") { - return false; - } - - return !shellState.activeProfileId; -} - -export function shouldShowFirstEverBootLoader(projectsLoading: boolean, projectCount: number): boolean { - 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" || action === "relaunch") && !session.cliSessionId) { - return "CLI session id is missing."; - } - return null; -} - -interface CliActionDeps { - currentProjectId?: string; - retryTask: (id: string) => Promise; - moveTask: (id: string, column: "todo") => Promise; - openAuthenticationSettings: () => void; - addToast: (message: string, type: "success" | "error") => void; - apiClient?: typeof api; - relaunchCliSessionClient?: typeof relaunchCliSession; -} - -export async function executeCliSessionBannerAction( - session: AiSessionSummary, - action: CliActionId, - deps: CliActionDeps, -): Promise { - 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. - * - * FNXC:SessionBanner 2026-06-14-20:16: - * `relaunch` is now a supported route-backed action for resume-exhausted CLI sessions; if `cliSessionId` is absent the handler exits without firing a malformed API call, preserving the no-silent-no-op invariant through the banner disabled reason. - */ - 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 === "relaunch") { - if (!session.cliSessionId) return; - await (deps.relaunchCliSessionClient ?? relaunchCliSession)(session.cliSessionId, deps.currentProjectId); - deps.addToast("CLI session relaunch requested", "success"); - 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(); diff --git a/packages/dashboard/app/utils/appLifecycle.ts b/packages/dashboard/app/utils/appLifecycle.ts new file mode 100644 index 0000000000..0855df94cb --- /dev/null +++ b/packages/dashboard/app/utils/appLifecycle.ts @@ -0,0 +1,177 @@ +/* +FNXC:AppLifecycle 2026-06-24-00:00: +Module-level lifecycle helpers, storage-key constants, and banner/CLI-banner pure functions extracted out of App.tsx so the root component stays an orchestrator. Behavior is byte-identical to the former inline definitions; App.tsx re-exports the unit-tested symbols to preserve its import contract. +*/ + +import type { AiSessionSummary } from "../api"; +import { api, relaunchCliSession } from "../api"; +import type { CliActionId } from "../components/SessionNotificationBanner"; + +export const SETUP_WARNING_DISMISSED_KEY = "kb-setup-warning-dismissed"; +export const WORKING_BRANCH_FILTER_STORAGE_KEY = "kb-dashboard-working-branch-filter"; +export const BASE_BRANCH_FILTER_STORAGE_KEY = "kb-dashboard-base-branch-filter"; +export const NO_BRANCH_FILTER_VALUE = "__fusion:no-branch__"; +export const APPROVAL_BANNER_DISMISSED_STORAGE_KEY = "fusion:approval-banner-dismissed"; +export const CAPACITY_RISK_DISMISSED_KEY = "kb-capacity-risk-banner-dismissed"; +export const RETRY_WARNING_RATIO = 0.8; + +export interface ApprovalBannerCandidate { + dedupeKey: string; + updatedAtMs: number; +} + +export function didEnterAwaitingApproval(nextStatus: string | undefined, previousStatus: string | undefined): boolean { + return nextStatus === "awaiting-approval" && previousStatus !== "awaiting-approval"; +} + +export function didEnterDone(nextStatus: string | undefined, previousStatus: string | undefined): boolean { + return nextStatus === "done" && previousStatus !== undefined && previousStatus !== "done"; +} + +export function parseDateMs(value: string | undefined): number { + if (!value) return 0; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +export function loadApprovalBannerDismissals(): Map { + if (typeof window === "undefined") return new Map(); + try { + const raw = window.localStorage.getItem(APPROVAL_BANNER_DISMISSED_STORAGE_KEY); + if (!raw) return new Map(); + const parsed = JSON.parse(raw) as Record; + const map = new Map(); + for (const [key, value] of Object.entries(parsed)) { + if (typeof value === "number" && Number.isFinite(value)) { + map.set(key, value); + } + } + return map; + } catch { + return new Map(); + } +} + +export function persistApprovalBannerDismissals(map: Map): void { + if (typeof window === "undefined") return; + try { + const data: Record = {}; + for (const [key, value] of map) { + data[key] = value; + } + window.localStorage.setItem(APPROVAL_BANNER_DISMISSED_STORAGE_KEY, JSON.stringify(data)); + } catch { + // no-op + } +} + +export function buildRemoteDashboardUrl(serverUrl: string, authToken?: string | null): string { + const url = new URL(serverUrl); + if (authToken) { + url.searchParams.set("rt", authToken); + } + return url.toString(); +} + +export function requiresNativeShellOnboarding( + shellState: { host: "web" | "mobile-shell" | "desktop-shell"; desktopMode?: "local" | "remote"; activeProfileId: string | null }, + shellReady: boolean, + shellOnboardingComplete: boolean, +): boolean { + if (!shellReady || shellOnboardingComplete || shellState.host === "web") { + return false; + } + + if (shellState.host === "mobile-shell") { + return !shellState.activeProfileId; + } + + if (shellState.desktopMode === "local") { + return false; + } + + return !shellState.activeProfileId; +} + +export function shouldShowFirstEverBootLoader(projectsLoading: boolean, projectCount: number): boolean { + 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" || action === "relaunch") && !session.cliSessionId) { + return "CLI session id is missing."; + } + return null; +} + +export interface CliActionDeps { + currentProjectId?: string; + retryTask: (id: string) => Promise; + moveTask: (id: string, column: "todo") => Promise; + openAuthenticationSettings: () => void; + addToast: (message: string, type: "success" | "error") => void; + apiClient?: typeof api; + relaunchCliSessionClient?: typeof relaunchCliSession; +} + +export async function executeCliSessionBannerAction( + session: AiSessionSummary, + action: CliActionId, + deps: CliActionDeps, +): Promise { + 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. + * + * FNXC:SessionBanner 2026-06-14-20:16: + * `relaunch` is now a supported route-backed action for resume-exhausted CLI sessions; if `cliSessionId` is absent the handler exits without firing a malformed API call, preserving the no-silent-no-op invariant through the banner disabled reason. + */ + 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 === "relaunch") { + if (!session.cliSessionId) return; + await (deps.relaunchCliSessionClient ?? relaunchCliSession)(session.cliSessionId, deps.currentProjectId); + deps.addToast("CLI session relaunch requested", "success"); + 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"); + } +}