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();
+ }
+ });
+});