feat(web): show WebSocket connectivity warnings (#2083)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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`.
|
||||
@@ -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. |
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user