diff --git a/canvas/src/components/CommunicationOverlay.tsx b/canvas/src/components/CommunicationOverlay.tsx index f61b662c..7b0c49c7 100644 --- a/canvas/src/components/CommunicationOverlay.tsx +++ b/canvas/src/components/CommunicationOverlay.tsx @@ -99,10 +99,10 @@ export function CommunicationOverlay() { return ( ); } @@ -111,13 +111,14 @@ export function CommunicationOverlay() {
- ↗↙ Communications ({comms.length}) + Communications ({comms.length})
@@ -141,15 +142,15 @@ export function CommunicationOverlay() { >
- {typeIcon} + {c.sourceName} - + {c.targetName}
- {statusIcon} + {age}
diff --git a/canvas/src/components/OnboardingWizard.tsx b/canvas/src/components/OnboardingWizard.tsx index 9a7d1b13..a0d18bca 100644 --- a/canvas/src/components/OnboardingWizard.tsx +++ b/canvas/src/components/OnboardingWizard.tsx @@ -138,7 +138,8 @@ export function OnboardingWizard() { diff --git a/canvas/src/components/SearchDialog.tsx b/canvas/src/components/SearchDialog.tsx index 02ced119..d6fe0f0f 100644 --- a/canvas/src/components/SearchDialog.tsx +++ b/canvas/src/components/SearchDialog.tsx @@ -112,7 +112,7 @@ export function SearchDialog() { onChange={(e) => setQuery(e.target.value)} onKeyDown={handleInputKeyDown} placeholder="Search workspaces..." - className="flex-1 bg-transparent text-sm text-zinc-100 placeholder-zinc-400 focus:outline-none" + className="flex-1 bg-transparent text-sm text-zinc-100 placeholder-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus:outline-none rounded" /> ESC
diff --git a/canvas/src/components/SidePanel.tsx b/canvas/src/components/SidePanel.tsx index 2580c396..6b0295fd 100644 --- a/canvas/src/components/SidePanel.tsx +++ b/canvas/src/components/SidePanel.tsx @@ -156,11 +156,14 @@ export function SidePanel() { - {/* Tabs */} + {/* Tabs — relative wrapper lets the fade gradient position against the scroll container */} +
+ {/* Right-edge fade: signals more tabs are hidden off-screen when the bar overflows */} + {/* Needs Restart Banner */} {node.data.needsRestart && !node.data.currentTask && selectedNodeId && ( 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 8d1cc3f8..f1b8bbb0 100644 --- a/canvas/src/components/tabs/ChatTab.tsx +++ b/canvas/src/components/tabs/ChatTab.tsx @@ -98,11 +98,22 @@ export function ChatTab({ workspaceId, data }: Props) { return (
{/* Sub-tab bar — role="tablist" so screen readers expose tab context */} -
+
{ + const tabs: ChatSubTab[] = ["my-chat", "agent-comms"]; + const idx = tabs.indexOf(subTab); + if (e.key === "ArrowRight") { e.preventDefault(); setSubTab(tabs[(idx + 1) % tabs.length]); } + else if (e.key === "ArrowLeft") { e.preventDefault(); setSubTab(tabs[(idx - 1 + tabs.length) % tabs.length]); } + }} + >
- {/* Content */} -
- {subTab === "my-chat" ? ( -
- -
- ) : ( -
- -
- )} + {/* Content — both panels are always in the DOM so aria-controls targets exist. + The inactive panel is hidden via the HTML `hidden` attribute (removed from + display and accessibility tree, but present in the DOM for WCAG 4.1.2). */} + +
); @@ -387,7 +397,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
Processing with {runtimeDisplayName(data.runtime)}...
{activityLog.map((line, i) => ( -
◇ {line}
+
◇ {line}
))}
)} diff --git a/canvas/src/styles/settings-panel.css b/canvas/src/styles/settings-panel.css index 48fd36bb..ce06d677 100644 --- a/canvas/src/styles/settings-panel.css +++ b/canvas/src/styles/settings-panel.css @@ -323,8 +323,8 @@ border-radius: 6px; font-size: 14px; font-family: inherit; - background: #18181b; - color: #d4d4d8; + background: rgb(39 39 42); + color: rgb(212 212 216); } .add-key-form__select:focus, @@ -398,8 +398,8 @@ border-radius: 6px; font-size: 14px; font-family: monospace; - background: #18181b; - color: #d4d4d8; + background: rgb(39 39 42); + color: rgb(212 212 216); } .key-value-field input:focus {