From 7ad1599b70f2007d4bec4642f0f008196deb434f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Fri, 31 Jul 2026 14:23:16 +0100 Subject: [PATCH] feat(web): show WebSocket connectivity warnings (#2083) --- .../app-sidebar/app-sidebar-footer.test.tsx | 35 ++- .../app-sidebar/app-sidebar-footer.tsx | 103 +++++++-- .../app-status-bar/app-status-bar.test.tsx | 22 +- .../app-status-bar/app-status-drawer.test.tsx | 42 ++++ .../app-status-bar/app-status-drawer.tsx | 4 +- .../app-status-bar/app-status-items.tsx | 8 +- .../app-status-surface-provider.test.tsx | 100 ++++++++- .../app-status-surface-provider.tsx | 55 ++++- .../connection-status-item.test.tsx | 19 +- .../app-status-bar/connection-status-item.tsx | 45 +++- .../kanban/kanban-header-mobile.test.tsx | 19 ++ .../kanban/kanban-header-mobile.tsx | 24 ++- .../components/kanban/mobile-menu-sheet.tsx | 18 +- .../settings/settings-layout-client.tsx | 39 +++- .../mobile/session-mobile-bottom-nav.test.tsx | 17 ++ .../task/mobile/session-mobile-bottom-nav.tsx | 84 ++++++-- .../task/mobile/session-mobile-layout.tsx | 23 +- .../e2e/tests/layout/app-status-bar.spec.ts | 17 ++ .../mobile-ws-connectivity-warning.spec.ts | 83 ++++++++ .../layout/ws-connectivity-warning.spec.ts | 96 +++++++++ apps/web/lib/state/slices/ui/types.ts | 4 +- apps/web/lib/state/slices/ui/ui-slice.ts | 6 +- apps/web/lib/state/store.test.ts | 6 + apps/web/lib/state/store.ts | 3 + apps/web/lib/types/connection.ts | 2 + .../lib/ws/connection-issue-monitor.test.ts | 70 ++++++ apps/web/lib/ws/connection-issue-monitor.ts | 55 +++++ apps/web/lib/ws/use-websocket.test.tsx | 71 +++++++ apps/web/lib/ws/use-websocket.tsx | 6 + docs/plans/ws-connectivity-warning/plan.md | 199 ++++++++++++++++++ .../task-01-connection-issue-timing.md | 75 +++++++ .../task-02-desktop-warning-surfaces.md | 62 ++++++ .../task-03-mobile-warning-surfaces.md | 73 +++++++ .../task-04-connectivity-warning-e2e.md | 63 ++++++ .../task-05-public-documentation.md | 65 ++++++ docs/public/configuration.md | 10 +- docs/public/operations.md | 6 +- docs/specs/INDEX.md | 3 +- docs/specs/feature-toggles/spec.md | 5 +- docs/specs/office/live-updates.md | 2 +- docs/specs/ui/app-status-bar.md | 11 +- docs/specs/ui/ws-connectivity-warning.md | 117 ++++++++++ 42 files changed, 1682 insertions(+), 85 deletions(-) create mode 100644 apps/web/e2e/tests/layout/mobile-ws-connectivity-warning.spec.ts create mode 100644 apps/web/e2e/tests/layout/ws-connectivity-warning.spec.ts create mode 100644 apps/web/lib/ws/connection-issue-monitor.test.ts create mode 100644 apps/web/lib/ws/connection-issue-monitor.ts create mode 100644 apps/web/lib/ws/use-websocket.test.tsx create mode 100644 docs/plans/ws-connectivity-warning/plan.md create mode 100644 docs/plans/ws-connectivity-warning/task-01-connection-issue-timing.md create mode 100644 docs/plans/ws-connectivity-warning/task-02-desktop-warning-surfaces.md create mode 100644 docs/plans/ws-connectivity-warning/task-03-mobile-warning-surfaces.md create mode 100644 docs/plans/ws-connectivity-warning/task-04-connectivity-warning-e2e.md create mode 100644 docs/plans/ws-connectivity-warning/task-05-public-documentation.md create mode 100644 docs/specs/ui/ws-connectivity-warning.md diff --git a/apps/web/components/app-sidebar/app-sidebar-footer.test.tsx b/apps/web/components/app-sidebar/app-sidebar-footer.test.tsx index 21e3f5749f..ebb232079b 100644 --- a/apps/web/components/app-sidebar/app-sidebar-footer.test.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-footer.test.tsx @@ -22,9 +22,11 @@ const state = { mode: "disabled" as string, user: null as { display_name: string; email: string } | null, }, + connection: { issueSeverity: "none" as "none" | "unstable" | "lost" }, }; let officeEnabled = false; +let appStatusBarEnabled = true; const DEFAULT_PATHNAME = "/tasks/session-1"; let pathname = DEFAULT_PATHNAME; @@ -38,7 +40,7 @@ vi.mock("@/components/state-provider", () => ({ })); vi.mock("@/hooks/domains/features/use-feature", () => ({ - useFeature: () => officeEnabled, + useFeature: (name: string) => (name === "office" ? officeEnabled : appStatusBarEnabled), })); vi.mock("@/hooks/use-release-notes", () => ({ @@ -114,6 +116,8 @@ describe("AppSidebarFooter", () => { ]; state.appSidebar.settingsMode = false; state.auth = { mode: "disabled", user: null }; + state.connection.issueSeverity = "none"; + appStatusBarEnabled = true; window.localStorage.clear(); document.cookie = "office-active-workspace=; path=/; max-age=0"; mocks.routerPush.mockClear(); @@ -232,6 +236,35 @@ describe("AppSidebarFooter", () => { }); }); +describe("AppSidebarFooter connection fallback", () => { + beforeEach(() => { + appStatusBarEnabled = true; + state.connection.issueSeverity = "none"; + }); + + afterEach(cleanup); + + it("shows the connection fallback immediately after the theme control during an outage", () => { + appStatusBarEnabled = false; + state.connection.issueSeverity = "unstable"; + + renderFooter(); + + const warning = screen.getByTestId("sidebar-connection-warning"); + expect(warning.getAttribute("aria-label")).toBe("Connection unstable. Reconnecting to Kandev."); + expect(warning.getAttribute("data-connection-severity")).toBe("unstable"); + expect(screen.getByRole("button", { name: "Theme" }).nextElementSibling).toBe(warning); + }); + + it("does not duplicate the warning fallback when the app status bar is enabled", () => { + state.connection.issueSeverity = "lost"; + + renderFooter(); + + expect(screen.queryByTestId("sidebar-connection-warning")).toBeNull(); + }); +}); + describe("AppSidebarFooter current-user chip", () => { beforeEach(() => { officeEnabled = false; diff --git a/apps/web/components/app-sidebar/app-sidebar-footer.tsx b/apps/web/components/app-sidebar/app-sidebar-footer.tsx index 274685fc8e..7d6c050b14 100644 --- a/apps/web/components/app-sidebar/app-sidebar-footer.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-footer.tsx @@ -9,6 +9,7 @@ import { IconSettings, IconSparkles, IconStethoscope, + IconWifiOff, } from "@tabler/icons-react"; import type { Icon as TablerIcon } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; @@ -31,6 +32,8 @@ import { workspaceHomeHref, } from "./app-sidebar-workspace-navigation"; import { isSettingsRoute } from "./app-sidebar-route"; +import { connectionIssueDetails } from "../app-status-bar/connection-status-item"; +import type { ConnectionIssueSeverity } from "@/lib/types/connection"; type AppSidebarFooterProps = { collapsed: boolean; @@ -98,6 +101,87 @@ function FooterIconButton({ ); } +function SidebarConnectionWarning({ + collapsed, + severity, +}: { + collapsed: boolean; + severity: Exclude; +}) { + const details = connectionIssueDetails(severity); + + return ( + + + + + {details.description} + + ); +} + +function SidebarConnectionFallback({ + collapsed, + appStatusBarEnabled, +}: { + collapsed: boolean; + appStatusBarEnabled: boolean; +}) { + const severity = useAppStore((state) => state.connection.issueSeverity); + if (appStatusBarEnabled || severity === "none") return null; + return ; +} + +function SidebarFooterDialogs({ + improveOpen, + onImproveOpenChange, + workspaceId, + onTaskCreated, + releaseNotes, +}: { + improveOpen: boolean; + onImproveOpenChange: (open: boolean) => void; + workspaceId: string | null; + onTaskCreated: (task: { id: string }) => void; + releaseNotes: ReturnType; +}) { + return ( + <> + + {releaseNotes.hasNotes && ( + + )} + + ); +} + export function AppSidebarFooter({ collapsed, onToggleSettingsMode }: AppSidebarFooterProps) { const router = useRouter(); const pathname = usePathname(); @@ -121,6 +205,7 @@ export function AppSidebarFooter({ collapsed, onToggleSettingsMode }: AppSidebar onToggleSettingsMode(); }; const officeEnabled = useFeature("office"); + const appStatusBarEnabled = useFeature("appStatusBar"); const releaseNotes = useReleaseNotes(); const [improveOpen, setImproveOpen] = useState(false); const authMode = useAppStore((s) => s.auth.mode); @@ -184,23 +269,17 @@ export function AppSidebarFooter({ collapsed, onToggleSettingsMode }: AppSidebar /> )} + {showCurrentUser && ( )} - router.push(linkToTask(task.id))} + onTaskCreated={(task) => router.push(linkToTask(task.id))} + releaseNotes={releaseNotes} /> - {releaseNotes.hasNotes && ( - - )} ); } diff --git a/apps/web/components/app-status-bar/app-status-bar.test.tsx b/apps/web/components/app-status-bar/app-status-bar.test.tsx index 8da36ac2a9..674d2eca2a 100644 --- a/apps/web/components/app-status-bar/app-status-bar.test.tsx +++ b/apps/web/components/app-status-bar/app-status-bar.test.tsx @@ -3,6 +3,7 @@ import { TooltipProvider } from "@kandev/ui/tooltip"; import { toast } from "sonner"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { StateProvider } from "@/components/state-provider"; +import type { AppState } from "@/lib/state/store"; import { ApiError } from "@/lib/api/client"; import { updateUserSettings } from "@/lib/api/domains/settings-api"; import { pluginRegistry } from "@/lib/plugins/registry"; @@ -104,6 +105,23 @@ describe("AppStatusBar rendering", () => { expect(document.querySelector(`[data-status-item-id="${APP_STATUS_METRICS_ID}"]`)).toBeNull(); }); + it("keeps the saved connection item identity while collapsing its healthy content", () => { + renderBar(); + + expect(statusItem(APP_STATUS_CONNECTION_ID)).toBeTruthy(); + expect(screen.queryByTestId("app-status-connection")).toBeNull(); + }); + + it("shows the active connection warning in the bottom status bar", () => { + renderBar({ + connection: { status: "reconnecting", error: null, issueSeverity: "lost" }, + }); + + expect(screen.getByTestId("app-status-connection").getAttribute("aria-label")).toBe( + "Connection lost for at least 10 seconds. Live updates may be stale.", + ); + }); + it("collapses a plugin item when its contribution renders nothing", () => { pluginRegistry.forPlugin(RIGHT_PLUGIN_ID).registerComponent("app-status-bar-right", () => null); @@ -263,9 +281,9 @@ describe("AppStatusBar persistence and recovery", () => { }); }); -function renderBar() { +function renderBar(initialState?: Partial) { return render( - + ({ const LEFT_PLUGIN_ID = "drawer-left"; const RIGHT_PLUGIN_ID = "drawer-right"; +function renderConnectionOnlyDrawer() { + pluginRegistry + .forPlugin(LEFT_PLUGIN_ID) + .registerComponent("app-status-bar-left", () => Left plugin); + pluginRegistry + .forPlugin(RIGHT_PLUGIN_ID) + .registerComponent("app-status-bar-right", () => Right plugin); + + render( + + + {}} + connectionOnly + /> + + , + ); +} + describe("AppStatusDrawer", () => { afterEach(() => { cleanup(); @@ -114,4 +147,13 @@ describe("AppStatusDrawer", () => { ); expect(row?.className).toContain("empty:hidden"); }); + + it("renders only connection state in connection-only mode", () => { + renderConnectionOnlyDrawer(); + + const rows = screen + .getByTestId("app-status-drawer") + .querySelectorAll("[data-status-item-id]"); + expect(Array.from(rows, (row) => row.dataset.statusItemId)).toEqual([APP_STATUS_CONNECTION_ID]); + }); }); diff --git a/apps/web/components/app-status-bar/app-status-drawer.tsx b/apps/web/components/app-status-bar/app-status-drawer.tsx index cdd89e9e34..8464286a2b 100644 --- a/apps/web/components/app-status-bar/app-status-drawer.tsx +++ b/apps/web/components/app-status-bar/app-status-drawer.tsx @@ -14,6 +14,7 @@ type AppStatusDrawerProps = { activeSessionId: string | null; open: boolean; onOpenChange: (open: boolean) => void; + connectionOnly?: boolean; }; export function AppStatusDrawer({ @@ -23,12 +24,13 @@ export function AppStatusDrawer({ activeSessionId, open, onOpenChange, + connectionOnly = false, }: AppStatusDrawerProps) { const context = useMemo( () => ({ pathname, activeWorkspaceId, activeTaskId, activeSessionId }), [pathname, activeWorkspaceId, activeTaskId, activeSessionId], ); - const activeItems = useAppStatusItems(context); + const activeItems = useAppStatusItems(context, { connectionOnly }); const { projected } = useAppStatusBarOrder(activeItems); const orderedItems = [...projected.left, ...projected.right]; let drawerHeight = "h-[min(32rem,calc(100dvh-16px-env(safe-area-inset-bottom,0px)))]"; diff --git a/apps/web/components/app-status-bar/app-status-items.tsx b/apps/web/components/app-status-bar/app-status-items.tsx index 327b082d0e..ecb2a66604 100644 --- a/apps/web/components/app-status-bar/app-status-items.tsx +++ b/apps/web/components/app-status-bar/app-status-items.tsx @@ -28,7 +28,10 @@ type AppStatusContext = Pick< "pathname" | "activeWorkspaceId" | "activeTaskId" | "activeSessionId" >; -export function useAppStatusItems(context: AppStatusContext): AppStatusItem[] { +export function useAppStatusItems( + context: AppStatusContext, + { connectionOnly = false }: { connectionOnly?: boolean } = {}, +): AppStatusItem[] { const registry = usePluginRegistry(); const registryVersion = registry.getVersion(); const metricsEnabled = useAppStore( @@ -38,13 +41,14 @@ export function useAppStatusItems(context: AppStatusContext): AppStatusItem[] { return useMemo(() => { const left = registry.getSlotRegistrations("app-status-bar-left"); const right = registry.getSlotRegistrations("app-status-bar-right"); + if (connectionOnly) return [connectionItem()]; return [ connectionItem(), ...left.map((registration) => pluginItem(registration, "left", context)), ...(metricsEnabled ? [metricsItem()] : []), ...right.map((registration) => pluginItem(registration, "right", context)), ]; - }, [context, metricsEnabled, registry, registryVersion]); + }, [connectionOnly, context, metricsEnabled, registry, registryVersion]); } function connectionItem(): AppStatusItem { diff --git a/apps/web/components/app-status-bar/app-status-surface-provider.test.tsx b/apps/web/components/app-status-bar/app-status-surface-provider.test.tsx index 39d6d60299..9af3583a67 100644 --- a/apps/web/components/app-status-bar/app-status-surface-provider.test.tsx +++ b/apps/web/components/app-status-bar/app-status-surface-provider.test.tsx @@ -1,6 +1,7 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { StateProvider } from "@/components/state-provider"; +import { StateProvider, useAppStoreApi } from "@/components/state-provider"; +import type { AppState } from "@/lib/state/store"; import { AppStatusDrawerTrigger, AppStatusSurfaceProvider } from "./app-status-surface-provider"; const responsiveState = vi.hoisted(() => ({ @@ -9,6 +10,7 @@ const responsiveState = vi.hoisted(() => ({ const featureState = vi.hoisted(() => ({ appStatusBar: true })); const STATUS_BAR_TEST_ID = "app-status-bar"; const STATUS_DRAWER_TEST_ID = "app-status-drawer"; +const STATUS_DRAWER_TRIGGER_TEST_ID = "app-status-drawer-trigger"; vi.mock("@/hooks/use-responsive-breakpoint", () => ({ useResponsiveBreakpoint: () => ({ @@ -32,21 +34,43 @@ vi.mock("./app-status-bar", () => ({ })); vi.mock("./app-status-drawer", () => ({ - AppStatusDrawer: ({ open }: { open: boolean }) => ( -
{String(open)}
+ AppStatusDrawer: ({ open, connectionOnly }: { open: boolean; connectionOnly?: boolean }) => ( +
+ {String(open)} +
), })); -function renderSurface() { +function renderSurface(initialState?: Partial, children = ) { return render( - - - - + + {children} , ); } +function ConnectionIssueControls() { + const store = useAppStoreApi(); + return ( + <> + ); } +function drawerTriggerVisibilityClass(connectionOnly: boolean) { + return connectionOnly ? "lg:hidden" : "sm:hidden"; +} + export function AppStatusSurfaceProvider({ children }: { children: ReactNode }) { const [drawerOpen, setStatusDrawerOpen] = useState(false); const pathname = usePathname(); const activeWorkspaceId = useAppStore((state) => state.workspaces.activeId); const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); + const issueSeverity = useAppStore((state) => state.connection.issueSeverity); const appStatusBarEnabled = useFeature("appStatusBar"); - const { isMobile, isFullDesktop } = useResponsiveBreakpoint(); - const drawerEnabled = appStatusBarEnabled && isMobile; + const { isMobile, isTablet, isFullDesktop } = useResponsiveBreakpoint(); + const connectionOnly = !appStatusBarEnabled && issueSeverity !== "none"; + const useDrawerSurface = isMobile || (isTablet && connectionOnly); + const drawerEnabled = useDrawerSurface && (appStatusBarEnabled || connectionOnly); + + useEffect(() => { + if (!drawerEnabled) setStatusDrawerOpen(false); + }, [drawerEnabled]); + const drawer = useMemo( () => ({ enabled: drawerEnabled, + issueSeverity, + connectionOnly, drawerOpen, openStatusDrawer: () => { if (drawerEnabled) setStatusDrawerOpen(true); }, setStatusDrawerOpen, }), - [drawerEnabled, drawerOpen], + [connectionOnly, drawerEnabled, drawerOpen, issueSeverity], ); const surfaceProps = { pathname, @@ -97,12 +137,13 @@ export function AppStatusSurfaceProvider({ children }: { children: ReactNode })
{children} - {appStatusBarEnabled && - (isMobile ? ( + {(useDrawerSurface ? drawerEnabled : appStatusBarEnabled) && + (useDrawerSurface ? ( ) : ( diff --git a/apps/web/components/app-status-bar/connection-status-item.test.tsx b/apps/web/components/app-status-bar/connection-status-item.test.tsx index a18da47734..af1e61a60d 100644 --- a/apps/web/components/app-status-bar/connection-status-item.test.tsx +++ b/apps/web/components/app-status-bar/connection-status-item.test.tsx @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { connectionStatusDetails } from "./connection-status-item"; +import { connectionIssueDetails, connectionStatusDetails } from "./connection-status-item"; describe("connectionStatusDetails", () => { it.each([ @@ -23,4 +23,21 @@ describe("connectionStatusDetails", () => { animate: false, }); }); + + it.each([ + [ + "unstable", + "Connection unstable", + "Connection unstable. Reconnecting to Kandev.", + "bg-amber-500", + ], + [ + "lost", + "Connection lost", + "Connection lost for at least 10 seconds. Live updates may be stale.", + "bg-destructive", + ], + ] as const)("maps %s to a problem-only warning", (severity, label, description, dotClass) => { + expect(connectionIssueDetails(severity)).toMatchObject({ label, description, dotClass }); + }); }); diff --git a/apps/web/components/app-status-bar/connection-status-item.tsx b/apps/web/components/app-status-bar/connection-status-item.tsx index dfa56ee257..67803db247 100644 --- a/apps/web/components/app-status-bar/connection-status-item.tsx +++ b/apps/web/components/app-status-bar/connection-status-item.tsx @@ -3,7 +3,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { cn } from "@kandev/ui/lib/utils"; import { useAppStore } from "@/components/state-provider"; -import type { ConnectionStatus } from "@/lib/types/connection"; +import type { ConnectionIssueSeverity, ConnectionStatus } from "@/lib/types/connection"; export type ConnectionStatusDetails = { label: string; @@ -12,6 +12,13 @@ export type ConnectionStatusDetails = { animate: boolean; }; +export type ConnectionIssueDetails = { + label: string; + description: string; + dotClass: string; + animate: boolean; +}; + export function connectionStatusDetails( status: ConnectionStatus, error: string | null, @@ -55,25 +62,53 @@ export function connectionStatusDetails( } } +export function connectionIssueDetails( + severity: Exclude, +): ConnectionIssueDetails { + switch (severity) { + case "unstable": + return { + label: "Connection unstable", + description: "Connection unstable. Reconnecting to Kandev.", + dotClass: "bg-amber-500", + animate: false, + }; + case "lost": + return { + label: "Connection lost", + description: "Connection lost for at least 10 seconds. Live updates may be stale.", + dotClass: "bg-destructive", + animate: false, + }; + } +} + export function ConnectionStatusItem({ presentation }: { presentation: "bar" | "mobile-drawer" }) { const status = useAppStore((state) => state.connection.status); const error = useAppStore((state) => state.connection.error); - const details = connectionStatusDetails(status, error); + const severity = useAppStore((state) => state.connection.issueSeverity); + if (severity === "none" && presentation === "bar") return null; + const issueActive = severity !== "none"; + const details = issueActive + ? connectionIssueDetails(severity) + : connectionStatusDetails(status, error); return (