feat(web): show WebSocket connectivity warnings (#2083)

This commit is contained in:
Carlos Florêncio
2026-07-31 14:23:16 +01:00
committed by GitHub
parent 7c73d5c4c4
commit 7ad1599b70
42 changed files with 1682 additions and 85 deletions
@@ -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;
@@ -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<ConnectionIssueSeverity, "none">;
}) {
const details = connectionIssueDetails(severity);
return (
<Tooltip>
<TooltipTrigger asChild>
<span
className="relative flex h-9 w-9 shrink-0 items-center justify-center rounded-md text-muted-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
role="status"
aria-label={details.description}
tabIndex={0}
data-testid="sidebar-connection-warning"
data-connection-severity={severity}
>
<IconWifiOff className="size-4" aria-hidden="true" />
<span
className={cn(
"absolute right-1 top-1 size-2 rounded-full ring-2 ring-background",
details.dotClass,
)}
aria-hidden="true"
/>
<span className="sr-only">{details.label}</span>
</span>
</TooltipTrigger>
<TooltipContent side={collapsed ? "right" : "top"}>{details.description}</TooltipContent>
</Tooltip>
);
}
function SidebarConnectionFallback({
collapsed,
appStatusBarEnabled,
}: {
collapsed: boolean;
appStatusBarEnabled: boolean;
}) {
const severity = useAppStore((state) => state.connection.issueSeverity);
if (appStatusBarEnabled || severity === "none") return null;
return <SidebarConnectionWarning collapsed={collapsed} severity={severity} />;
}
function SidebarFooterDialogs({
improveOpen,
onImproveOpenChange,
workspaceId,
onTaskCreated,
releaseNotes,
}: {
improveOpen: boolean;
onImproveOpenChange: (open: boolean) => void;
workspaceId: string | null;
onTaskCreated: (task: { id: string }) => void;
releaseNotes: ReturnType<typeof useReleaseNotes>;
}) {
return (
<>
<ImproveKandevDialog
open={improveOpen}
onOpenChange={onImproveOpenChange}
workspaceId={workspaceId}
onSuccess={onTaskCreated}
/>
{releaseNotes.hasNotes && (
<ReleaseNotesDialog
open={releaseNotes.dialogOpen}
onOpenChange={releaseNotes.closeDialog}
entries={releaseNotes.unseenEntries}
latestVersion={releaseNotes.latestVersion}
/>
)}
</>
);
}
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
/>
)}
<ThemeToggle />
<SidebarConnectionFallback collapsed={collapsed} appStatusBarEnabled={appStatusBarEnabled} />
{showCurrentUser && (
<CurrentUserChip collapsed={collapsed} className={cn(!collapsed && "ml-auto")} />
)}
<ImproveKandevDialog
open={improveOpen}
onOpenChange={setImproveOpen}
<SidebarFooterDialogs
improveOpen={improveOpen}
onImproveOpenChange={setImproveOpen}
workspaceId={workspaceId ?? null}
onSuccess={(task) => router.push(linkToTask(task.id))}
onTaskCreated={(task) => router.push(linkToTask(task.id))}
releaseNotes={releaseNotes}
/>
{releaseNotes.hasNotes && (
<ReleaseNotesDialog
open={releaseNotes.dialogOpen}
onOpenChange={releaseNotes.closeDialog}
entries={releaseNotes.unseenEntries}
latestVersion={releaseNotes.latestVersion}
/>
)}
</div>
);
}
@@ -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<AppState>) {
return render(
<StateProvider>
<StateProvider initialState={initialState}>
<TooltipProvider>
<AppStatusBar
pathname="/"
@@ -26,6 +26,39 @@ vi.mock("@/hooks/use-responsive-breakpoint", () => ({
const LEFT_PLUGIN_ID = "drawer-left";
const RIGHT_PLUGIN_ID = "drawer-right";
function renderConnectionOnlyDrawer() {
pluginRegistry
.forPlugin(LEFT_PLUGIN_ID)
.registerComponent("app-status-bar-left", () => <span>Left plugin</span>);
pluginRegistry
.forPlugin(RIGHT_PLUGIN_ID)
.registerComponent("app-status-bar-right", () => <span>Right plugin</span>);
render(
<StateProvider
initialState={{
userSettings: {
...defaultSettingsState.userSettings,
systemMetricsDisplay: { showInTopbar: true, simplified: false },
},
connection: { status: "reconnecting", error: null, issueSeverity: "unstable" },
}}
>
<TooltipProvider>
<AppStatusDrawer
pathname="/"
activeWorkspaceId={null}
activeTaskId={null}
activeSessionId={null}
open
onOpenChange={() => {}}
connectionOnly
/>
</TooltipProvider>
</StateProvider>,
);
}
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<HTMLElement>("[data-status-item-id]");
expect(Array.from(rows, (row) => row.dataset.statusItemId)).toEqual([APP_STATUS_CONNECTION_ID]);
});
});
@@ -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)))]";
@@ -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 {
@@ -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 }) => (
<div data-testid={STATUS_DRAWER_TEST_ID}>{String(open)}</div>
AppStatusDrawer: ({ open, connectionOnly }: { open: boolean; connectionOnly?: boolean }) => (
<div data-testid={STATUS_DRAWER_TEST_ID} data-connection-only={connectionOnly}>
{String(open)}
</div>
),
}));
function renderSurface() {
function renderSurface(initialState?: Partial<AppState>, children = <AppStatusDrawerTrigger />) {
return render(
<StateProvider>
<AppStatusSurfaceProvider>
<AppStatusDrawerTrigger />
</AppStatusSurfaceProvider>
<StateProvider initialState={initialState}>
<AppStatusSurfaceProvider>{children}</AppStatusSurfaceProvider>
</StateProvider>,
);
}
function ConnectionIssueControls() {
const store = useAppStoreApi();
return (
<>
<button
data-testid="recover-connection"
onClick={() => {
store.getState().setConnectionStatus("connected");
store.getState().setConnectionIssueSeverity("none");
}}
/>
<button
data-testid="restore-connection-issue"
onClick={() => {
store.getState().setConnectionStatus("reconnecting");
store.getState().setConnectionIssueSeverity("unstable");
}}
/>
</>
);
}
describe("AppStatusSurfaceProvider", () => {
beforeEach(() => {
responsiveState.breakpoint = "desktop";
@@ -80,7 +104,25 @@ describe("AppStatusSurfaceProvider", () => {
expect(screen.queryByTestId(STATUS_BAR_TEST_ID)).toBeNull();
expect(screen.queryByTestId(STATUS_DRAWER_TEST_ID)).toBeNull();
expect(screen.queryByTestId("app-status-drawer-trigger")).toBeNull();
expect(screen.queryByTestId(STATUS_DRAWER_TRIGGER_TEST_ID)).toBeNull();
});
it("exposes a connection-only Status drawer for an active phone warning when disabled", () => {
responsiveState.breakpoint = "mobile";
featureState.appStatusBar = false;
renderSurface({
connection: { status: "reconnecting", error: null, issueSeverity: "unstable" },
});
expect(screen.getByTestId(STATUS_DRAWER_TEST_ID).getAttribute("data-connection-only")).toBe(
"true",
);
expect(screen.getByTestId(STATUS_DRAWER_TRIGGER_TEST_ID).getAttribute("aria-label")).toBe(
"Connection unstable. Reconnecting to Kandev.",
);
fireEvent.click(screen.getByTestId(STATUS_DRAWER_TRIGGER_TEST_ID));
expect(screen.getByTestId(STATUS_DRAWER_TEST_ID).textContent).toBe("true");
});
it("does not expose a drawer trigger at the tablet breakpoint", () => {
@@ -89,6 +131,44 @@ describe("AppStatusSurfaceProvider", () => {
expect(screen.getByTestId(STATUS_BAR_TEST_ID)).toBeTruthy();
expect(screen.queryByTestId(STATUS_DRAWER_TEST_ID)).toBeNull();
expect(screen.queryByTestId("app-status-drawer-trigger")).toBeNull();
expect(screen.queryByTestId(STATUS_DRAWER_TRIGGER_TEST_ID)).toBeNull();
});
it("keeps an active connection warning reachable at the tablet breakpoint", () => {
responsiveState.breakpoint = "tablet";
featureState.appStatusBar = false;
renderSurface({
connection: { status: "reconnecting", error: null, issueSeverity: "unstable" },
});
expect(screen.getByTestId(STATUS_DRAWER_TEST_ID).getAttribute("data-connection-only")).toBe(
"true",
);
const trigger = screen.getByTestId(STATUS_DRAWER_TRIGGER_TEST_ID);
expect(trigger.className).toContain("lg:hidden");
expect(trigger.className).not.toContain("md:hidden");
});
it("closes a connection-only drawer when the warning recovers", () => {
responsiveState.breakpoint = "mobile";
featureState.appStatusBar = false;
renderSurface(
{ connection: { status: "reconnecting", error: null, issueSeverity: "unstable" } },
<>
<AppStatusDrawerTrigger />
<ConnectionIssueControls />
</>,
);
fireEvent.click(screen.getByTestId(STATUS_DRAWER_TRIGGER_TEST_ID));
expect(screen.getByTestId(STATUS_DRAWER_TEST_ID).textContent).toBe("true");
fireEvent.click(screen.getByTestId("recover-connection"));
expect(screen.queryByTestId(STATUS_DRAWER_TEST_ID)).toBeNull();
fireEvent.click(screen.getByTestId("restore-connection-issue"));
expect(screen.getByTestId(STATUS_DRAWER_TEST_ID).textContent).toBe("false");
});
});
@@ -3,6 +3,7 @@
import {
createContext,
useContext,
useEffect,
useMemo,
useState,
type ComponentProps,
@@ -17,9 +18,13 @@ import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { usePathname } from "@/lib/routing/client-router";
import { AppStatusBar } from "./app-status-bar";
import { AppStatusDrawer } from "./app-status-drawer";
import { connectionIssueDetails } from "./connection-status-item";
import type { ConnectionIssueSeverity } from "@/lib/types/connection";
type AppStatusDrawerContextValue = {
enabled: boolean;
issueSeverity: ConnectionIssueSeverity;
connectionOnly: boolean;
drawerOpen: boolean;
openStatusDrawer: () => void;
setStatusDrawerOpen: (open: boolean) => void;
@@ -28,6 +33,8 @@ type AppStatusDrawerContextValue = {
const AppStatusDrawerContext = createContext<AppStatusDrawerContextValue | null>(null);
const unavailableDrawer: AppStatusDrawerContextValue = {
enabled: false,
issueSeverity: "none",
connectionOnly: false,
drawerOpen: false,
openStatusDrawer: () => {},
setStatusDrawerOpen: () => {},
@@ -50,41 +57,74 @@ export function AppStatusDrawerTrigger({
}: AppStatusDrawerTriggerProps) {
const drawer = useContext(AppStatusDrawerContext);
if (!drawer?.enabled) return null;
const issueDetails =
drawer.issueSeverity === "none" ? null : connectionIssueDetails(drawer.issueSeverity);
const issueActive = issueDetails !== null;
const accessibleLabel = issueDetails?.description ?? label;
return (
<Button
{...buttonProps}
type="button"
variant={buttonProps.variant ?? "ghost"}
size={buttonProps.size ?? "icon"}
className={cn("h-11 w-11 cursor-pointer sm:hidden", className)}
aria-label={label}
className={cn(
"relative h-11 w-11 cursor-pointer",
drawerTriggerVisibilityClass(drawer.connectionOnly),
issueActive && (drawer.issueSeverity === "lost" ? "text-destructive" : "text-amber-500"),
className,
)}
aria-label={accessibleLabel}
onClick={drawer.openStatusDrawer}
data-testid="app-status-drawer-trigger"
data-connection-severity={issueActive ? drawer.issueSeverity : undefined}
>
{children ?? <IconActivity className="h-4 w-4" />}
{issueActive && (
<span
className={cn(
"absolute right-2 top-2 size-2 rounded-full ring-2 ring-background",
drawer.issueSeverity === "lost" ? "bg-destructive" : "bg-amber-500",
)}
aria-hidden="true"
/>
)}
</Button>
);
}
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<AppStatusDrawerContextValue>(
() => ({
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 })
<AppStatusDrawerContext.Provider value={drawer}>
<div className="flex h-dvh min-h-0 w-full flex-col overflow-hidden">
{children}
{appStatusBarEnabled &&
(isMobile ? (
{(useDrawerSurface ? drawerEnabled : appStatusBarEnabled) &&
(useDrawerSurface ? (
<AppStatusDrawer
{...surfaceProps}
open={drawerOpen}
onOpenChange={setStatusDrawerOpen}
connectionOnly={connectionOnly}
/>
) : (
<AppStatusBar {...surfaceProps} density={isFullDesktop ? "full" : "compact"} />
@@ -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 });
});
});
@@ -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<ConnectionIssueSeverity, "none">,
): 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 (
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
"inline-flex h-full items-center leading-none",
presentation === "bar" ? "w-5 justify-center" : "min-h-11 gap-3 px-1 text-sm",
"inline-flex items-center leading-none",
presentation === "bar" ? "h-full w-5 justify-center" : "min-h-11 gap-3 px-1 text-sm",
)}
role="status"
aria-label={details.description}
tabIndex={presentation === "bar" ? 0 : undefined}
data-testid="app-status-connection"
data-connection-severity={issueActive ? severity : undefined}
>
<span
className={`size-1.5 shrink-0 rounded-full ${details.dotClass} ${details.animate ? "animate-pulse" : ""}`}
className={`size-1.5 shrink-0 rounded-full ${details.dotClass} ${issueActive || details.animate ? "animate-pulse" : ""}`}
aria-hidden="true"
/>
{presentation === "bar" ? (
@@ -30,17 +30,25 @@ vi.mock("./mobile-menu-sheet", () => ({
}));
const quickChatMocks = vi.hoisted(() => ({ openQuickChat: vi.fn() }));
const statusDrawerState = vi.hoisted(() => ({
issueSeverity: "none" as "none" | "unstable" | "lost",
}));
vi.mock("@/hooks/use-quick-chat-launcher", () => ({
useQuickChatLauncher: () => quickChatMocks.openQuickChat,
}));
vi.mock("@/components/app-status-bar/app-status-surface-provider", () => ({
useAppStatusDrawer: () => statusDrawerState,
}));
const LEFT_ACTIONS_TEST_ID = "topbar-left-actions";
const QUICK_CHAT_TEST_ID = "mobile-quick-chat-button";
afterEach(() => {
cleanup();
quickChatMocks.openQuickChat.mockClear();
statusDrawerState.issueSeverity = "none";
});
function renderHeader(title: string, workspaceId?: string, onSearchChange?: () => void) {
@@ -97,4 +105,15 @@ describe("KanbanHeaderMobile", () => {
const search = screen.getByTestId("mobile-search-toggle");
expect(quickChat.nextElementSibling).toBe(search);
});
it("describes a connectivity warning on the persistent Home menu trigger", () => {
statusDrawerState.issueSeverity = "lost";
renderHeader("Home");
expect(
screen.getByRole("button", {
name: "Connection lost for at least 10 seconds. Live updates may be stale. Open menu",
}),
).toBeTruthy();
});
});
@@ -9,8 +9,11 @@ import { MainTopBarPluginActions } from "./main-top-bar-plugin-actions";
import { MobileMenuSheet } from "./mobile-menu-sheet";
import type { TasksListDisplayOptions } from "./mobile-menu-task-list-options";
import { useAppStore } from "@/components/state-provider";
import { useAppStatusDrawer } from "@/components/app-status-bar/app-status-surface-provider";
import { connectionIssueDetails } from "@/components/app-status-bar/connection-status-item";
import { useQuickChatLauncher } from "@/hooks/use-quick-chat-launcher";
import { workspaceHomeHref } from "@/components/app-sidebar/app-sidebar-workspace-navigation";
import { cn } from "@/lib/utils";
type KanbanHeaderMobileProps = {
workspaceId?: string;
@@ -57,6 +60,9 @@ function MobileHeaderActions({
toggleSearch: () => void;
setMenuOpen: (open: boolean) => void;
}) {
const { issueSeverity } = useAppStatusDrawer();
const issueDetails = issueSeverity === "none" ? null : connectionIssueDetails(issueSeverity);
return (
<>
<MainTopBarPluginActions
@@ -94,10 +100,24 @@ function MobileHeaderActions({
variant="outline"
size="icon-lg"
onClick={() => setMenuOpen(true)}
className="cursor-pointer"
className={cn(
"relative cursor-pointer",
issueSeverity === "lost" && "text-destructive",
issueSeverity === "unstable" && "text-amber-500",
)}
aria-label={issueDetails ? `${issueDetails.description} Open menu` : "Open menu"}
data-connection-severity={issueSeverity === "none" ? undefined : issueSeverity}
>
<IconMenu2 className="h-4 w-4" />
<span className="sr-only">Open menu</span>
{issueDetails && (
<span
className={cn(
"absolute right-1.5 top-1.5 size-2 rounded-full ring-2 ring-background",
issueDetails.dotClass,
)}
aria-hidden="true"
/>
)}
</Button>
</>
);
@@ -31,6 +31,7 @@ import type { Repository, Task } from "@/lib/types/http";
import type { WorkflowsState } from "@/lib/state/slices";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { useAppStatusDrawer } from "@/components/app-status-bar/app-status-surface-provider";
import { connectionIssueDetails } from "@/components/app-status-bar/connection-status-item";
import { ImproveKandevDialog } from "@/components/improve-kandev-dialog";
type MobileMenuSheetProps = {
open: boolean;
@@ -289,7 +290,8 @@ function MobileUtilityActions({
onOpenImproveKandev: () => void;
onOpenChange: (open: boolean) => void;
}) {
const { enabled: statusDrawerEnabled, openStatusDrawer } = useAppStatusDrawer();
const { enabled: statusDrawerEnabled, issueSeverity, openStatusDrawer } = useAppStatusDrawer();
const issueDetails = issueSeverity === "none" ? null : connectionIssueDetails(issueSeverity);
const closeSheet = () => onOpenChange(false);
const openHealth = () => {
closeSheet();
@@ -307,12 +309,24 @@ function MobileUtilityActions({
<Button
type="button"
variant="outline"
className="h-11 w-full cursor-pointer justify-start gap-3 px-3 text-sm"
className={cn(
"relative h-11 w-full cursor-pointer justify-start gap-3 px-3 text-sm",
issueSeverity === "lost" && "border-destructive/40 text-destructive",
issueSeverity === "unstable" && "border-amber-500/40 text-amber-500",
)}
onClick={openStatus}
data-testid="mobile-home-status-button"
aria-label={issueDetails?.description}
data-connection-severity={issueSeverity === "none" ? undefined : issueSeverity}
>
<IconActivity className={mobileControlIconClass} />
Status
{issueDetails && (
<span
className={cn("ml-auto size-2 rounded-full", issueDetails.dotClass)}
aria-hidden="true"
/>
)}
</Button>
)}
<Button
@@ -15,6 +15,8 @@ import { IntegrationCopyConfigMenu } from "@/components/integrations/integration
import { integrationFromPathname } from "@/components/integrations/integration-copy-config";
import { safeDecodePathSegment } from "@/lib/routing/path";
import { SettingsSaveProvider } from "@/components/settings/settings-save-provider";
import { connectionIssueDetails } from "@/components/app-status-bar/connection-status-item";
import { cn } from "@/lib/utils";
// Brand/initialism overrides so the derived label matches how the rest of the
// app spells these (e.g. "github" → "GitHub", not "Github"). Anything not
@@ -143,7 +145,8 @@ function IntegrationCopyConfigAction() {
function SettingsMobileMenu({ pathname }: { pathname: string }) {
const [open, setOpen] = useState(false);
const { enabled: statusDrawerEnabled, openStatusDrawer } = useAppStatusDrawer();
const { enabled: statusDrawerEnabled, issueSeverity, openStatusDrawer } = useAppStatusDrawer();
const issueDetails = issueSeverity === "none" ? null : connectionIssueDetails(issueSeverity);
const closeOnLinkClick = (event: MouseEvent<HTMLDivElement>) => {
if (event.target instanceof Element && event.target.closest("a[href]")) {
@@ -162,11 +165,27 @@ function SettingsMobileMenu({ pathname }: { pathname: string }) {
type="button"
variant="ghost"
size="icon"
className="h-11 w-11 cursor-pointer md:hidden"
aria-label="Open settings menu"
className={cn(
"relative h-11 w-11 cursor-pointer md:hidden",
issueSeverity === "lost" && "text-destructive",
issueSeverity === "unstable" && "text-amber-500",
)}
aria-label={
issueDetails ? `${issueDetails.description} Open settings menu` : "Open settings menu"
}
data-testid="settings-mobile-menu-button"
data-connection-severity={issueSeverity === "none" ? undefined : issueSeverity}
>
<IconMenu2 className="h-4 w-4" />
{issueDetails && (
<span
className={cn(
"absolute right-2 top-2 size-2 rounded-full ring-2 ring-background",
issueDetails.dotClass,
)}
aria-hidden="true"
/>
)}
</Button>
</SheetTrigger>
<SheetContent
@@ -182,12 +201,24 @@ function SettingsMobileMenu({ pathname }: { pathname: string }) {
<Button
type="button"
variant="ghost"
className="h-11 cursor-pointer justify-start gap-2.5 px-2.5"
className={cn(
"relative h-11 cursor-pointer justify-start gap-2.5 px-2.5",
issueSeverity === "lost" && "text-destructive",
issueSeverity === "unstable" && "text-amber-500",
)}
onClick={openStatus}
data-testid="settings-mobile-status-button"
aria-label={issueDetails?.description}
data-connection-severity={issueSeverity === "none" ? undefined : issueSeverity}
>
<IconActivity className="h-4 w-4 shrink-0" />
<span>Status</span>
{issueDetails && (
<span
className={cn("ml-auto size-2 rounded-full", issueDetails.dotClass)}
aria-hidden="true"
/>
)}
</Button>
)}
<Link
@@ -54,6 +54,23 @@ describe("SessionMobileBottomNav", () => {
expect(onPanelChange).not.toHaveBeenCalled();
});
it("describes an active connectivity warning on the Status action", () => {
render(
<SessionMobileBottomNav
activePanel="chat"
onPanelChange={vi.fn()}
showStatus
onOpenStatus={vi.fn()}
connectionIssueSeverity="lost"
/>,
);
const status = screen.getByRole("button", {
name: "Connection lost for at least 10 seconds. Live updates may be stale.",
});
expect(status.getAttribute("data-connection-severity")).toBe("lost");
});
it("does not reserve navigation space when Status is disabled", () => {
render(
<SessionMobileBottomNav
@@ -13,6 +13,8 @@ import {
import { cn } from "@/lib/utils";
import { Badge } from "@kandev/ui/badge";
import type { MobileSessionPanel } from "@/lib/state/slices/ui/types";
import type { ConnectionIssueSeverity } from "@/lib/types/connection";
import { connectionIssueDetails } from "@/components/app-status-bar/connection-status-item";
type SessionMobileBottomNavProps = {
activePanel: MobileSessionPanel;
@@ -22,12 +24,14 @@ type SessionMobileBottomNavProps = {
hasReview?: boolean;
showStatus: boolean;
onOpenStatus: () => void;
connectionIssueSeverity?: ConnectionIssueSeverity;
};
type NavItem = {
label: string;
icon: React.ReactNode;
badge?: React.ReactNode;
connectionIssueSeverity?: Exclude<ConnectionIssueSeverity, "none">;
} & ({ panel: MobileSessionPanel; onClick?: never } | { panel?: never; onClick: () => void });
export function SessionMobileBottomNav({
@@ -38,6 +42,7 @@ export function SessionMobileBottomNav({
hasReview = false,
showStatus,
onOpenStatus,
connectionIssueSeverity = "none",
}: SessionMobileBottomNavProps) {
const items: NavItem[] = useMemo(
() => [
@@ -93,11 +98,12 @@ export function SessionMobileBottomNav({
label: "Status",
icon: <IconActivity className="h-5 w-5" />,
onClick: onOpenStatus,
...(connectionIssueSeverity !== "none" && { connectionIssueSeverity }),
},
]
: []),
],
[planBadge, changesBadge, hasReview, showStatus, onOpenStatus],
[planBadge, changesBadge, hasReview, showStatus, onOpenStatus, connectionIssueSeverity],
);
return (
@@ -106,24 +112,68 @@ export function SessionMobileBottomNav({
style={{ paddingBottom: "env(safe-area-inset-bottom, 0px)" }}
>
{items.map((item) => (
<button
<MobileNavButton
key={item.label}
type="button"
onClick={item.onClick ?? (() => onPanelChange(item.panel))}
className={cn(
"flex min-h-11 flex-col items-center justify-center py-2 px-3 gap-0.5 min-w-0 flex-1 transition-colors",
activePanel === item.panel
? "text-primary"
: "text-muted-foreground hover:text-foreground",
)}
>
<span className="relative">
{item.icon}
{item.badge}
</span>
<span className="text-[10px] font-medium truncate">{item.label}</span>
</button>
item={item}
activePanel={activePanel}
onPanelChange={onPanelChange}
/>
))}
</nav>
);
}
function MobileNavButton({
item,
activePanel,
onPanelChange,
}: {
item: NavItem;
activePanel: MobileSessionPanel;
onPanelChange: (panel: MobileSessionPanel) => void;
}) {
const issueDetails = item.connectionIssueSeverity
? connectionIssueDetails(item.connectionIssueSeverity)
: null;
return (
<button
type="button"
onClick={item.onClick ?? (() => onPanelChange(item.panel))}
className={cn(
"flex min-h-11 min-w-0 flex-1 flex-col items-center justify-center gap-0.5 px-3 py-2 transition-colors",
mobileNavColorClass(item, activePanel, issueDetails !== null),
)}
aria-label={issueDetails?.description}
data-connection-severity={item.connectionIssueSeverity}
>
<span className="relative">
{item.icon}
{item.badge}
{issueDetails && (
<span
className={cn(
"absolute -right-1 -top-1 size-2 rounded-full ring-2 ring-background",
issueDetails.dotClass,
)}
aria-hidden="true"
/>
)}
</span>
<span className="text-[10px] font-medium truncate">{item.label}</span>
</button>
);
}
function mobileNavColorClass(
item: NavItem,
activePanel: MobileSessionPanel,
hasConnectionIssue: boolean,
) {
if (hasConnectionIssue) {
return item.connectionIssueSeverity === "lost" ? "text-destructive" : "text-amber-500";
}
return activePanel === item.panel
? "text-primary"
: "text-muted-foreground hover:text-foreground";
}
@@ -429,6 +429,7 @@ type SessionMobileFooterProps = {
hasReview: boolean;
showStatus: boolean;
onOpenStatus: () => void;
connectionIssueSeverity: import("@/lib/types/connection").ConnectionIssueSeverity;
};
function SessionMobileFooter({
@@ -440,6 +441,7 @@ function SessionMobileFooter({
hasReview,
showStatus,
onOpenStatus,
connectionIssueSeverity,
}: SessionMobileFooterProps) {
return (
<>
@@ -456,15 +458,29 @@ function SessionMobileFooter({
hasReview={hasReview}
showStatus={showStatus}
onOpenStatus={onOpenStatus}
connectionIssueSeverity={connectionIssueSeverity}
/>
</>
);
}
function StatusAwareSessionMobileFooter(
props: Omit<SessionMobileFooterProps, "showStatus" | "onOpenStatus" | "connectionIssueSeverity">,
) {
const { enabled, issueSeverity, openStatusDrawer } = useAppStatusDrawer();
return (
<SessionMobileFooter
{...props}
showStatus={enabled}
onOpenStatus={openStatusDrawer}
connectionIssueSeverity={issueSeverity}
/>
);
}
export const SessionMobileLayout = memo(function SessionMobileLayout(
props: SessionMobileLayoutProps,
) {
const { enabled: statusDrawerEnabled, openStatusDrawer } = useAppStatusDrawer();
const {
activeTaskId,
effectiveSessionId,
@@ -514,7 +530,6 @@ export const SessionMobileLayout = memo(function SessionMobileLayout(
showApproveButton={showApproveButton}
onApprove={handleApprove}
/>
<MobilePanelArea
currentMobilePanel={effectiveMobilePanel}
activeTaskId={activeTaskId}
@@ -537,15 +552,13 @@ export const SessionMobileLayout = memo(function SessionMobileLayout(
onSelectReviewPR={mobilePR.selectPR}
/>
<SessionMobileFooter
<StatusAwareSessionMobileFooter
sessionId={effectiveSessionId ?? null}
activePanel={effectiveMobilePanel}
onPanelChange={handleMobilePanelChange}
planBadge={hasUnseenPlanUpdate}
changesBadge={totalChangesCount}
hasReview={reviewSource !== null}
showStatus={statusDrawerEnabled}
onOpenStatus={openStatusDrawer}
/>
{/* Task Switcher Sheet */}
@@ -1,8 +1,10 @@
import { expect, test } from "../../fixtures/test-base";
import type { Page } from "@playwright/test";
test.describe("App status bar", () => {
test("uses one 24px in-flow footer across sidebar and route content", async ({ testPage }) => {
await testPage.goto("/");
await setConnectionIssueSeverity(testPage, "unstable");
const bar = testPage.getByTestId("app-status-bar");
await expect(bar).toBeVisible();
@@ -40,6 +42,7 @@ test.describe("App status bar", () => {
test("persists a modifier-mouse move across the spacer", async ({ testPage }) => {
await testPage.goto("/");
await setConnectionIssueSeverity(testPage, "unstable");
const bar = testPage.getByTestId("app-status-bar");
const connection = bar.locator('[data-status-item-id="builtin:connection"]');
const [sourceBox, barBox] = await Promise.all([connection.boundingBox(), bar.boundingBox()]);
@@ -69,3 +72,17 @@ test.describe("App status bar", () => {
).toHaveAttribute("data-status-side", "right");
});
});
async function setConnectionIssueSeverity(page: Page, severity: "none" | "unstable" | "lost") {
await page.evaluate((nextSeverity) => {
const store = (
window as Window & {
__KANDEV_E2E_STORE__?: {
getState: () => { setConnectionIssueSeverity: (severity: typeof nextSeverity) => void };
};
}
).__KANDEV_E2E_STORE__;
if (!store) throw new Error("E2E store bridge missing");
store.getState().setConnectionIssueSeverity(nextSeverity);
}, severity);
}
@@ -0,0 +1,83 @@
import type { Page } from "@playwright/test";
import { expect, test } from "../../fixtures/test-base";
test.describe("Mobile WebSocket connectivity warning", () => {
test("opens a touch-sized connection-only Status drawer while the feature is disabled", async ({
testPage,
prCapture,
}) => {
await testPage.goto("/stats");
await setAppStatusBarEnabled(testPage, false);
await setConnectionIssueSeverity(testPage, "lost");
await expect(testPage.getByTestId("app-status-bar")).toHaveCount(0);
const trigger = testPage.getByTestId("app-status-drawer-trigger");
await expect(trigger).toHaveAttribute(
"aria-label",
"Connection lost for at least 10 seconds. Live updates may be stale.",
);
await expect(trigger).toHaveAttribute("data-connection-severity", "lost");
expect((await trigger.boundingBox())?.height).toBeGreaterThanOrEqual(44);
await trigger.click();
const drawer = testPage.getByTestId("app-status-drawer");
await expect(drawer).toBeVisible();
await expect(drawer.locator("[data-status-item-id]")).toHaveCount(1);
await expect(drawer.getByTestId("app-status-connection")).toHaveAttribute(
"aria-label",
"Connection lost for at least 10 seconds. Live updates may be stale.",
);
await prCapture.screenshot("mobile-warning-drawer", {
caption: "Mobile connection-only WebSocket warning drawer",
});
expect(await testPage.evaluate(() => document.documentElement.scrollWidth)).toBe(
await testPage.evaluate(() => document.documentElement.clientWidth),
);
await testPage.keyboard.press("Escape");
await expect(drawer).toBeHidden();
await expect(trigger).toBeFocused();
await setConnectionIssueSeverity(testPage, "none");
await expect(trigger).toHaveCount(0);
});
test("keeps the connection-only trigger visible on a coarse-pointer tablet", async ({
testPage,
}) => {
await testPage.setViewportSize({ width: 900, height: 900 });
await testPage.goto("/stats");
await setAppStatusBarEnabled(testPage, false);
await setConnectionIssueSeverity(testPage, "unstable");
const trigger = testPage.getByTestId("app-status-drawer-trigger");
await expect(trigger).toBeVisible();
await trigger.tap();
await expect(testPage.getByTestId("app-status-drawer")).toBeVisible();
});
});
type E2EStore = {
getState: () => {
features: Record<string, boolean>;
setFeatures: (features: Record<string, boolean>) => void;
setConnectionIssueSeverity: (severity: "none" | "unstable" | "lost") => void;
};
};
async function setConnectionIssueSeverity(page: Page, severity: "none" | "unstable" | "lost") {
await page.evaluate((nextSeverity) => {
const store = (window as Window & { __KANDEV_E2E_STORE__?: E2EStore }).__KANDEV_E2E_STORE__;
if (!store) throw new Error("E2E store bridge missing");
store.getState().setConnectionIssueSeverity(nextSeverity);
}, severity);
}
async function setAppStatusBarEnabled(page: Page, enabled: boolean) {
await page.evaluate((nextEnabled) => {
const store = (window as Window & { __KANDEV_E2E_STORE__?: E2EStore }).__KANDEV_E2E_STORE__;
if (!store) throw new Error("E2E store bridge missing");
const state = store.getState();
state.setFeatures({ ...state.features, appStatusBar: nextEnabled });
}, enabled);
}
@@ -0,0 +1,96 @@
import type { Page } from "@playwright/test";
import { expect, test } from "../../fixtures/test-base";
test.describe("WebSocket connectivity warning", () => {
test("shows one bottom-bar warning while the app status bar is enabled", async ({
testPage,
prCapture,
}) => {
await testPage.goto("/");
await setConnectionIssueSeverity(testPage, "unstable");
const warning = testPage.getByTestId("app-status-connection");
await expect(warning).toHaveAttribute(
"aria-label",
"Connection unstable. Reconnecting to Kandev.",
);
await expect(warning).toHaveAttribute("data-connection-severity", "unstable");
await expect(testPage.getByTestId("sidebar-connection-warning")).toHaveCount(0);
await prCapture.screenshot("desktop-warning", {
caption: "Desktop yellow WebSocket connectivity warning",
});
await setConnectionIssueSeverity(testPage, "lost");
await expect(warning).toHaveAttribute(
"aria-label",
"Connection lost for at least 10 seconds. Live updates may be stale.",
);
await setConnectionIssueSeverity(testPage, "none");
await expect(warning).toHaveCount(0);
});
test("uses the sidebar fallback when the app status bar is disabled", async ({ testPage }) => {
await testPage.goto("/");
await setAppStatusBarEnabled(testPage, false);
await setConnectionIssueSeverity(testPage, "unstable");
await expect(testPage.getByTestId("app-status-bar")).toHaveCount(0);
const warning = testPage.getByTestId("sidebar-connection-warning");
await expect(warning).toHaveAttribute(
"aria-label",
"Connection unstable. Reconnecting to Kandev.",
);
await expect(warning).toHaveAttribute("data-connection-severity", "unstable");
await warning.focus();
await expect(testPage.getByRole("tooltip")).toContainText("Connection unstable");
await setConnectionIssueSeverity(testPage, "none");
await expect(warning).toHaveCount(0);
});
test("keeps a connection-only warning reachable on tablet widths", async ({ testPage }) => {
await testPage.setViewportSize({ width: 700, height: 900 });
await testPage.goto("/stats");
await setAppStatusBarEnabled(testPage, false);
await setConnectionIssueSeverity(testPage, "unstable");
await expect(testPage.getByTestId("app-status-bar")).toHaveCount(0);
const trigger = testPage.getByTestId("app-status-drawer-trigger");
await expect(trigger).toBeVisible();
await expect(trigger).toHaveAttribute(
"aria-label",
"Connection unstable. Reconnecting to Kandev.",
);
await trigger.click();
const drawer = testPage.getByTestId("app-status-drawer");
await expect(drawer).toBeVisible();
await expect(drawer.locator("[data-status-item-id]")).toHaveCount(1);
});
});
type E2EStore = {
getState: () => {
features: Record<string, boolean>;
setFeatures: (features: Record<string, boolean>) => void;
setConnectionIssueSeverity: (severity: "none" | "unstable" | "lost") => void;
};
};
async function setConnectionIssueSeverity(page: Page, severity: "none" | "unstable" | "lost") {
await page.evaluate((nextSeverity) => {
const store = (window as Window & { __KANDEV_E2E_STORE__?: E2EStore }).__KANDEV_E2E_STORE__;
if (!store) throw new Error("E2E store bridge missing");
store.getState().setConnectionIssueSeverity(nextSeverity);
}, severity);
}
async function setAppStatusBarEnabled(page: Page, enabled: boolean) {
await page.evaluate((nextEnabled) => {
const store = (window as Window & { __KANDEV_E2E_STORE__?: E2EStore }).__KANDEV_E2E_STORE__;
if (!store) throw new Error("E2E store bridge missing");
const state = store.getState();
state.setFeatures({ ...state.features, appStatusBar: nextEnabled });
}, enabled);
}
+3 -1
View File
@@ -1,4 +1,4 @@
import type { ConnectionStatus } from "@/lib/types/connection";
import type { ConnectionIssueSeverity, ConnectionStatus } from "@/lib/types/connection";
import type { HealthCheckSummary, HealthIssue, SystemHealthResponse } from "@/lib/types/health";
import type { StateSnapshot } from "react-virtuoso";
import type {
@@ -34,6 +34,7 @@ export type DiffState = {
export type ConnectionState = {
status: ConnectionStatus;
error: string | null;
issueSeverity: ConnectionIssueSeverity;
};
export type MobileKanbanState = {
@@ -213,6 +214,7 @@ export type UISliceActions = {
setPreviewUrlDraft: (sessionId: string, url: string) => void;
setRightPanelActiveTab: (sessionId: string, tab: string) => void;
setConnectionStatus: (status: ConnectionState["status"], error?: string | null) => void;
setConnectionIssueSeverity: (severity: ConnectionIssueSeverity) => void;
setMobileKanbanColumnIndex: (index: number) => void;
setMobileKanbanMenuOpen: (open: boolean) => void;
setMobileKanbanSearchOpen: (open: boolean) => void;
+5 -1
View File
@@ -80,7 +80,7 @@ export const defaultUIState: UISliceState = {
},
rightPanel: { activeTabBySessionId: {} },
diffs: { files: [] },
connection: { status: "disconnected", error: null },
connection: { status: "disconnected", error: null, issueSeverity: "none" },
mobileKanban: { activeColumnIndex: 0, isMenuOpen: false, isSearchOpen: false },
mobileSession: {
activePanelBySessionId: {},
@@ -456,6 +456,10 @@ export const createUISlice: StateCreator<UISlice, [["zustand/immer", never]], []
draft.connection.status = status;
draft.connection.error = error ?? null;
}),
setConnectionIssueSeverity: (severity) =>
set((draft) => {
draft.connection.issueSeverity = severity;
}),
setPlanMode: (sessionId, enabled) =>
set((draft) => {
draft.chatInput.planModeBySessionId[sessionId] = enabled;
+6
View File
@@ -59,4 +59,10 @@ describe("createAppStore", () => {
"task-2": "acme/widget/2",
});
});
it("starts with no connection issue severity", () => {
const store = createAppStore();
expect((store.getState().connection as { issueSeverity?: string }).issueSeverity).toBe("none");
});
});
+3
View File
@@ -317,6 +317,9 @@ export type AppState = KanbanSlice & {
setPreviewUrlDraft: (sessionId: string, url: string) => void;
setRightPanelActiveTab: (sessionId: string, tab: string) => void;
setConnectionStatus: (status: ConnectionState["status"], error?: string | null) => void;
setConnectionIssueSeverity: (
severity: import("@/lib/types/connection").ConnectionIssueSeverity,
) => void;
setMobileKanbanColumnIndex: (index: number) => void;
setMobileKanbanMenuOpen: (open: boolean) => void;
setMobileKanbanSearchOpen: (open: boolean) => void;
+2
View File
@@ -13,3 +13,5 @@ export type ConnectionStatus =
| "connected"
| "error"
| "reconnecting";
export type ConnectionIssueSeverity = "none" | "unstable" | "lost";
@@ -0,0 +1,70 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { ConnectionIssueMonitor } from "./connection-issue-monitor";
describe("ConnectionIssueMonitor", () => {
afterEach(() => vi.useRealTimers());
it("waits three seconds before reporting an unstable connection", () => {
vi.useFakeTimers();
const onChange = vi.fn();
const monitor = new ConnectionIssueMonitor(onChange);
monitor.onStatusChange("connecting");
vi.advanceTimersByTime(2_999);
expect(onChange).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(onChange).toHaveBeenCalledWith("unstable");
});
it("keeps one outage clock across reconnect lifecycle transitions", () => {
vi.useFakeTimers();
const onChange = vi.fn();
const monitor = new ConnectionIssueMonitor(onChange);
monitor.onStatusChange("reconnecting");
vi.advanceTimersByTime(3_000);
monitor.onStatusChange("connecting");
monitor.onStatusChange("error");
vi.advanceTimersByTime(7_000);
expect(onChange).toHaveBeenNthCalledWith(1, "unstable");
expect(onChange).toHaveBeenNthCalledWith(2, "lost");
});
it("clears immediately on connection and starts a later outage fresh", () => {
vi.useFakeTimers();
const onChange = vi.fn();
const monitor = new ConnectionIssueMonitor(onChange);
monitor.onStatusChange("disconnected");
vi.advanceTimersByTime(3_000);
monitor.onStatusChange("connected");
monitor.onStatusChange("reconnecting");
vi.advanceTimersByTime(2_999);
expect(onChange).toHaveBeenNthCalledWith(1, "unstable");
expect(onChange).toHaveBeenNthCalledWith(2, "none");
expect(onChange).toHaveBeenCalledTimes(2);
vi.advanceTimersByTime(1);
expect(onChange).toHaveBeenNthCalledWith(3, "unstable");
expect(onChange).toHaveBeenCalledTimes(3);
});
it("clears scheduled timers and ignores later status changes after disposal", () => {
vi.useFakeTimers();
const onChange = vi.fn();
const monitor = new ConnectionIssueMonitor(onChange);
monitor.onStatusChange("reconnecting");
monitor.dispose();
monitor.onStatusChange("reconnecting");
vi.advanceTimersByTime(10_000);
expect(onChange).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,55 @@
import type { ConnectionIssueSeverity, ConnectionStatus } from "@/lib/types/connection";
const UNSTABLE_DELAY_MS = 3_000;
const LOST_DELAY_MS = 10_000;
export class ConnectionIssueMonitor {
private disposed = false;
private offline = false;
private severity: ConnectionIssueSeverity = "none";
private unstableTimer: ReturnType<typeof setTimeout> | null = null;
private lostTimer: ReturnType<typeof setTimeout> | null = null;
constructor(private onSeverityChange: (severity: ConnectionIssueSeverity) => void) {}
onStatusChange(status: ConnectionStatus) {
if (this.disposed) {
return;
}
if (status === "connected") {
this.reset();
return;
}
if (this.offline) return;
this.offline = true;
this.unstableTimer = setTimeout(() => this.reportSeverity("unstable"), UNSTABLE_DELAY_MS);
this.lostTimer = setTimeout(() => this.reportSeverity("lost"), LOST_DELAY_MS);
}
dispose() {
if (this.disposed) return;
this.reset();
this.disposed = true;
}
private reset() {
this.offline = false;
this.clearTimers();
if (this.severity !== "none") this.reportSeverity("none");
}
private reportSeverity(severity: ConnectionIssueSeverity) {
if (this.disposed) return;
this.severity = severity;
this.onSeverityChange(severity);
}
private clearTimers() {
if (this.unstableTimer) clearTimeout(this.unstableTimer);
if (this.lostTimer) clearTimeout(this.lostTimer);
this.unstableTimer = null;
this.lostTimer = null;
}
}
+71
View File
@@ -0,0 +1,71 @@
import { act, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ConnectionStatus } from "@/lib/types/connection";
import { createAppStore } from "@/lib/state/store";
import { useWebSocket } from "./use-websocket";
const mocks = vi.hoisted(() => ({
onStatusChange: null as ((status: ConnectionStatus) => void) | null,
}));
vi.mock("@/lib/ws/client", () => ({
WebSocketClient: class {
constructor(_url: string, onStatusChange: (status: ConnectionStatus) => void) {
mocks.onStatusChange = onStatusChange;
}
connect = vi.fn();
disconnect = vi.fn();
subscribeUser = vi.fn();
on = vi.fn(() => vi.fn());
},
}));
vi.mock("@/lib/ws/router", () => ({ registerWsHandlers: vi.fn(() => ({})) }));
vi.mock("@/lib/ws/connection", () => ({ setWebSocketClient: vi.fn() }));
vi.mock("@/lib/debug/log", () => ({ createDebugLogger: () => vi.fn() }));
describe("useWebSocket", () => {
afterEach(() => {
mocks.onStatusChange = null;
vi.useRealTimers();
});
it("reflects a sustained WebSocket outage in the UI store and clears on reconnect", () => {
vi.useFakeTimers();
const store = createAppStore();
const { unmount } = renderHook(() => useWebSocket(store, "ws://kandev.test/ws"));
act(() => {
mocks.onStatusChange?.("reconnecting");
vi.advanceTimersByTime(3_000);
});
expect(store.getState().connection.issueSeverity).toBe("unstable");
act(() => mocks.onStatusChange?.("connected"));
expect(store.getState().connection.issueSeverity).toBe("none");
unmount();
});
it("clears an active outage when replacing the WebSocket URL", () => {
vi.useFakeTimers();
const store = createAppStore();
const { rerender, unmount } = renderHook(
({ url }: { url: string }) => useWebSocket(store, url),
{ initialProps: { url: "ws://kandev.test/first" } },
);
act(() => {
mocks.onStatusChange?.("reconnecting");
vi.advanceTimersByTime(3_000);
});
expect(store.getState().connection.issueSeverity).toBe("unstable");
rerender({ url: "ws://kandev.test/replacement" });
expect(store.getState().connection.issueSeverity).toBe("none");
unmount();
});
});
+6
View File
@@ -7,6 +7,7 @@ import { registerWsHandlers } from "@/lib/ws/router";
import type { AppState } from "@/lib/state/store";
import { setWebSocketClient } from "@/lib/ws/connection";
import { createDebugLogger } from "@/lib/debug/log";
import { ConnectionIssueMonitor } from "@/lib/ws/connection-issue-monitor";
const debug = createDebugLogger("ws:connection");
@@ -15,9 +16,13 @@ export function useWebSocket(store: StoreApi<AppState>, url: string) {
useEffect(() => {
debug("WS hook mounting", { url });
const connectionIssueMonitor = new ConnectionIssueMonitor((severity) => {
store.getState().setConnectionIssueSeverity(severity);
});
const client = new WebSocketClient(
url,
(status) => {
connectionIssueMonitor.onStatusChange(status);
const setConnectionStatus = store.getState().setConnectionStatus;
debug("status transition", { status, timestamp: new Date().toISOString() });
// WS client and ConnectionState share one ConnectionStatus vocabulary,
@@ -47,6 +52,7 @@ export function useWebSocket(store: StoreApi<AppState>, url: string) {
return () => {
unsubscribers.forEach((unsubscribe) => unsubscribe());
connectionIssueMonitor.dispose();
client.disconnect();
setWebSocketClient(null);
};
+199
View File
@@ -0,0 +1,199 @@
---
spec: docs/specs/ui/ws-connectivity-warning.md
created: 2026-07-30
status: complete
---
# Implementation Plan: WebSocket Connectivity Warning
## Overview
Derive one time-based issue severity beside the existing WebSocket lifecycle, then project it into
the existing App status bar, the desktop/sidebar fallback, and native phone Status paths. The
timing owner lands first so every surface shares the same continuous outage clock. Desktop and
mobile presentation tasks follow, then focused production-build E2E and public documentation.
No backend, schema, HTTP, or WebSocket protocol change is required.
---
## Backend
None. The feature consumes the existing frontend `ConnectionStatus` transitions and leaves the
backend gateway, retry policy, and subscription protocol unchanged.
---
## Frontend
### Canonical issue timing
- Add `ConnectionIssueSeverity = "none" | "unstable" | "lost"` in
`apps/web/lib/types/connection.ts`.
- Extend the transient `ConnectionState` in
`apps/web/lib/state/slices/ui/types.ts`,
`apps/web/lib/state/slices/ui/ui-slice.ts`, and `apps/web/lib/state/store.ts` with the current
issue severity and an action that updates it. The default is `none`; hydration remains
non-persistent.
- Add a small timer owner in `apps/web/lib/ws/connection-issue-monitor.ts`. It treats every
non-`connected` status as one continuous interval, emits `unstable` at 3,000 ms and `lost` at
10,000 ms, clears immediately on `connected`, and cancels both timers on disposal.
- Wire that owner into the single `apps/web/lib/ws/use-websocket.tsx` lifecycle. Raw connection
status/error updates, retry options, and subscription behavior remain unchanged.
### Desktop and tablet presentation
- Refine `apps/web/components/app-status-bar/connection-status-item.tsx` so the bar presentation
returns no content for `none`, renders the exact yellow/red copy for an active issue, and keeps
complete healthy/raw connection detail in the manually opened phone drawer.
- Reuse the same semantic detail mapping in a focusable sidebar warning indicator rather than
duplicating color/copy decisions.
- Update `apps/web/components/app-sidebar/app-sidebar-footer.tsx` to render that indicator
immediately after `ThemeToggle` only when `features.appStatusBar` is off and issue severity is
active. The sidebar remains the persistent tablet/desktop fallback in both expanded and collapsed
layouts.
- Preserve `builtin:connection` ordering. Its desktop wrapper remains present in the saved order but
collapses through the existing `empty:hidden` behavior while no warning content renders.
### Phone presentation
- Extend `AppStatusDrawerContextValue` in
`apps/web/components/app-status-bar/app-status-surface-provider.tsx` with issue severity and
whether the drawer is an issue-only fallback.
- On phone, enable Status access when either `features.appStatusBar` is on or an issue is active.
When only the issue activates it, mount `apps/web/components/app-status-bar/app-status-drawer.tsx`
in connection-only mode; do not mount metrics or plugin contributions.
- Apply shared yellow/red styling and accessible warning text to
`AppStatusDrawerTrigger`. Existing Page and Office top-bar triggers inherit it without route-local
state logic.
- Propagate the same severity to custom native paths:
`apps/web/components/kanban/kanban-header-mobile.tsx`,
`apps/web/components/kanban/mobile-menu-sheet.tsx`,
`apps/web/components/settings/settings-layout-client.tsx`,
`apps/web/components/task/mobile/session-mobile-layout.tsx`, and
`apps/web/components/task/mobile/session-mobile-bottom-nav.tsx`.
- Home/Settings menu triggers show the warning before their nested Status row is opened. Task
bottom navigation and direct Page/Office top-bar Status controls show it directly.
### Mobile design contract
- **Desktop outcome / phone entry:** desktop uses the enabled bottom status item or sidebar-footer
fallback; phone uses the route's existing top-bar, bottom-nav, or menu Status path.
- **Nearest exemplar:** the shipped `AppStatusDrawer` supplies the inset bottom drawer, fixed
summary, safe-area clearance, 44 px rows, single internal scroll owner, dismissal, and focus
return.
- **Hierarchy:** persistent chrome communicates severity; opening Status reveals the exact
connection explanation. No healthy-state phone chrome is added when the general status feature is
off.
- **Presentation:** an inset drawer fits one short, temporary diagnostic better than a new route or
full-height surface.
- **Shared logic:** the Zustand issue severity and detail mapper are common; phone code only selects
and styles touch presentation.
- **Geometry:** existing `h-dvh` shell, drawer `100dvh` bounds, safe-area padding, and 44 px native
controls remain authoritative. No additional document scroll owner is introduced.
---
## Tests
- **Continuous interval thresholds and reset**
- **File:** `apps/web/lib/ws/connection-issue-monitor.test.ts`
- **How:** Vitest fake timers prove no signal before 3 seconds, `unstable` at 3 seconds, `lost`
at 10 seconds, raw non-connected transitions do not reset time, `connected` clears immediately,
a later outage starts fresh, and dispose suppresses pending emissions.
- **Store and lifecycle wiring**
- **Files:** `apps/web/lib/state/store.test.ts` and, if needed for the lifecycle boundary,
`apps/web/lib/ws/use-websocket.test.tsx`
- **How:** focused unit tests prove the derived severity reaches the canonical store without
changing raw `ConnectionStatus` forwarding.
- **Connection details and desktop fallback**
- **Files:** `apps/web/components/app-status-bar/connection-status-item.test.tsx`,
`apps/web/components/app-sidebar/app-sidebar-footer.test.tsx`, and
`apps/web/components/app-status-bar/app-status-bar.test.tsx`
- **How:** component tests cover exact accessible copy/color, healthy bar collapse, enabled-bar
exclusivity, feature-off sidebar fallback, and collapsed-sidebar focus/tooltip access.
- **Phone visibility gate and native controls**
- **Files:** `apps/web/components/app-status-bar/app-status-surface-provider.test.tsx`,
`apps/web/components/app-status-bar/app-status-drawer.test.tsx`,
`apps/web/components/kanban/kanban-header-mobile.test.tsx`,
`apps/web/components/settings/settings-layout-client.test.tsx`, and
`apps/web/components/task/mobile/session-mobile-bottom-nav.test.tsx`
- **How:** component tests cover feature-on ordinary access, feature-off healthy absence,
feature-off warning access, connection-only drawer contents, severity styling, 44 px controls,
and unchanged actions/focus behavior.
There is no handler/service/repository integration path to test because the feature is frontend-only.
The production-build Playwright scenarios below provide the cross-component integration proof.
---
## E2E Tests
- **Scenario:** App status bar enabled on desktop; setting issue severity shows exactly one bottom
yellow/red connection item and clearing it removes the item content.
- **File:** `apps/web/e2e/tests/layout/ws-connectivity-warning.spec.ts`
- **What to verify:** semantic copy, yellow/red treatment, no sidebar duplicate, and recovery.
- **Scenario:** App status bar disabled on desktop/tablet; an issue shows the sidebar-footer
fallback beside the theme control and no bottom bar.
- **File:** `apps/web/e2e/tests/layout/ws-connectivity-warning.spec.ts`
- **What to verify:** feature-off placement, hover/focus detail, severity transition, and recovery.
- **Scenario:** App status bar disabled on Pixel 5; an issue marks persistent route chrome, exposes a
44 px Status path, and opens a connection-only drawer.
- **File:** `apps/web/e2e/tests/layout/mobile-ws-connectivity-warning.spec.ts`
- **What to verify:** warning is discoverable before nested navigation, only connection detail
renders, viewport containment, focus return, safe-area/internal scrolling, no horizontal
overflow, and recovery hides the issue-only path.
The tests may use the existing E2E store bridge to inject derived severity; unit tests own the
3/10-second clock so Playwright does not add ten-second sleeps or alter WebSocket accounting.
---
## Public documentation
- Amend `docs/public/operations.md` to explain the 3-second yellow / 10-second red warning,
its placement with App status bar on or off, and that red means live data may be stale.
- Amend the `features.app_status_bar` row in `docs/public/configuration.md` so disabling the general
status surface does not imply that urgent connectivity warnings disappear.
- No new page or navigation entry is needed.
---
## Implementation Waves And Parallel Candidates
Execution remains sequential in the primary conversation.
Wave 1:
- [x] [task-01-connection-issue-timing](task-01-connection-issue-timing.md)
Wave 2:
- [x] [task-02-desktop-warning-surfaces](task-02-desktop-warning-surfaces.md)
Wave 3:
- [x] [task-03-mobile-warning-surfaces](task-03-mobile-warning-surfaces.md)
Wave 4:
- [x] [task-04-connectivity-warning-e2e](task-04-connectivity-warning-e2e.md)
- [x] [task-05-public-documentation](task-05-public-documentation.md)
Tasks 02 and 03 both consume shared status components/provider contracts, so they are not marked
parallel-safe. Task 04 depends on both rendered paths. Task 05 is logically independent after the
spec but stays sequential to keep documented copy aligned with the final UI.
---
## Risks
- Raw `error → disconnected → reconnecting → connecting` churn can accidentally reset a
component-local timer; the timer must live once beside `useWebSocket`.
- A feature-off issue-only phone drawer must filter before rendering contributions, or plugin
effects and metrics subscriptions could run despite the toggle.
- Home and Settings hide Status inside a menu, so their persistent menu triggers must carry the
warning; styling only the nested row would fail discoverability.
- Existing E2E status-bar ordering assumes a visible healthy connection dot. Its geometry/order
assertions must be updated to inject an issue where connection-item visibility is material,
without changing saved identity semantics.
@@ -0,0 +1,75 @@
---
id: "01-connection-issue-timing"
title: "Connection issue timing"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/ui/ws-connectivity-warning.md"
---
# Task 01: Connection issue timing
## Acceptance
- One transient `ConnectionIssueSeverity` in the canonical frontend store remains `none` before
3,000 ms offline, becomes `unstable` at 3,000 ms, and becomes `lost` at 10,000 ms.
- Switching among non-`connected` raw statuses preserves the original interval; `connected` clears
severity immediately and starts the next outage from a fresh grace period.
- The single `useWebSocket` lifecycle owns and disposes the timers without changing retry,
subscription, queue, or raw status/error behavior.
## Verification
```bash
cd apps && pnpm --filter @kandev/web test -- lib/ws/connection-issue-monitor.test.ts lib/ws/use-websocket.test.tsx lib/state/store.test.ts
cd apps/web && pnpm run typecheck
```
## Completion report
- Timing/state behavior: the monitor preserves one outage clock, reports `unstable` at 3,000 ms
and `lost` at 10,000 ms, clears a visible warning on reconnect or lifecycle disposal, and cancels
pre-threshold timers without emitting a redundant state change.
- Focused tests: passed — 3 test files and 11 tests.
- Typecheck: passed — `tsc --noEmit` completed with no errors.
- Blockers: none.
- Remaining risk: browser timer throttling can delay a warning, but cannot shorten either grace
period or leave a surfaced warning behind after monitor replacement.
## Files likely touched
- `apps/web/lib/types/connection.ts`
- `apps/web/lib/state/slices/ui/types.ts`
- `apps/web/lib/state/slices/ui/ui-slice.ts`
- `apps/web/lib/state/store.ts`
- `apps/web/lib/ws/connection-issue-monitor.ts`
- `apps/web/lib/ws/connection-issue-monitor.test.ts`
- `apps/web/lib/ws/use-websocket.tsx`
- `apps/web/lib/ws/use-websocket.test.tsx` if direct lifecycle wiring needs a focused boundary test
- `apps/web/lib/state/store.test.ts`
## Dependencies
None.
## Parallelism
Sequential. Every later surface consumes this shared state contract.
## Inputs
- Spec: `What`, `State machine`, `Failure modes`, and the first five scenarios.
- Plan: `Frontend > Canonical issue timing`.
- Existing patterns: `apps/web/lib/ws/use-websocket.tsx`,
`apps/web/lib/state/slices/ui/ui-slice.ts`.
## Risks
- React remounts or retry status churn must not create multiple clocks.
- Cleanup order must prevent an intentional unmount disconnect from starting a new timer.
## Output contract
Report the timing/state behavior, files changed, exact commands and results, blockers/risks, then
mark this task `done` and update its checkbox in `plan.md`.
@@ -0,0 +1,62 @@
---
id: "02-desktop-warning-surfaces"
title: "Desktop warning surfaces"
status: done
wave: 2
depends_on: ["01-connection-issue-timing"]
plan: "plan.md"
spec: "../../specs/ui/ws-connectivity-warning.md"
---
# Task 02: Desktop warning surfaces
## Acceptance
- With App status bar enabled, its connection item has no healthy/grace content, shows the exact
yellow/red accessible details during an issue, and keeps its stable saved item identity.
- With App status bar disabled, one focusable indicator appears immediately after the sidebar theme
control only during an issue, in both expanded and collapsed sidebar layouts.
- The two desktop/tablet surfaces are mutually exclusive, expose details on hover and keyboard
focus, and clear immediately on recovery.
## Verification
```bash
cd apps && pnpm --filter @kandev/web test -- components/app-status-bar/connection-status-item.test.tsx components/app-status-bar/app-status-bar.test.tsx components/app-sidebar/app-sidebar-footer.test.tsx
cd apps/web && pnpm run typecheck
```
## Files likely touched
- `apps/web/components/app-status-bar/connection-status-item.tsx`
- `apps/web/components/app-status-bar/connection-status-item.test.tsx`
- `apps/web/components/app-status-bar/app-status-items.tsx`
- `apps/web/components/app-status-bar/app-status-bar.test.tsx`
- `apps/web/components/app-sidebar/app-sidebar-footer.tsx`
- `apps/web/components/app-sidebar/app-sidebar-footer.test.tsx`
## Dependencies
Task 01.
## Parallelism
Sequential. Shared connection detail/presentation contracts are also consumed by Task 03.
## Inputs
- Spec: desktop/tablet bullets, accessibility requirements, and desktop scenarios.
- Plan: `Frontend > Desktop and tablet presentation`.
- Existing patterns: `ConnectionStatusItem`, `FooterIconButton`, and the app-status item
`empty:hidden` wrapper.
## Risks
- Removing the healthy visual must not remove `builtin:connection` from saved ordering.
- A non-action warning must not be implemented as a misleading button; use semantic status/focus
behavior with the existing tooltip pattern.
## Output contract
Report surface behavior, files changed, exact commands and results, blockers/risks, then mark this
task `done` and update its checkbox in `plan.md`.
@@ -0,0 +1,73 @@
---
id: "03-mobile-warning-surfaces"
title: "Mobile warning surfaces"
status: done
wave: 3
depends_on: ["01-connection-issue-timing", "02-desktop-warning-surfaces"]
plan: "plan.md"
spec: "../../specs/ui/ws-connectivity-warning.md"
---
# Task 03: Mobile warning surfaces
## Acceptance
- Phone Status access remains ordinary when App status bar is enabled; when it is disabled, it
appears only for an active issue and opens a connection-only drawer with no metrics or plugin
contributions.
- Direct Page/Office Status controls, task bottom navigation, and Home/Settings persistent menu
triggers plus nested Status rows communicate yellow/red severity without relying on color alone.
- Existing 44 px touch targets, inset drawer containment, one internal scroll owner, safe-area
clearance, dismissal, focus return, and no-horizontal-overflow behavior remain intact.
## Verification
```bash
cd apps && pnpm --filter @kandev/web test -- components/app-status-bar/app-status-surface-provider.test.tsx components/app-status-bar/app-status-drawer.test.tsx components/kanban/kanban-header-mobile.test.tsx components/settings/settings-layout-client.test.tsx components/task/mobile/session-mobile-bottom-nav.test.tsx
cd apps/web && pnpm run typecheck
```
## Files likely touched
- `apps/web/components/app-status-bar/app-status-surface-provider.tsx`
- `apps/web/components/app-status-bar/app-status-surface-provider.test.tsx`
- `apps/web/components/app-status-bar/app-status-drawer.tsx`
- `apps/web/components/app-status-bar/app-status-drawer.test.tsx`
- `apps/web/components/kanban/kanban-header-mobile.tsx`
- `apps/web/components/kanban/kanban-header-mobile.test.tsx`
- `apps/web/components/kanban/mobile-menu-sheet.tsx`
- `apps/web/components/settings/settings-layout-client.tsx`
- `apps/web/components/settings/settings-layout-client.test.tsx`
- `apps/web/components/task/mobile/session-mobile-layout.tsx`
- `apps/web/components/task/mobile/session-mobile-bottom-nav.tsx`
- `apps/web/components/task/mobile/session-mobile-bottom-nav.test.tsx`
## Dependencies
Tasks 01 and 02.
## Parallelism
Sequential. It extends the shared details from Task 02 and the provider contract used by all mobile
callers.
## Inputs
- Spec: `Responsive and mobile contract` and phone scenarios.
- Plan: `Frontend > Phone presentation` and `Mobile design contract`.
- Existing exemplar:
`apps/web/components/app-status-bar/app-status-drawer.tsx`; existing responsive routing in
`AppStatusSurfaceProvider`.
## Risks
- Filtering after item render could still mount disabled metrics/plugin effects; connection-only
selection must happen before contributions render.
- Home and Settings require warning treatment on their persistent menu triggers, not only on nested
Status rows.
## Output contract
Report mobile composition, files changed, exact commands and results, rendered verification
evidence or blocker, blockers/risks, then mark this task `done` and update its checkbox in
`plan.md`.
@@ -0,0 +1,63 @@
---
id: "04-connectivity-warning-e2e"
title: "Connectivity warning E2E"
status: done
wave: 4
depends_on:
["01-connection-issue-timing", "02-desktop-warning-surfaces", "03-mobile-warning-surfaces"]
plan: "plan.md"
spec: "../../specs/ui/ws-connectivity-warning.md"
---
# Task 04: Connectivity warning E2E
## Acceptance
- Production-build desktop E2E proves enabled-bar placement, feature-off sidebar fallback,
yellow/red details, mutual exclusion, and immediate recovery.
- Pixel 5 E2E proves the feature-off warning is visible from persistent route chrome, reaches a
44 px Status action, opens a connection-only drawer, returns focus, and introduces no viewport or
horizontal-overflow regression.
- Existing status-bar ordering/geometry E2E is updated only where the now-hidden healthy connection
content is material; stable connection identity and saved ordering remain covered.
## Verification
```bash
cd apps/web && pnpm e2e:run tests/layout/ws-connectivity-warning.spec.ts tests/layout/mobile-ws-connectivity-warning.spec.ts
```
## Files likely touched
- `apps/web/e2e/tests/layout/ws-connectivity-warning.spec.ts`
- `apps/web/e2e/tests/layout/mobile-ws-connectivity-warning.spec.ts`
- `apps/web/e2e/helpers/connection-state.ts` if a typed store-bridge helper keeps tests focused
- `apps/web/e2e/tests/layout/app-status-bar.spec.ts`
- Any existing page object that already owns the affected Status trigger, only if reuse is clearer
than local selectors
## Dependencies
Tasks 01–03.
## Parallelism
Sequential. The E2E contract spans both rendered implementations.
## Inputs
- Every spec scenario involving a visible surface.
- Plan: `E2E Tests`.
- E2E guidance: production managed runner, store bridge for derived-state injection, stable
`data-testid` selectors, and `mobile-chrome` routing through the `mobile-*.spec.ts` filename.
## Risks
- Do not add real ten-second sleeps; unit tests own timing and E2E owns integration/presentation.
- A backend restart used to disable the feature must restore baseline configuration in teardown.
- Rebuild production assets through `pnpm e2e:run`; do not test a stale Vite bundle.
## Output contract
Report scenarios, screenshots/rendered evidence where captured, exact command and result,
blockers/risks, then mark this task `done` and update its checkbox in `plan.md`.
@@ -0,0 +1,65 @@
---
id: "05-public-documentation"
title: "Public documentation"
status: done
wave: 4
depends_on: ["02-desktop-warning-surfaces", "03-mobile-warning-surfaces"]
plan: "plan.md"
spec: "../../specs/ui/ws-connectivity-warning.md"
---
# Task 05: Public documentation
## Acceptance
- Operations docs explain the 3-second yellow and 10-second red states, where the warning appears
with App status bar enabled/disabled, and that red means live data may be stale.
- Configuration docs keep `features.app_status_bar` accurate: it controls the general-purpose
surface but not urgent connectivity fallback visibility.
- Public-doc validation passes with no new page or navigation entry.
## Verification
```bash
node --test scripts/validate-public-docs.test.mjs
node scripts/validate-public-docs.mjs
```
## Completion report
- Public docs: updated the operations and configuration guidance for the 3-second yellow state,
10-second red state, stale-data warning, and urgent fallback when the general status bar is off.
- Validator tests: passed — 58 tests.
- Published-doc validation: passed — 41 pages validated.
- Blockers: none.
- Remaining risk: none beyond keeping the documented thresholds and feature-toggle behavior in sync
with future connectivity UI changes.
## Files likely touched
- `docs/public/operations.md`
- `docs/public/configuration.md`
## Dependencies
Tasks 02 and 03.
## Parallelism
Sequential so terminology matches the landed UI.
## Inputs
- Spec: `What`, `Failure modes`, and `Out of scope`.
- Plan: `Public documentation`.
- Existing Feature toggles and troubleshooting sections in `docs/public/operations.md`.
## Risks
- Do not imply that `features.app_status_bar=false` mounts plugins or metrics during an outage.
- Keep the warning distinct from `/health`, System Status, executor health, and integration health.
## Output contract
Report public docs changed, exact validation results, blockers/risks, then mark this task `done` and
update its checkbox in `plan.md`.
+8 -2
View File
@@ -116,7 +116,10 @@ The launcher starts `agentctl`, performs a one-time nonce handshake, and supplie
| `office.jwtSigningKey` | `KANDEV_OFFICE_JWTSIGNINGKEY` | random per start | HMAC key for Office agent-runtime JWTs. Set a stable secret when Office tasks must survive restarts. |
| `voice.openAIApiKey` | `KANDEV_VOICE_OPENAI_API_KEY` | empty | Server-side transcription fallback when browser speech recognition is unavailable. Empty disables the fallback and its endpoint returns unavailable. |
| `features.office` | `KANDEV_FEATURES_OFFICE` | `false` in production | Experimental Office UI, routes, services, and automation. |
| `features.app_status_bar` | `KANDEV_FEATURES_APP_STATUS_BAR` | `false` in production | Opt-in global connection/host-metrics/plugin surface: bottom bar on tablet/desktop and Status drawer on phones. |
| `features.plugins` | `KANDEV_FEATURES_PLUGINS` | `false` in production | Extensible plugin system: install/manage plugins, spawn plugin backends, and load native UI bundles. Loaded plugin code runs with backend privileges. |
| `features.app_status_bar` | `KANDEV_FEATURES_APP_STATUS_BAR` | `false` in production | Opt-in global connection/host-metrics/plugin surface: bottom bar on tablet/desktop and Status drawer on phones. Disabling it does not suppress active WebSocket connectivity warnings. |
| `features.plugins` | `KANDEV_FEATURES_PLUGINS` | `false` in production | Extensible plugin system: install/manage plugins, spawn plugin backends, and load native UI bundles. Loaded plugin code runs with backend privileges. |
| `features.app_status_bar` | `KANDEV_FEATURES_APP_STATUS_BAR` | `false` in production | Opt-in global connection/host-metrics/plugin surface: bottom bar on tablet/desktop and Status drawer on phones. Disabling it does not suppress active WebSocket connectivity warnings. |
| `features.claude_background_prompt_handoff` | `KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF` | `false` | High-risk experiment that lets Claude Code accept a new prompt after its foreground yields while adapter-attested background work remains active. Other providers keep the coarse busy gate. |
Do not infer security from `auth.jwtSecret`: setting it currently does not turn the local server into an authenticated public service. Office's JWT key has a narrower, active purpose. Store both active secrets and third-party API keys in your deployment secret manager; never commit them in `config.yaml`.
@@ -292,7 +295,10 @@ Copying this entire file is unnecessary and can freeze old defaults in a deploym
| Key | Environment lock | Production default | Effect |
|---|---|---|---|
| `features.office` | `KANDEV_FEATURES_OFFICE` | off | Experimental autonomous-agent Office surfaces and automation. |
| `features.appStatusBar` | `KANDEV_FEATURES_APP_STATUS_BAR` | off | Global status bar on tablet/desktop and Status drawer entry on phones. Enabling changes visibility only. |
| `features.plugins` | `KANDEV_FEATURES_PLUGINS` | off | Extensible plugin system: install/manage plugins and load their backends and native UI bundles. |
| `features.appStatusBar` | `KANDEV_FEATURES_APP_STATUS_BAR` | off | Global status bar on tablet/desktop and Status drawer entry on phones. Enabling changes visibility only; urgent WebSocket connectivity warnings remain visible when it is off. |
| `features.plugins` | `KANDEV_FEATURES_PLUGINS` | off | Extensible plugin system: install/manage plugins and load their backends and native UI bundles. |
| `features.appStatusBar` | `KANDEV_FEATURES_APP_STATUS_BAR` | off | Global status bar on tablet/desktop and Status drawer entry on phones. Enabling changes visibility only; urgent WebSocket connectivity warnings remain visible when it is off. |
| `features.claudeBackgroundPromptHandoff` | `KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF` | off | High-risk Claude Code experiment that exposes recognized background-only activity and admits a successor prompt. |
| `debug.devMode` | `KANDEV_DEBUG_DEV_MODE` (also locked by explicit legacy/debug-message vars) | off | High-risk diagnostic endpoints and ACP frame logging. |
+5 -1
View File
@@ -274,12 +274,16 @@ Collection starts only while at least one connected client displays metrics in t
These metrics are lightweight UI observability. Set alerts, retention, CPU/memory limits, and disk quotas in the host, container platform, or external monitoring stack.
## WebSocket connectivity warnings
Kandev warns when its live WebSocket connection has not recovered for three seconds: yellow means the connection is unstable and reconnecting; after ten seconds the warning turns red, meaning live updates may be stale. The warning clears as soon as the connection recovers. With **App status bar** enabled, it appears in the bottom bar on desktop/tablet and through the existing Status controls on phones. When that feature is disabled, an active warning still appears beside the sidebar theme control on desktop/tablet and opens a connection-only Status drawer on phones.
## Feature toggles
**Settings > System > Feature Toggles** currently exposes:
- **Office mode** — experimental, medium risk, and off in the production profile by default.
- **App status bar** — stable, low risk, and off in the production profile by default. Enabling it adds the desktop/tablet bar and phone Status entry after restart; disabling it again does not stop connections, metrics collection requested by other clients, or plugins.
- **App status bar** — stable, low risk, and off in the production profile by default. Enabling it adds the desktop/tablet bar and phone Status entry after restart; disabling it again does not stop connections, metrics collection requested by other clients, or plugins. Urgent WebSocket connectivity warnings still remain visible while the feature is off.
- **Claude background prompt handoff** — experimental, high risk, and off in every profile by default. Enabling it lets Claude Code accept another prompt after its foreground yields while recognized async subagent, `run_in_background` shell, or Monitor work remains active. ACP lifecycle gaps can misclassify activity or overlap prompts; use it only for controlled testing.
- **Debug mode** — high risk; enables diagnostic endpoints and agent-message logging that can contain sensitive content.
+2 -1
View File
@@ -133,7 +133,7 @@ Subscription quota tracking and per-agent cheap-model profile routing.
| [mermaid-rendering](ui/mermaid-rendering.md) | shipped |
| [settings-manual-save](ui/settings-manual-save.md) | shipped |
| [transcript-navigation-settings](ui/transcript-navigation-settings.md) | shipped |
| [app-status-bar](ui/app-status-bar.md) | draft |
| [app-status-bar](ui/app-status-bar.md) | approved |
| [mobile-task-navigation](ui/mobile-task-navigation.md) | shipped |
| [adaptive-kanban](ui/adaptive-kanban.md) | shipped |
| [task-layout-profiles](ui/task-layout-profiles.md) | draft |
@@ -145,6 +145,7 @@ Subscription quota tracking and per-agent cheap-model profile routing.
| [task-workspace-content-search](ui/task-workspace-content-search.md) | shipped |
| [embedded-vscode-executor-availability](ui/embedded-vscode-executor-availability.md) | approved |
| [embedded-vscode-windows-availability](ui/embedded-vscode-windows-availability.md) | archived; superseded by embedded-vscode-executor-availability |
| [ws-connectivity-warning](ui/ws-connectivity-warning.md) | approved |
## system-page/ — operational diagnostics & maintenance UI
+3 -2
View File
@@ -21,7 +21,7 @@ restart required for startup-gated features such as Office mode and Debug mode.
- The page shows user-facing runtime toggles, not every profile/env knob.
- V1 exposes three user-facing toggles:
- **Office mode** — enables the autonomous-agent Office surface.
- **App status bar** — shows the global desktop/tablet status bar and the phone Status drawer entry. It is stable and off by default in production; enabling it changes visibility only.
- **App status bar** — shows the general-purpose desktop/tablet status bar and phone Status drawer entry. It is stable and off by default in production; enabling it changes visibility only. Disabling it does not suppress the problem-only [WebSocket connectivity warning](../ui/ws-connectivity-warning.md).
- **Debug mode** — enables local diagnostic/debug behavior, including agent
message debug logs.
- Office mode is marked **Experimental** and includes a concise description of
@@ -262,7 +262,8 @@ especially Debug mode, to admins.
enabled after restart.
- **GIVEN** App status bar is using its default, **WHEN** the user opens Feature
Toggles, **THEN** it appears off and explains that enabling it adds both the
desktop/tablet bar and phone Status entry after restart.
desktop/tablet bar and ordinary phone Status entry after restart, while urgent
WebSocket connectivity warnings remain available when it is off.
- **GIVEN** a toggle override exists, **WHEN** the user clicks `Reset to default`,
**THEN** the override is removed and the page shows restart-required state if
the effective runtime value differs from the restored default.
+1 -1
View File
@@ -297,7 +297,7 @@ The kandev backend runs in a single-user model (`store.DefaultUserID = "default-
| Dependency / scenario | Observable behavior |
|---|---|
| **WS network drop** | Socket transitions `open → closed → reconnecting`. Pending request promises stay armed until `cleanupPendingRequests()` rejects them at the reconnect cap. Subscription maps are retained. On `open` the client flushes the buffered outbound queue, then re-issues every `subscribe` / `focus` / `user.subscribe` / `run.subscribe` frame from `resubscribe()`. **No replay** of missed notifications — surfaces refetch on next event or stay stale until then. |
| **Reconnect cap exceeded** | After `maxAttempts` (default 10) consecutive failures with exponential backoff capped at 30s, status moves to `error`, pending requests are rejected with `WebSocket connection closed`, and no further automatic reconnects occur. The topbar surfaces a connection indicator; the user must reload to recover. |
| **Reconnect cap exceeded** | After `maxAttempts` (default 10) consecutive failures with exponential backoff capped at 30s, status moves to `error`, pending requests are rejected with `WebSocket connection closed`, and no further automatic reconnects occur. The [WebSocket connectivity warning](../ui/ws-connectivity-warning.md) remains red; the user must reload to recover. |
| **Server send buffer full** | `client.send` is a 256-deep buffer. When full, `sendBytes` logs `Client send buffer full, dropping message` and the message is dropped for that client only. Other clients still receive it. No retry, no replay; consumer must reconcile on next event. |
| **Frontend handler throws** | The hub's frontend WS client invokes handlers in a `forEach`; an unhandled throw skips remaining handlers for that event but does not tear down the socket. |
| **Event bus publish during shutdown** | `MemoryEventBus.Publish` returns `event bus is closed` after `Close()`. The publisher (orchestrator / office service) logs and continues; the broadcast is dropped. WS clients see no notification for that event. |
+6 -5
View File
@@ -1,5 +1,5 @@
---
status: shipped
status: approved
created: 2026-07-21
owner: kandev
---
@@ -14,7 +14,7 @@ Kandev has useful app-wide state, but it is scattered through route headers. A s
- Desktop and tablet render one persistent **24 px**, in-flow bottom status bar across the app shell, including sidebar and route content. Desktop uses `full` density; tablet uses `compact` density.
- Phone renders no persistent second bottom bar. Native route controls open one global **Status** inset bottom drawer, so it does not collide with task bottom navigation. The drawer mirrors the saved bar order vertically (saved left sequence, then saved right sequence), has a fixed header, one internal scroll body, safe-area clearance, 44 px action rows, and returns focus to its trigger.
- The production-default-off `features.appStatusBar` runtime toggle controls both presentations. When disabled, neither the desktop/tablet bar nor phone Status entry points render. Enabling or disabling it requires a Kandev restart. It changes visibility only; it does not stop connections, metrics collection requested by other clients, or plugin execution.
- The production-default-off `features.appStatusBar` runtime toggle controls both general-purpose presentations. When disabled, neither the desktop/tablet bar nor ordinary phone Status entry points render. An active [WebSocket connectivity warning](ws-connectivity-warning.md) is the sole exception: it uses problem-only fallback chrome and a connection-only phone drawer without mounting the bar, metrics, or plugin contributions. Enabling or disabling the toggle requires a Kandev restart. It changes visibility only; it does not stop connections, metrics collection requested by other clients, or plugin execution.
- Built-ins are limited to Kandev-owned state:
- Canonical connection state and error from `state.connection.status` / `state.connection.error`, with a restrained semantic dot, the connected detail **Connected to Kandev**, accessible text, and readable failure detail.
- Existing Kandev-host CPU/memory metrics, preserving enabled-metric order, formatting, thresholds, limits, and tooltips. The built-in surface does not render active-task, active-session, or executor metrics.
@@ -31,7 +31,7 @@ Kandev has useful app-wide state, but it is scattered through route headers. A s
- `AppShell` owns viewport geometry: an `h-dvh` column with a `min-h-0 flex-1` sidebar/route row, followed by the status surface. Shell-owned route roots use parent height and explicit local overflow rather than adding a second viewport height.
- `--app-status-bar-height` is `1.5rem` on tablet/desktop and `0` on phone. It offsets only audited desktop `position: fixed` overlays; it is not global content padding. Phone bottom navigation and phone drop targets retain `bottom: 0`.
- Exactly one presentation mounts at once: bar on tablet/desktop, drawer contents only while the phone drawer is open. This prevents duplicate plugin effects and metrics subscriptions at breakpoints. Disabling `features.appStatusBar` mounts neither.
- Exactly one general-purpose presentation mounts at once: bar on tablet/desktop, drawer contents only while the phone drawer is open. This prevents duplicate plugin effects and metrics subscriptions at breakpoints. Disabling `features.appStatusBar` mounts neither general-purpose presentation; a connection-only warning drawer may mount under the exception above.
- Standard mobile headers, Home utility menu, task bottom navigation, Settings mobile menu, and Office topbar expose Status. A full-bleed plugin route (`topbar: false`) owns its chrome and must mount `AppStatusDrawerTrigger` if it wants status access.
## Plugin slots
@@ -84,7 +84,7 @@ The only public API addition is `registerComponent("app-status-bar-left" | "app-
- Before the first metrics snapshot arrives, the metrics item remains hidden while its normal WebSocket subscription stays active; loading is not presented as an availability failure and does not create a fallback fetch or provider.
- A received snapshot without the Kandev host source renders the recognizable unavailable state. Unavailable metric samples keep their existing per-metric degraded presentation and inspectable error detail.
- Connection errors remain inspectable through accessible detail; reconnecting is not misrepresented as connected.
- Connection errors remain inspectable through accessible detail; reconnecting is not misrepresented as connected. The bottom connection item follows the problem-only timing and severity contract in [WebSocket connectivity warning](ws-connectivity-warning.md).
- A failed plugin contribution is contained by its own boundary; remaining contributions and first-party state remain usable.
- If Status drawer closes during a metrics update or breakpoint changes, the inactive presentation unmounts and releases only its own ref-counted subscription.
- Invalid, duplicate, or temporarily unavailable saved item identities never mount duplicate UI. The host normalizes active items once and retains unavailable identities for later plugin re-enable.
@@ -112,7 +112,8 @@ Visual interaction is a clean Kandev adaptation of Orca's public status-bar idea
- **GIVEN** the simplified metrics preference, **WHEN** a phone user opens Status, **THEN** the metrics row shows the same icon-and-value presentation without a host marker or percentage meter bar.
- **GIVEN** metrics preference disabled, or a phone Status drawer closed, **WHEN** the app runs, **THEN** no system-metrics WebSocket subscription is held by this feature.
- **GIVEN** the feature is enabled for a phone user, **WHEN** they choose Status from a native entry point, **THEN** the drawer shows the same built-ins and plugin regions; dismissing it restores focus and leaves no persistent status bar.
- **GIVEN** an administrator disables **App status bar** in Feature Toggles and restarts Kandev, **WHEN** a route renders on any breakpoint, **THEN** the bar/drawer and native Status triggers are absent while the rest of the shell remains available.
- **GIVEN** an administrator disables **App status bar** in Feature Toggles and restarts Kandev, **WHEN** a route renders without a WebSocket warning, **THEN** the bar/drawer and native Status triggers are absent while the rest of the shell remains available.
- **GIVEN** App status bar is disabled, **WHEN** a WebSocket warning becomes active, **THEN** only the problem-only fallback defined by [WebSocket connectivity warning](ws-connectivity-warning.md) appears; metrics and plugin contributions remain unmounted.
- **GIVEN** a plugin registered for either status slot, **WHEN** it enables or disables, **THEN** its contribution appears or disappears without reload in the active presentation. A failed contribution does not suppress a following healthy one after registrations change.
- **GIVEN** a desktop/tablet bar, **WHEN** the user holds Cmd/Ctrl and mouse-drags a built-in or plugin contribution across another item or the spacer, **THEN** the item moves horizontally, normal click activation is suppressed only after a drag begins, and the new side/order survives reload and backend restart.
- **GIVEN** a modified mouse press begins on status text, **WHEN** the pointer crosses the drag threshold, **THEN** the item enters its dragging state without selecting text in the bar.
+117
View File
@@ -0,0 +1,117 @@
---
status: approved
created: 2026-07-30
owner: kandev
---
# WebSocket Connectivity Warning
## Why
Kandev can look current while its live WebSocket updates are delayed or unavailable. Users need a
quiet but unavoidable indication that task, session, and workspace state may be stale, without
showing permanent healthy-state chrome or flashing for harmless reconnects.
## What
- Kandev derives one transient connectivity severity from the continuous time since its canonical
application WebSocket last entered `connected`:
- **Healthy/grace:** no warning before 3 seconds offline.
- **Unstable:** yellow from 3 seconds offline until 10 seconds offline.
- **Lost:** red from 10 seconds offline until the connection recovers.
- Transitions among `connecting`, `reconnecting`, `disconnected`, and `error` belong to the same
continuous offline interval. They do not restart either threshold.
- Any `connected` transition clears the warning immediately. A later disconnect starts a new grace
interval.
- The yellow detail reads **Connection unstable. Reconnecting to Kandev.**
- The red detail reads **Connection lost for at least 10 seconds. Live updates may be stale.**
- Color is never the only signal. The active surface exposes the matching detail as an accessible
status name and through hover/focus on fine-pointer layouts or tap on phone.
- Desktop and tablet show exactly one warning:
- When **App status bar** is enabled, its built-in connection item is problem-only: absent while
healthy or in grace, yellow while unstable, and red while lost.
- When **App status bar** is disabled, a problem-only connection indicator appears in the persistent
app-sidebar footer beside the theme control. The fallback is absent while healthy or in grace.
- Phone uses its existing native Status paths rather than adding a persistent bar or overlay:
- Existing Status entry points receive yellow/red warning treatment.
- Route chrome that leads to a nested Status action, such as the Home or Settings menu, carries
the same warning treatment so the issue is visible before opening the menu.
- When **App status bar** is disabled, these paths appear only during an active warning and open a
connection-only Status drawer. Plugin contributions and metrics stay disabled.
- The warning does not alter reconnect attempts, WebSocket subscriptions, queued requests, or
last-known application data.
## State machine
| State | Entry | Visible result | Exit |
|---|---|---|---|
| `healthy` | WebSocket enters `connected` | No warning | First non-`connected` status |
| `grace` | A continuous offline interval starts | No warning | `connected`, or 3 seconds elapse |
| `unstable` | The same interval reaches 3 seconds | Yellow warning | `connected`, or 10 seconds elapse |
| `lost` | The same interval reaches 10 seconds | Red warning | `connected` |
The state is client-local and transient. A page reload starts a new connection attempt and grace
interval; no outage timestamp or warning history persists.
## Failure modes
- If the reconnect client exhausts its attempts, the warning remains red until a later
`connected` transition or page reload. It does not claim that retries are still running.
- If the optional App status surface is disabled, an active warning bypasses only its visibility
gate. It does not mount metrics, plugin status contributions, or a desktop/tablet status bar.
- If a responsive presentation changes during an outage, the current severity moves to the new
canonical surface without resetting its offline interval or rendering duplicate warnings.
- If the warning timer owner unmounts, it cancels pending thresholds and does not update stale
application state.
## Responsive and mobile contract
- Desktop outcome: one problem-only warning in the enabled bottom status bar or, when that bar is
disabled, beside the sidebar theme control.
- Phone entry points reuse the shipped Page/Office top-bar Status button, task bottom-nav Status
action, and Home/Settings menu paths. The persistent route control signals severity before the
nested Status action is opened.
- The existing App Status drawer is the nearest shipped mobile exemplar. It contributes the inset
bottom surface, fixed summary, one safe-area-aware internal scroll owner, 44 px rows, dismissal,
and focus return.
- A short connection explanation belongs in that temporary drawer; it does not justify a new route
or full-height surface.
- Connection timing and severity are shared across breakpoints. Mobile-specific code only chooses
presentation and touch entry points.
## Scenarios
- **GIVEN** a connected client, **WHEN** its WebSocket is offline for less than 3 seconds and
reconnects, **THEN** no connectivity warning appears.
- **GIVEN** a connected client, **WHEN** its WebSocket remains offline for 3 seconds, **THEN** one
yellow warning appears with **Connection unstable. Reconnecting to Kandev.**
- **GIVEN** the same outage, **WHEN** it reaches 10 seconds, **THEN** the existing warning becomes
red with **Connection lost for at least 10 seconds. Live updates may be stale.**
- **GIVEN** an unstable or lost warning, **WHEN** any reconnect attempt enters `connected`, **THEN**
the warning disappears immediately and a later outage receives a fresh grace interval.
- **GIVEN** reconnect attempts cycle through non-connected raw statuses, **WHEN** the continuous
outage crosses a threshold, **THEN** the threshold is measured from the original disconnect.
- **GIVEN** App status bar enabled on desktop or tablet, **WHEN** connection severity changes,
**THEN** only its built-in connection item reflects the warning and no sidebar fallback appears.
- **GIVEN** App status bar disabled on desktop or tablet, **WHEN** connection severity becomes
unstable or lost, **THEN** one warning indicator appears beside the sidebar theme control and exposes
the matching detail on hover and focus.
- **GIVEN** App status bar disabled on phone, **WHEN** connection severity becomes unstable or
lost, **THEN** persistent route chrome signals the issue and its Status path opens a
connection-only, touch-sized drawer without metrics or plugin contributions.
- **GIVEN** a phone warning, **WHEN** the user opens and dismisses Status, **THEN** the connection
detail is readable, the drawer stays within the viewport, and focus returns to the trigger.
- **GIVEN** an active warning, **WHEN** the viewport crosses between phone and wider layouts,
**THEN** the severity remains unchanged and exactly one canonical warning surface is visible.
## Out of scope
- Latency, packet-loss, heartbeat-quality, or REST-health sampling.
- Changing reconnect delays, retry limits, queueing, resubscription, or missed-event recovery.
- Manual reconnect actions, recovery toasts, healthy-state success messages, outage history, or
persisted incident timestamps.
- A new phone bar, toast, route, full-screen alert, or desktop top-bar injection.
## Implementation plan
[WebSocket connectivity warning plan](../../plans/ws-connectivity-warning/plan.md)