diff --git a/canvas/src/components/CommunicationOverlay.tsx b/canvas/src/components/CommunicationOverlay.tsx
index ee69531a..7b0c49c7 100644
--- a/canvas/src/components/CommunicationOverlay.tsx
+++ b/canvas/src/components/CommunicationOverlay.tsx
@@ -150,7 +150,7 @@ export function CommunicationOverlay() {
{c.targetName}
- {statusIcon}
+ {statusIcon}
{age}
diff --git a/canvas/src/components/__tests__/AuthGate.test.tsx b/canvas/src/components/__tests__/AuthGate.test.tsx
new file mode 100644
index 00000000..7f581769
--- /dev/null
+++ b/canvas/src/components/__tests__/AuthGate.test.tsx
@@ -0,0 +1,117 @@
+// @vitest-environment jsdom
+import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
+import { render, cleanup, act } from "@testing-library/react";
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+
+// ── Mocks (defined before dynamic import of component) ───────────────────────
+let mockFetchSession: ReturnType;
+let mockRedirectToLogin: ReturnType;
+let mockGetTenantSlug: ReturnType;
+
+beforeEach(() => {
+ mockFetchSession = vi.fn();
+ mockRedirectToLogin = vi.fn();
+ mockGetTenantSlug = vi.fn(() => null); // default: non-SaaS (pass-through)
+});
+
+vi.mock("@/lib/auth", () => ({
+ fetchSession: (...args: unknown[]) => mockFetchSession(...args),
+ redirectToLogin: (...args: unknown[]) => mockRedirectToLogin(...args),
+}));
+
+vi.mock("@/lib/tenant", () => ({
+ getTenantSlug: (...args: unknown[]) => mockGetTenantSlug(...args),
+}));
+
+// Import after mocks are set up
+import { AuthGate } from "../AuthGate";
+
+// ─────────────────────────────────────────────────────────────────────────────
+
+describe("AuthGate — loading state", () => {
+ it("renders a blank overlay while session fetch is in-flight (prevents flash of unauth'd content)", () => {
+ // getTenantSlug returns a slug so the session fetch is triggered
+ mockGetTenantSlug.mockReturnValue("acme");
+ // fetchSession never resolves — keeps the component in loading state
+ mockFetchSession.mockReturnValue(new Promise(() => {}));
+
+ const { container } = render(
+
+ Protected content
+
+ );
+
+ const overlay = container.querySelector(".bg-zinc-950.fixed.inset-0");
+ expect(overlay).not.toBeNull();
+ expect(overlay?.getAttribute("aria-hidden")).toBe("true");
+ });
+
+ it("does not render children while in loading state", () => {
+ mockGetTenantSlug.mockReturnValue("acme");
+ mockFetchSession.mockReturnValue(new Promise(() => {}));
+
+ const { queryByTestId } = render(
+
+ Protected content
+
+ );
+
+ expect(queryByTestId("child")).toBeNull();
+ });
+});
+
+describe("AuthGate — non-SaaS / pass-through mode", () => {
+ it("renders children immediately when there is no tenant slug", async () => {
+ mockGetTenantSlug.mockReturnValue(null);
+
+ let result: ReturnType;
+ await act(async () => {
+ result = render(
+
+ Protected content
+
+ );
+ });
+
+ expect(result!.getByTestId("child")).toBeTruthy();
+ });
+});
+
+describe("AuthGate — authenticated state", () => {
+ it("renders children after a successful session fetch", async () => {
+ mockGetTenantSlug.mockReturnValue("acme");
+ mockFetchSession.mockResolvedValue({ userId: "u1", email: "a@b.com" });
+
+ let result: ReturnType;
+ await act(async () => {
+ result = render(
+
+ Protected content
+
+ );
+ });
+
+ expect(result!.getByTestId("child")).toBeTruthy();
+ });
+});
+
+describe("AuthGate — anonymous / redirect state", () => {
+ it("calls redirectToLogin when session fetch returns null", async () => {
+ mockGetTenantSlug.mockReturnValue("acme");
+ mockFetchSession.mockResolvedValue(null);
+
+ await act(async () => {
+ render(
+
+ Protected content
+
+ );
+ });
+
+ expect(mockRedirectToLogin).toHaveBeenCalledWith("sign-in");
+ });
+});
diff --git a/canvas/src/components/tabs/ChatTab.tsx b/canvas/src/components/tabs/ChatTab.tsx
index 2c402833..f1b8bbb0 100644
--- a/canvas/src/components/tabs/ChatTab.tsx
+++ b/canvas/src/components/tabs/ChatTab.tsx
@@ -109,6 +109,7 @@ export function ChatTab({ workspaceId, data }: Props) {
}}
>