diff --git a/DESIGN.md b/DESIGN.md index f05119312..57b0aa16d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -179,25 +179,28 @@ alone. ## Motion Durations run 150–300ms; entrances ease out, never linear or bouncy-in. -Two named easings cover the system: - -- `spring` — `cubic-bezier(.2, .9, .3, 1.15)` — for things that pop into - place with a little overshoot. -- `out` — `cubic-bezier(.2, .8, .3, 1)` — for straightforward entrances and - exits with no overshoot. - -Something that grows or shrinks _in place_ — the search bar's morph, a rail -resizing — takes `--ease-in-out` instead: an overshoot there does not read as -liveliness, it drags every neighbour in the row along with it. This -supersedes the earlier reading of `spring` as the search morph's curve -(CL-6410 review); the curves themselves are react-ui's, and its `theme.css` -documents `--ease-in-out` as the morph curve. - -These are tokens on `@corbits/react-ui`'s theme, not Tailwind utilities the -product can name: the app imports react-ui's _prebuilt_ stylesheet, so a -`duration-standard` or `ease-spring` class compiles to nothing here. Product -motion is authored as a real `transition` declaration reading -`var(--duration-*)` / `var(--ease-*)`. +Three named easings cover the system (all sourced from `@corbits/react-ui`'s +`theme.css` — never re-declared locally): + +- `out` (`--ease-out`) — `cubic-bezier(.23, 1, .32, 1)` — straightforward + entrances and exits with no overshoot. The default for most motion. +- `spring` (`--ease-spring`) — `cubic-bezier(.2, .9, .3, 1.15)` — for things + that pop into place with a little overshoot (docks, popovers, toasts + arriving). +- `in-out` (`--ease-in-out`) — `cubic-bezier(.65, 0, .35, 1)` — for something + that grows or shrinks _in place_ — the search bar's morph, a rail resizing, + a composer height change — where overshoot would drag every neighbour in the + row along with it. This supersedes the earlier reading of `spring` as the + search morph's curve (CL-6410 review). + +Named durations are also react-ui tokens: `--duration-micro` (150ms) for a +hover/pressed state or icon swap, `--duration-standard` (200ms) for a toast or +dropdown, and `--duration-large` (300ms) for a dialog, drawer, or panel swap — +all declared on `:root` in `theme.css` and re-exposed as Tailwind's +`--transition-duration-*` utilities. Hand-written motion reads +`var(--duration-*)` / `var(--ease-*)` rather than Tailwind's +`duration-standard` / `ease-out` classes, since the app imports react-ui's +_prebuilt_ stylesheet where those utilities are already compiled. Motion always encodes a state change — something entering, something transforming, focus moving — never plain decoration. If removing an diff --git a/apps/web/src/app.css b/apps/web/src/app.css index f07767a1e..83428f4f3 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -636,9 +636,38 @@ select:disabled, } .chat-sidebar-row-menu-trigger { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.6rem; + height: 1.6rem; flex-shrink: 0; + border: 0; + border-radius: var(--ui-radius-sm, 0.25rem); + background: transparent; + color: var(--muted-foreground); opacity: 0; - transition: opacity 120ms; + transition: + opacity var(--duration-micro, 150ms) var(--ease-out, ease), + background var(--duration-micro, 150ms) var(--ease-out, ease), + color var(--duration-micro, 150ms) var(--ease-out, ease); +} + +.chat-sidebar-row-menu-trigger::after { + content: ""; + position: absolute; + inset: -8px; +} + +.chat-sidebar-row-menu-trigger:hover { + background: color-mix(in srgb, var(--foreground) 8%, transparent); + color: var(--foreground); +} + +.chat-sidebar-row-menu-trigger:focus-visible { + outline: 2px solid var(--ring, var(--primary)); + outline-offset: 2px; } .chat-sidebar-row:hover .chat-sidebar-row-menu-trigger, @@ -2289,9 +2318,11 @@ select:disabled, } /* Tasteful motion only: a soft entrance on phase change, no looping or - attention-seeking animation, fully off under reduced motion. */ + attention-seeking animation, fully off under reduced motion. + DESIGN.md's motion ceiling is 300ms — stays at 280ms. */ .onboarding-phase { - animation: onboarding-phase-in 0.32s ease both; + animation: onboarding-phase-in var(--duration-large, 300ms) + var(--ease-out, ease) both; } @keyframes onboarding-phase-in { diff --git a/apps/web/src/pages/plugins-page.tsx b/apps/web/src/pages/plugins-page.tsx index c7888eef5..8f2ba489f 100644 --- a/apps/web/src/pages/plugins-page.tsx +++ b/apps/web/src/pages/plugins-page.tsx @@ -145,6 +145,37 @@ export function PluginsRoute({ }; }, [selectedTenantId, pluginsReloadKey]); + // Keep plugin connection status live while the gallery sits open — + // a credential expiring or a disconnect in another tab/window would + // otherwise leave "Connected" stale indefinitely. Re-read on + // visibility/focus and poll every 30s while visible, mirroring the + // pattern `ConnectionsSection` and the `subscribeConnectState` containers + // use for in-room connect cards. + useEffect(() => { + if (selectedTenantId === null) return; + // `visibilitychange` and `focus` both fire in the same tick when a tab + // regains focus; the microtask guard collapses that pair into one + // scheduled bump instead of two back-to-back reloads. + let bumpScheduled = false; + const refreshWhenVisible = () => { + if (document.visibilityState !== "visible") return; + if (bumpScheduled) return; + bumpScheduled = true; + queueMicrotask(() => { + bumpScheduled = false; + setPluginsReloadKey((key) => key + 1); + }); + }; + document.addEventListener("visibilitychange", refreshWhenVisible); + window.addEventListener("focus", refreshWhenVisible); + const interval = setInterval(refreshWhenVisible, 30_000); + return () => { + document.removeEventListener("visibilitychange", refreshWhenVisible); + window.removeEventListener("focus", refreshWhenVisible); + clearInterval(interval); + }; + }, [selectedTenantId]); + useEffect(() => { if (selectedTenantId === null) return; let cancelled = false; diff --git a/apps/web/test/plugins-page.test.tsx b/apps/web/test/plugins-page.test.tsx index f5e90c072..759fa47a2 100644 --- a/apps/web/test/plugins-page.test.tsx +++ b/apps/web/test/plugins-page.test.tsx @@ -5,7 +5,7 @@ // `@corbits/plugins-ui`'s own tests — this proves the page composes real // data into that component correctly. -import { afterEach, describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, spyOn, test } from "bun:test"; import { act, useState } from "react"; import type { ReactNode } from "react"; import { createRoot } from "react-dom/client"; @@ -757,3 +757,141 @@ describe("PluginsRoute", () => { expect(window.location.search).toBe(""); }); }); + +// CL-6487: `plugins-page.tsx`'s visibility/focus refresh effect re-reads +// plugin status on `visibilitychange`/`focus` and every 30s while visible, +// gated on a selected tenant, with a microtask guard collapsing a same-tick +// visibilitychange+focus pair into a single reload. +describe("PluginsRoute refresh-on-visibility effect", () => { + test("becoming visible/focused in the same tick triggers exactly one reload, does nothing while tenantId is null, and cleans up its listeners/interval on unmount", async () => { + let resolveGithubCalls = 0; + globalThis.fetch = ((input: RequestInfo | URL) => { + const path = typeof input === "string" ? input : String(input); + if (path.includes("/mcp-servers/presets")) + return Promise.resolve(json({ data: [] })); + if (path.includes("/api/me/principals")) + return Promise.resolve(json(membership)); + if (path.includes("/api/workbench-tenancies/kinds")) + return Promise.resolve(json({ workbenchTenantIds: [] })); + if (path.includes("/credentials/resolve/GitHub")) { + resolveGithubCalls += 1; + return Promise.resolve(json(null, 404)); + } + if (path.includes("/credentials/resolve/")) + return Promise.resolve(json(null, 404)); + if (path.includes("/connections/provider-health")) + return Promise.resolve( + json({ providers: {}, connectedProviderCount: 0 }), + ); + if (path.includes("/api/tenants/tnt_1/skills")) + return Promise.resolve(json({ skills: [] })); + return Promise.resolve(json({ data: [], nextCursor: null })); + }) as typeof fetch; + + const documentAddSpy = spyOn(document, "addEventListener"); + const documentRemoveSpy = spyOn(document, "removeEventListener"); + const windowAddSpy = spyOn(window, "addEventListener"); + const windowRemoveSpy = spyOn(window, "removeEventListener"); + + // BenchProvider resolves `selectedTenantId` from the principals fetch + // asynchronously — while that's pending (and before mount() drains the + // microtask queue below) `selectedTenantId` is `null` and the effect's + // early return means no listener ever sees a bump go out for it. + await mount(); + + expect(resolveGithubCalls).toBeGreaterThan(0); + const callsAfterMount = resolveGithubCalls; + + const visibilityHandler = documentAddSpy.mock.calls.find( + (call) => call[0] === "visibilitychange", + )?.[1] as EventListener; + const focusHandler = windowAddSpy.mock.calls.find( + (call) => call[0] === "focus", + )?.[1] as EventListener; + expect(visibilityHandler).not.toBeUndefined(); + expect(focusHandler).not.toBeUndefined(); + + Object.defineProperty(document, "visibilityState", { + value: "visible", + configurable: true, + }); + await act(async () => { + visibilityHandler(new Event("visibilitychange")); + focusHandler(new Event("focus")); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + for (let i = 0; i < 5; i++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } + + expect(resolveGithubCalls).toBe(callsAfterMount + 1); + + act(() => root?.unmount()); + root = null; + + expect(documentRemoveSpy).toHaveBeenCalledWith( + "visibilitychange", + visibilityHandler, + ); + expect(windowRemoveSpy).toHaveBeenCalledWith("focus", focusHandler); + + documentAddSpy.mockRestore(); + documentRemoveSpy.mockRestore(); + windowAddSpy.mockRestore(); + windowRemoveSpy.mockRestore(); + }); + + test("registers no visibility/focus listener while tenantId is null", async () => { + stubFetch(); + + const documentAddSpy = spyOn(document, "addEventListener"); + const windowAddSpy = spyOn(window, "addEventListener"); + + function BenchHarness({ children }: { readonly children: ReactNode }) { + const value: BenchState = { + memberships: { kind: "loading" }, + selectedTenantId: null, + selectedPrincipalId: null, + selectTenant: () => undefined, + onBenchCreated: () => undefined, + }; + return ( + {children} + ); + } + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => { + root?.render( + + undefined}> + + + undefined} /> + + + + , + ); + }); + for (let i = 0; i < 10; i++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } + + expect( + documentAddSpy.mock.calls.some((call) => call[0] === "visibilitychange"), + ).toBe(false); + expect(windowAddSpy.mock.calls.some((call) => call[0] === "focus")).toBe( + false, + ); + + documentAddSpy.mockRestore(); + windowAddSpy.mockRestore(); + }); +}); diff --git a/eslint.config.ts b/eslint.config.ts index 017ce1dc0..7a6eea09e 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -12,6 +12,7 @@ export default defineConfig( "**/node_modules/**", "**/dist/**", ".data/**", + "apps/hub/.data/**", "coverage/**", "tmp/**", "vendor/**", diff --git a/packages/chat-ui/src/styles.css b/packages/chat-ui/src/styles.css index 805321355..a966bd7e0 100644 --- a/packages/chat-ui/src/styles.css +++ b/packages/chat-ui/src/styles.css @@ -2,13 +2,20 @@ shell's contextual panel. Layout glue only: visual styling comes from `@corbits/react-ui`'s prebuilt stylesheet and the theme's tokens. */ +/* Motion is gated centrally by react-ui's `theme.css` blanket + `prefers-reduced-motion` rule (`animation-duration: 0.01ms` etc.), + so every animation below may also be wrapped in a local + `@media (prefers-reduced-motion: no-preference)` guard or left to + that central override — either way it collapses when the user asks + for reduced motion. */ + :root { /* Deliberately not an invented curve: this aliases react-ui's own `--ease-out` (DESIGN.md's "out" — straightforward entrances/exits, no overshoot) so the chat surface never carries a third easing curve of its own. Falls back to the equivalent cubic-bezier only for the rare test/story context that doesn't load react-ui's theme stylesheet. */ - --chat-ease: var(--ease-out, cubic-bezier(0.2, 0.8, 0.3, 1)); + --chat-ease: var(--ease-out, cubic-bezier(0.23, 1, 0.32, 1)); } .chat-workspace { @@ -2214,13 +2221,13 @@ padding: 0.35rem 0.75rem 0.35rem 0.4rem; transition: transform var(--duration-standard, 180ms) - var(--ease-out, cubic-bezier(0.2, 0.8, 0.3, 1)), + var(--ease-out, cubic-bezier(0.23, 1, 0.32, 1)), background-color var(--duration-standard, 180ms) - var(--ease-out, cubic-bezier(0.2, 0.8, 0.3, 1)), + var(--ease-out, cubic-bezier(0.23, 1, 0.32, 1)), border-color var(--duration-standard, 180ms) - var(--ease-out, cubic-bezier(0.2, 0.8, 0.3, 1)), + var(--ease-out, cubic-bezier(0.23, 1, 0.32, 1)), color var(--duration-standard, 180ms) - var(--ease-out, cubic-bezier(0.2, 0.8, 0.3, 1)); + var(--ease-out, cubic-bezier(0.23, 1, 0.32, 1)); } .chat-tool-activity-trigger { @@ -2380,7 +2387,7 @@ font-size: 11px; color: var(--muted-foreground); transition: transform var(--duration-standard, 180ms) - var(--ease-out, cubic-bezier(0.2, 0.8, 0.3, 1)); + var(--ease-out, cubic-bezier(0.23, 1, 0.32, 1)); } .chat-tool-activity-caret[data-open="true"] { diff --git a/packages/settings-ui/src/connections-section.tsx b/packages/settings-ui/src/connections-section.tsx index 44db53aea..efe799633 100644 --- a/packages/settings-ui/src/connections-section.tsx +++ b/packages/settings-ui/src/connections-section.tsx @@ -281,6 +281,37 @@ export function ConnectionsSection({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [tenantId, reloadKey]); + // Connections can change elsewhere (another tab, the Plugins gallery's + // connect panel, or a credential expiring during a long agent run). + // Re-read on visibility/focus and poll while the page sits open so a + // "Connected" pill never lies silently — the same live-surface concern + // that `ConnectServiceBlockContainer` and `ConnectGithubBlockContainer` + // solve via `subscribeConnectState`. + useEffect(() => { + if (tenantId === null) return; + // `visibilitychange` and `focus` both fire in the same tick when a tab + // regains focus; the microtask guard collapses that pair into one + // scheduled bump instead of two back-to-back reloads. + let bumpScheduled = false; + const refreshWhenVisible = () => { + if (document.visibilityState !== "visible") return; + if (bumpScheduled) return; + bumpScheduled = true; + queueMicrotask(() => { + bumpScheduled = false; + setReloadKey((value) => value + 1); + }); + }; + document.addEventListener("visibilitychange", refreshWhenVisible); + window.addEventListener("focus", refreshWhenVisible); + const interval = setInterval(refreshWhenVisible, 30_000); + return () => { + document.removeEventListener("visibilitychange", refreshWhenVisible); + window.removeEventListener("focus", refreshWhenVisible); + clearInterval(interval); + }; + }, [tenantId]); + if (tenantId === null) { return ( { + globalThis.fetch = realFetch; +}); + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); + +const settle = () => + act(() => new Promise((resolve) => setTimeout(resolve, 10))); + +function renderSection(tenantId: string | null) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root: Root = createRoot(container); + act(() => { + root.render(); + }); + return { container, root }; +} + +function stubFetch(onCredentialsFetch: () => void): typeof fetch { + return (async (url: string) => { + if (url === "/api/tenants/ten_1/credentials") { + onCredentialsFetch(); + return json({ data: [], nextCursor: null }); + } + if (url === "/api/tenants/ten_1/providers") + return json({ data: [], nextCursor: null }); + if (url === "/api/tenants/ten_1/connections/oauth-configured") + return json({}); + if (url === "/api/tenants/ten_1/models") return json([]); + if (url === "/api/tenants/ten_1/catalog/offerings") + return json({ data: [], nextCursor: null }); + throw new Error(`unexpected fetch: ${url}`); + }) as unknown as typeof fetch; +} + +describe("ConnectionsSection refresh-on-visibility effect", () => { + test("becoming visible/focused in the same tick triggers exactly one reload, does nothing while tenantId is null, and cleans up its listeners/interval on unmount", async () => { + let credentialsCalls = 0; + globalThis.fetch = stubFetch(() => { + credentialsCalls += 1; + }); + + const documentAddSpy = spyOn(document, "addEventListener"); + const documentRemoveSpy = spyOn(document, "removeEventListener"); + const windowAddSpy = spyOn(window, "addEventListener"); + const windowRemoveSpy = spyOn(window, "removeEventListener"); + + const { container, root } = renderSection("ten_1"); + try { + await settle(); + expect(credentialsCalls).toBeGreaterThan(0); + const callsAfterMount = credentialsCalls; + + const visibilityHandler = documentAddSpy.mock.calls.find( + (call) => call[0] === "visibilitychange", + )?.[1] as EventListener; + const focusHandler = windowAddSpy.mock.calls.find( + (call) => call[0] === "focus", + )?.[1] as EventListener; + expect(visibilityHandler).not.toBeUndefined(); + expect(focusHandler).not.toBeUndefined(); + + Object.defineProperty(document, "visibilityState", { + value: "visible", + configurable: true, + }); + act(() => { + visibilityHandler(new Event("visibilitychange")); + focusHandler(new Event("focus")); + }); + await settle(); + + expect(credentialsCalls).toBe(callsAfterMount + 1); + + act(() => root.unmount()); + + expect(documentRemoveSpy).toHaveBeenCalledWith( + "visibilitychange", + visibilityHandler, + ); + expect(windowRemoveSpy).toHaveBeenCalledWith("focus", focusHandler); + } finally { + container.remove(); + documentAddSpy.mockRestore(); + documentRemoveSpy.mockRestore(); + windowAddSpy.mockRestore(); + windowRemoveSpy.mockRestore(); + } + }); + + test("registers no visibility/focus listener while tenantId is null", async () => { + globalThis.fetch = stubFetch(() => undefined); + + const documentAddSpy = spyOn(document, "addEventListener"); + const windowAddSpy = spyOn(window, "addEventListener"); + + const { container, root } = renderSection(null); + try { + await settle(); + + expect( + documentAddSpy.mock.calls.some( + (call) => call[0] === "visibilitychange", + ), + ).toBe(false); + expect(windowAddSpy.mock.calls.some((call) => call[0] === "focus")).toBe( + false, + ); + } finally { + act(() => root.unmount()); + container.remove(); + documentAddSpy.mockRestore(); + windowAddSpy.mockRestore(); + } + }); +});