Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 22 additions & 19 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 34 additions & 3 deletions apps/web/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
31 changes: 31 additions & 0 deletions apps/web/src/pages/plugins-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
140 changes: 139 additions & 1 deletion apps/web/test/plugins-page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 (
<BenchContext.Provider value={value}>{children}</BenchContext.Provider>
);
}

container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<TestQueryProvider>
<NavigationProvider navigate={() => undefined}>
<BenchHarness>
<ProviderHealthProvider>
<PluginsRoute path="/plugins" navigate={() => undefined} />
</ProviderHealthProvider>
</BenchHarness>
</NavigationProvider>
</TestQueryProvider>,
);
});
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();
});
});
1 change: 1 addition & 0 deletions eslint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export default defineConfig(
"**/node_modules/**",
"**/dist/**",
".data/**",
"apps/hub/.data/**",
"coverage/**",
"tmp/**",
"vendor/**",
Expand Down
19 changes: 13 additions & 6 deletions packages/chat-ui/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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"] {
Expand Down
31 changes: 31 additions & 0 deletions packages/settings-ui/src/connections-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<EmptyState
Expand Down
Loading
Loading