diff --git a/apps/web/src/pages/legacy-settings-redirects.tsx b/apps/web/src/pages/legacy-settings-redirects.tsx
deleted file mode 100644
index 164ad4d53..000000000
--- a/apps/web/src/pages/legacy-settings-redirects.tsx
+++ /dev/null
@@ -1,80 +0,0 @@
-// Old links that must still land somewhere real. Agents and Skills were
-// Settings sections for a stretch (: `/settings/agents[/:id]`,
-// `/settings/skills[/:id]`); / moved both back out to their
-// own rail destinations, so any deep link into the old Settings home now
-// bounces to the new one, preserving a deep-linked id. Library was renamed
-// Files, then Artifacts, moving off `/library` and `/files` in turn — both
-// old prefixes bounce to `/artifacts` the same way.
-
-import { useEffect } from "react";
-
-function legacyRedirectTarget(path: string, oldPrefix: string, newPrefix: string): string {
- if (path === oldPrefix) return newPrefix;
- if (path.startsWith(`${oldPrefix}/`)) {
- return `${newPrefix}/${path.slice(oldPrefix.length + 1)}`;
- }
- return newPrefix;
-}
-
-export function LegacyRedirect({
- path,
- navigate,
- oldPrefix,
- newPrefix,
-}: {
- readonly path: string;
- readonly navigate: (to: string) => void;
- readonly oldPrefix: string;
- readonly newPrefix: string;
-}) {
- useEffect(() => {
- navigate(legacyRedirectTarget(path, oldPrefix, newPrefix));
- }, [path, navigate, oldPrefix, newPrefix]);
- return null;
-}
-
-export function LegacySettingsAgentsRedirect({
- path,
- navigate,
-}: {
- readonly path: string;
- readonly navigate: (to: string) => void;
-}) {
- return (
-
- );
-}
-
-export function LegacySettingsSkillsRedirect({
- path,
- navigate,
-}: {
- readonly path: string;
- readonly navigate: (to: string) => void;
-}) {
- return (
-
- );
-}
-
-export function LegacyLibraryRedirect({
- path,
- navigate,
-}: {
- readonly path: string;
- readonly navigate: (to: string) => void;
-}) {
- return (
-
- );
-}
diff --git a/apps/web/src/pages/library-page.tsx b/apps/web/src/pages/library-page.tsx
index 81613ae1e..44e436c45 100644
--- a/apps/web/src/pages/library-page.tsx
+++ b/apps/web/src/pages/library-page.tsx
@@ -369,9 +369,11 @@ export function LibraryPage({
// — so a selection made in one view has nothing to anchor to in the
// other. Clearing on view change is simpler than teaching the card view
// its own checkboxes for a selection UI it doesn't otherwise need.
- useEffect(() => {
+ const [selectionViewMode, setSelectionViewMode] = useState(viewMode);
+ if (selectionViewMode !== viewMode) {
+ setSelectionViewMode(viewMode);
selection.clear();
- }, [viewMode, selection.clear]);
+ }
const openPicker = useCallback(() => {
if (uploading === true) return;
@@ -620,9 +622,11 @@ export function LibraryRoute({ path }: { readonly path: string }) {
// existed.
const deepLinkedArtifactId = libraryArtifactIdFromPath(path);
const [selectedId, setSelectedId] = useState
(deepLinkedArtifactId);
- useEffect(() => {
+ const [appliedDeepLink, setAppliedDeepLink] = useState(deepLinkedArtifactId);
+ if (appliedDeepLink !== deepLinkedArtifactId) {
+ setAppliedDeepLink(deepLinkedArtifactId);
if (deepLinkedArtifactId !== null) setSelectedId(deepLinkedArtifactId);
- }, [deepLinkedArtifactId]);
+ }
const kindSegment = deepLinkedArtifactId === null ? libraryKindSegmentFromPath(path) : "";
// Artifacts' workbench-first lens: the workbench the person just
@@ -654,14 +658,14 @@ export function LibraryRoute({ path }: { readonly path: string }) {
: `/api/tenants/${scopeTenantId}/artifacts/${encodeURIComponent(selectedId)}`;
const detail = useAPIQuery(detailPath, ArtifactDetailSchema);
- // Drop selection when the filtered list no longer contains the id.
- useEffect(() => {
- if (selectedId === null || page.kind !== "ready") return;
+ // A selection the filtered list no longer contains is dropped during
+ // render, so the detail pane never paints for a row that is not there.
+ if (selectedId !== null && page.kind === "ready") {
const stillThere = mapArtifactListToSummaries(page.data.artifacts)
.filter((row) => artifactMatchesLibraryKindSegment(row, kindSegment))
.some((row) => row.id === selectedId);
if (!stillThere) setSelectedId(null);
- }, [page, selectedId, kindSegment]);
+ }
if (selectedTenantId === null) {
return (
diff --git a/apps/web/src/pages/settings-page.tsx b/apps/web/src/pages/settings-page.tsx
index feb04ddf5..60cedc178 100644
--- a/apps/web/src/pages/settings-page.tsx
+++ b/apps/web/src/pages/settings-page.tsx
@@ -9,10 +9,10 @@
import { flattenSettingsSections, resolveActiveSection, SettingsShell } from "@/settings";
import { PageShell } from "@corbits/react-ui";
-import { useEffect } from "react";
import { useBench } from "../bench-context";
import { useSignOut } from "../navigation";
+import { Redirect } from "../redirect";
import {
SETTINGS_PATH_PREFIX,
settingsEntityIdFromPath,
@@ -58,17 +58,15 @@ export function SettingsRoute({
// Bare /settings, and an unknown or gate-denied /settings/:section, both
// correct to the first allowed section's own URL — never a fallback
- // rendered under a URL the section nav disagrees with. Depends on
- // `activeSectionId` (a primitive), not `activeSection` (a fresh object
- // every render, since `resolveSettingsSectionGroups` isn't memoized) —
- // otherwise an unrelated re-render (e.g. BenchProvider persisting the
- // resolved tenant id) would refire this and double-navigate.
- useEffect(() => {
- if (activeSectionId === null) return;
- if (requestedId !== null && requestedSectionExists) return;
- if (requestedId !== null && !accessSettled) return;
- navigate(`${SETTINGS_PATH_PREFIX}/${activeSectionId}`);
- }, [requestedId, requestedSectionExists, accessSettled, activeSectionId, navigate]);
+ // rendered under a URL the section nav disagrees with.
+ if (
+ activeSectionId !== null &&
+ (requestedId === null || (!requestedSectionExists && accessSettled))
+ ) {
+ return (
+
+ );
+ }
return (
diff --git a/apps/web/src/pages/skill-detail-page.tsx b/apps/web/src/pages/skill-detail-page.tsx
index 91bbfce76..03accecb7 100644
--- a/apps/web/src/pages/skill-detail-page.tsx
+++ b/apps/web/src/pages/skill-detail-page.tsx
@@ -17,7 +17,10 @@ import { PageShell, RichEmptyState, Section, formatRelativeTime } from "@corbits
import { Lightning } from "@/lib/icons";
import { WorkbenchLoadingState } from "@/chat";
import { ApiQueryError, describeApiError } from "@/lib/api-query";
-import { useCallback, useEffect, useState, type ReactNode } from "react";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { useCallback, type ReactNode } from "react";
+
+import { tenantKeys } from "../query-client";
import { useBench } from "../bench-context";
import { SKILLS_PATH_PREFIX, skillIdFromPath } from "../path-ids";
@@ -44,32 +47,28 @@ export function SkillDetailPage({
readonly name: string;
readonly now?: number;
}) {
- const [state, setState] = useState
({ status: "loading" });
+ const queryClient = useQueryClient();
+ const queryKey = [...tenantKeys.skills(tenantId ?? "none"), name] as const;
+ const detail = useQuery({
+ queryKey,
+ queryFn: async () => (await loadSkill(tenantId ?? "", name)).skill,
+ enabled: tenantId !== null,
+ });
+
+ // The failure is the page state — missing and error render distinct
+ // honest copy, so neither is reported beyond what the reader already sees.
+ const state: PageState = detail.isError
+ ? statusOf(detail.error) === 404
+ ? { status: "missing" }
+ : { status: "error", message: describeApiError(detail.error, "loading this skill") }
+ : detail.data === undefined
+ ? { status: "loading" }
+ : { status: "ready", skill: detail.data };
const read = useCallback(async (): Promise => {
- if (tenantId === null) return;
- setState({ status: "loading" });
- try {
- const { skill } = await loadSkill(tenantId, name);
- setState({ status: "ready", skill });
- } catch (cause) {
- // report-error-ignore: the failure is the page state — missing vs
- // error renders distinct honest copy, so there is nothing to report
- // beyond what the user already sees.
- setState(
- statusOf(cause) === 404
- ? { status: "missing" }
- : {
- status: "error",
- message: describeApiError(cause, "loading this skill"),
- },
- );
- }
- }, [tenantId, name]);
-
- useEffect(() => {
- void read();
- }, [read]);
+ await queryClient.invalidateQueries({ queryKey });
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- queryKey is derived from tenantId + name
+ }, [queryClient, tenantId, name]);
const crumbs = [{ label: "Skills", href: SKILLS_PATH_PREFIX }, { label: name }];
diff --git a/apps/web/src/pages/skills-page.tsx b/apps/web/src/pages/skills-page.tsx
index 3c96555d3..0f0cdeeb7 100644
--- a/apps/web/src/pages/skills-page.tsx
+++ b/apps/web/src/pages/skills-page.tsx
@@ -29,8 +29,11 @@ import {
} from "@corbits/react-ui";
import { Lightning, Plus } from "@/lib/icons";
import { WorkbenchLoadingState } from "@/chat";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useState, type ReactNode } from "react";
+import { tenantKeys } from "../query-client";
+
import { rowActivationProps } from "../activatable-row";
import { consumePendingNewSkill } from "../command-palette-actions";
import { createSkill, listSkills, type SkillSummary } from "../skills-api";
@@ -67,28 +70,26 @@ export function SkillsPage({
readonly tenantId: string | null;
readonly navigate?: (to: string) => void;
}) {
- const [state, setState] = useState({ status: "loading" });
+ const queryClient = useQueryClient();
const [query, setQuery] = useState("");
- const [createOpen, setCreateOpen] = useState(false);
+ // The "New skill" hop from elsewhere is consumed once, as the page mounts.
+ const [createOpen, setCreateOpen] = useState(() => consumePendingNewSkill());
+
+ const registry = useQuery({
+ queryKey: tenantKeys.skills(tenantId ?? "none"),
+ queryFn: () => (tenantId === null ? Promise.resolve([]) : listSkills(tenantId)),
+ enabled: tenantId !== null,
+ });
+ const state: RegistryState = registry.isError
+ ? { status: "error", message: messageOf(registry.error) }
+ : registry.data === undefined
+ ? { status: "loading" }
+ : { status: "ready", skills: registry.data };
const reload = useCallback(async () => {
if (tenantId === null) return;
- setState({ status: "loading" });
- try {
- const skills = await listSkills(tenantId);
- setState({ status: "ready", skills });
- } catch (cause) {
- setState({ status: "error", message: messageOf(cause) });
- }
- }, [tenantId]);
-
- useEffect(() => {
- void reload();
- }, [reload]);
-
- useEffect(() => {
- if (consumePendingNewSkill()) setCreateOpen(true);
- }, []);
+ await queryClient.invalidateQueries({ queryKey: tenantKeys.skills(tenantId) });
+ }, [queryClient, tenantId]);
useEffect(() => {
const onCreate = () => setCreateOpen(true);
diff --git a/apps/web/src/query-client.ts b/apps/web/src/query-client.ts
index 0bb5e98fd..32d1552e8 100644
--- a/apps/web/src/query-client.ts
+++ b/apps/web/src/query-client.ts
@@ -99,6 +99,10 @@ export const tenantKeys = {
// `invalidateQueries({ queryKey: tenantKeys.artifacts(tenantId) })` after
// an upload covers both the list and the kind-nav counts.
artifactCounts: (tenantId: string) => ["tenant", tenantId, "artifacts", "counts"] as const,
+ credentials: (tenantId: string) => ["tenant", tenantId, "credentials"] as const,
+ principals: (tenantId: string) => ["tenant", tenantId, "principals"] as const,
+ roles: (tenantId: string) => ["tenant", tenantId, "roles"] as const,
+ grants: (tenantId: string) => ["tenant", tenantId, "grants"] as const,
/** Settings section-nav gating (People/Roles/Grants/Credentials). Keyed
* so col2's nav band and the settings stage — mounted in separate
* subtrees — share one cached probe instead of each firing its own. */
diff --git a/apps/web/src/redirect.tsx b/apps/web/src/redirect.tsx
new file mode 100644
index 000000000..c9dddd480
--- /dev/null
+++ b/apps/web/src/redirect.tsx
@@ -0,0 +1,28 @@
+// A screen that only exists to forward. The hop happens during render, not
+// from an effect, so the route it leaves never paints a frame of its own —
+// the router store publishes the new path on a microtask, so nothing is
+// updated mid-render.
+
+import { useRef } from "react";
+
+import type { Navigate } from "./navigation";
+
+/** Scheduled from render on a microtask, and at most once per target: the
+ * hop leaves this render alone (nothing is updated mid-render) yet still
+ * lands before the browser paints the route it is leaving. */
+export function Redirect({
+ to,
+ from,
+ navigate,
+}: {
+ readonly to: string;
+ readonly from: string;
+ readonly navigate: Navigate;
+}) {
+ const sent = useRef(null);
+ if (sent.current !== to && from !== new URL(to, window.location.origin).pathname) {
+ sent.current = to;
+ queueMicrotask(() => navigate(to));
+ }
+ return null;
+}
diff --git a/apps/web/src/router-store.ts b/apps/web/src/router-store.ts
new file mode 100644
index 000000000..12e1ed477
--- /dev/null
+++ b/apps/web/src/router-store.ts
@@ -0,0 +1,63 @@
+// The browser history as an external store: the path lives outside React,
+// so the shell subscribes with `useSyncExternalStore` instead of an effect
+// that registers a `popstate` listener after the first paint. Retired paths
+// are canonicalized here, before any screen mounts — a bookmark to
+// `/files/a1` becomes `/artifacts/a1` in the URL bar and in the store, and
+// no route entry exists just to bounce it.
+
+import { redirectTargetFor } from "./routes";
+
+function canonicalPath(pathname: string): string {
+ return redirectTargetFor(pathname) ?? pathname;
+}
+
+const listeners = new Set<() => void>();
+
+let currentPath = canonicalPath(window.location.pathname);
+if (currentPath !== window.location.pathname) {
+ window.history.replaceState(null, "", currentPath);
+}
+
+function setPath(next: string): void {
+ if (next === currentPath) return;
+ currentPath = next;
+ // Published on a microtask so a render-phase `navigateTo` (a forwarding
+ // route resolving its target) never updates a subscriber mid-render.
+ queueMicrotask(() => {
+ for (const listener of listeners) listener();
+ });
+}
+
+window.addEventListener("popstate", () => {
+ const canonical = canonicalPath(window.location.pathname);
+ if (canonical !== window.location.pathname) {
+ window.history.replaceState(null, "", canonical);
+ }
+ setPath(canonical);
+});
+
+export function subscribeToPath(listener: () => void): () => void {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+}
+
+export function getPath(): string {
+ return currentPath;
+}
+
+/**
+ * Push a new entry and publish the new path. The store keeps the pathname
+ * only — every comparison against it (`matchesRoute`, `LOGIN_PATH`,
+ * `ONBOARDING_PATH`) expects a bare path — while a query string like
+ * `/login?next=...` still reaches the URL bar.
+ */
+export function navigateTo(to: string): void {
+ const url = new URL(to, window.location.origin);
+ const canonical = canonicalPath(url.pathname);
+ // A hop to the path already showing is a no-op, which is what makes a
+ // render-phase `navigate` safe to run twice (StrictMode, a re-render):
+ // it can never stack duplicate history entries.
+ if (canonical === currentPath) return;
+ window.history.pushState(null, "", canonical === url.pathname ? to : canonical);
+ setPath(canonical);
+}
diff --git a/apps/web/src/routes.tsx b/apps/web/src/routes.tsx
index 4fe5dc09f..0d789de02 100644
--- a/apps/web/src/routes.tsx
+++ b/apps/web/src/routes.tsx
@@ -8,16 +8,12 @@
// open her conversation) for a bench with a workbench already, or the
// guided first-workbench describe screen for a bench with none — never a
// Home dashboard.
-// Approvals has no page — the Activity band owns them. Agents
-// and Skills are their own rail destinations again — they spent
-// a stretch as Settings sections and `/settings/agents[/:id]` /
-// `/settings/skills[/:id]` stay routable only as redirects back here, so
-// old links and bookmarks still land somewhere real. Library was renamed
-// Files, then Workbench renamed Routines to Workflows and Files to
-// Artifacts — `/routines` and `/files` (and the older `/library`) all
-// redirect to their current homes. Inbox is gone too (tasks + approvals
-// don't flow into workbenches); `/inbox` stays routable only as a
-// redirect to `/`.
+// Approvals has no page — the Activity band owns them. Agents and Skills
+// are their own rail destinations again. Every path this app has retired
+// along the way (`/settings/agents`, `/routines`, `/files`, `/library`,
+// `/inbox`, `/mission-control`) lives in `RETIRED_PREFIXES` below, which
+// the router resolves before a screen mounts — never a route entry whose
+// only job is to bounce.
import {
ChatCircle,
@@ -31,7 +27,7 @@ import {
} from "@/lib/icons";
import { CHAT_STRINGS } from "@/chat";
import type { Slug } from "@/lib/slug";
-import { lazy, useEffect, type ReactElement, type ReactNode } from "react";
+import { lazy, type ReactElement, type ReactNode } from "react";
import {
AGENTS_PATH_PREFIX,
@@ -42,12 +38,6 @@ import {
} from "./path-ids";
import { WORKBENCH_PATH_PREFIX, isWorkbenchPath } from "./workbench-path";
import { CHATS_PATH_PREFIX, isChatPath } from "./chat-path";
-import {
- LegacyRedirect,
- LegacyLibraryRedirect,
- LegacySettingsAgentsRedirect,
- LegacySettingsSkillsRedirect,
-} from "./pages/legacy-settings-redirects";
// Each signed-in page is a dynamic import so Vite emits one chunk per
// screen. Static imports here pulled chat-ui, artifact-ui, settings-ui,
@@ -204,15 +194,10 @@ export function matchesRoute(routePath: string, path: string): boolean {
}
if (
routePath === "/workflows" ||
- routePath === "/routines" ||
- routePath === "/library" ||
routePath === "/artifacts" ||
- routePath === "/files" ||
routePath === "/insights" ||
routePath === "/agents" ||
routePath === "/skills" ||
- routePath === "/settings/agents" ||
- routePath === "/settings/skills" ||
routePath === SETTINGS_PATH
) {
return path === routePath || path.startsWith(`${routePath}/`);
@@ -220,12 +205,29 @@ export function matchesRoute(routePath: string, path: string): boolean {
return routePath === path;
}
-/** Bounces old `/inbox` links and bookmarks home (: the Inbox page
- * is gone — tasks and approvals don't flow into a workbench). */
-function InboxRedirect({ navigate }: { readonly navigate: (to: string) => void }) {
- useEffect(() => {
- navigate("/");
- }, [navigate]);
+/** Every retired path prefix and where it lives now. Resolved by the router
+ * before a screen mounts, so a bookmark never renders a page just to bounce
+ * off it. */
+const RETIRED_PREFIXES: readonly (readonly [string, string])[] = [
+ ["/mission-control", "/"],
+ ["/inbox", "/"],
+ ["/routines", "/workflows"],
+ ["/files", "/artifacts"],
+ ["/library", "/artifacts"],
+ ["/settings/agents", "/agents"],
+ ["/settings/skills", "/skills"],
+];
+
+/** The current home for a retired path, `null` for a path that is still its
+ * own. A deep-linked id is carried across (`/files/a1` → `/artifacts/a1`). */
+export function redirectTargetFor(path: string): string | null {
+ for (const [oldPrefix, newPrefix] of RETIRED_PREFIXES) {
+ if (path === oldPrefix) return newPrefix;
+ if (path.startsWith(`${oldPrefix}/`)) {
+ const rest = path.slice(oldPrefix.length + 1);
+ return newPrefix === "/" ? "/" : `${newPrefix}/${rest}`;
+ }
+ }
return null;
}
@@ -237,16 +239,6 @@ export const APP_ROUTES: readonly AppRoute[] = [
render: () => ,
hasStageTopBar: false,
},
- {
- // Mission Control is gone — pending approvals and activity live in the
- // workbench view now; old links and bookmarks bounce home.
- path: "/mission-control",
- label: "Mission Control",
- icon: ,
- render: (path: string, navigate: (to: string) => void) => (
-
- ),
- },
{
path: NEW_WORKBENCH_PATH,
label: CHAT_STRINGS.newWorkbenchAction,
@@ -267,14 +259,6 @@ export const APP_ROUTES: readonly AppRoute[] = [
icon: ,
render: (path: string) => ,
},
- {
- path: "/inbox",
- label: "Inbox",
- icon: ,
- render: (_path: string, navigate: (to: string) => void) => (
-
- ),
- },
{
// Detail routes come before their roster: the roster prefix matches
// everything beneath it, so the more specific slug route has to be
@@ -290,20 +274,6 @@ export const APP_ROUTES: readonly AppRoute[] = [
icon: ,
render: () => ,
},
- {
- // Old `/routines` links and bookmarks (its rename) land here.
- path: "/routines",
- label: "Workflows",
- icon: ,
- render: (path: string, navigate: (to: string) => void) => (
-
- ),
- },
{
// The renamed, remounted Library page — "Library" stays out
// of user-facing copy, but the underlying artifact machinery
@@ -313,24 +283,6 @@ export const APP_ROUTES: readonly AppRoute[] = [
icon: ,
render: (path: string) => ,
},
- {
- // Old `/files` links and bookmarks (its rename) land here.
- path: "/files",
- label: "Artifacts",
- icon: ,
- render: (path: string, navigate: (to: string) => void) => (
-
- ),
- },
- {
- // Old `/library` links and bookmarks (its rename) land here.
- path: "/library",
- label: "Artifacts",
- icon: ,
- render: (path: string, navigate: (to: string) => void) => (
-
- ),
- },
{
path: AGENT_DETAIL_PATH,
label: "Agent",
@@ -347,16 +299,6 @@ export const APP_ROUTES: readonly AppRoute[] = [
),
},
- {
- // Agents spent through as a Settings section — this
- // entry keeps old `/settings/agents[/:id]` links routable.
- path: "/settings/agents",
- label: "Agents",
- icon: ,
- render: (path: string, navigate: (to: string) => void) => (
-
- ),
- },
{
path: SKILL_DETAIL_PATH,
label: "Skill",
@@ -369,16 +311,6 @@ export const APP_ROUTES: readonly AppRoute[] = [
icon: ,
render: (_path: string, navigate: (to: string) => void) => ,
},
- {
- // Skills spent through as a Settings section — this
- // entry keeps old `/settings/skills[/:id]` links routable.
- path: "/settings/skills",
- label: "Skills",
- icon: ,
- render: (path: string, navigate: (to: string) => void) => (
-
- ),
- },
{
path: "/tools",
label: "Tools",
diff --git a/apps/web/src/settings/access.ts b/apps/web/src/settings/access.ts
index 9ef2479e9..5f48f8e2d 100644
--- a/apps/web/src/settings/access.ts
+++ b/apps/web/src/settings/access.ts
@@ -9,8 +9,6 @@
// together made the gated nav vanish as if the principal were
// unauthorized.
-import { useEffect, useState } from "react";
-
import { evaluate } from "./tenancy-api";
export type SectionAccess = "loading" | "allowed" | "denied" | "error";
@@ -47,45 +45,3 @@ export function coalesceSectionAccess(previous: SectionAccess, next: SectionAcce
}
return next;
}
-
-function useResourceAccess(
- tenantId: string | null,
- principalId: string | null,
- resource: string,
-): SectionAccess {
- const [access, setAccess] = useState("loading");
-
- useEffect(() => {
- if (tenantId === null || principalId === null) {
- setAccess("loading");
- return;
- }
- let cancelled = false;
- setAccess("loading");
- void probeSectionAccess(tenantId, principalId, resource).then((next) => {
- if (!cancelled) setAccess(next);
- });
- return () => {
- cancelled = true;
- };
- }, [tenantId, principalId, resource]);
-
- return access;
-}
-
-/** One probe per section, run in parallel — a section stays out of the nav
- * until its probe resolves `allowed`, so a slow probe reads as "not shown
- * yet", never as a visible-but-disabled tab. A failed probe is `error`,
- * not `denied`: the registry withholds the section but marks the group so
- * a host can show a couldn't-check state instead of looking unauthorized. */
-export function useTenancyAccess(
- tenantId: string | null,
- principalId: string | null,
-): TenancyAccess {
- return {
- people: useResourceAccess(tenantId, principalId, "principal"),
- roles: useResourceAccess(tenantId, principalId, "role"),
- grants: useResourceAccess(tenantId, principalId, "grant"),
- credentials: useResourceAccess(tenantId, principalId, "credential"),
- };
-}
diff --git a/apps/web/src/settings/account-section.tsx b/apps/web/src/settings/account-section.tsx
index c1315934d..7ce720376 100644
--- a/apps/web/src/settings/account-section.tsx
+++ b/apps/web/src/settings/account-section.tsx
@@ -18,10 +18,9 @@ import {
} from "@corbits/react-ui";
import { Select } from "@corbits/react-ui/ui/select";
import { ChatCircleDots, Copy, SignOut } from "@/lib/icons";
-import { useCallback, useEffect, useState } from "react";
+import { useQuery } from "@tanstack/react-query";
-import type { APIQuery } from "@/lib/api-query";
-import { QueryView, UnauthenticatedError, describeQueryError } from "@/lib/api-query";
+import { QueryView, toAPIQuery } from "@/lib/api-query";
import { resolveAvatarFill } from "@/chat";
import webPackage from "../../package.json";
import { getAccount, type Account } from "./api";
@@ -32,33 +31,7 @@ import { SETTINGS_STRINGS } from "./strings";
const FEEDBACK_URL = `${webPackage.repository.url}/issues`;
export function AccountSection({ onSignOut }: { readonly onSignOut?: () => void }) {
- const [query, setQuery] = useState>({ kind: "loading" });
-
- const load = useCallback(() => {
- setQuery({ kind: "loading" });
- let cancelled = false;
- getAccount()
- .then((account) => {
- if (!cancelled) setQuery({ kind: "ready", data: account });
- })
- .catch((cause: unknown) => {
- if (cancelled) return;
- if (cause instanceof UnauthenticatedError) {
- setQuery({ kind: "unauthenticated" });
- return;
- }
- setQuery({
- kind: "error",
- message: describeQueryError(cause),
- retry: load,
- });
- });
- return () => {
- cancelled = true;
- };
- }, []);
-
- useEffect(() => load(), [load]);
+ const query = toAPIQuery(useQuery({ queryKey: ["me", "account"], queryFn: getAccount }));
return (
<>
diff --git a/apps/web/src/settings/credentials-section.tsx b/apps/web/src/settings/credentials-section.tsx
index 25f4e945f..1073f4777 100644
--- a/apps/web/src/settings/credentials-section.tsx
+++ b/apps/web/src/settings/credentials-section.tsx
@@ -26,10 +26,11 @@ import {
TableRow,
} from "@corbits/react-ui";
import type { CredentialType } from "@intx/types";
-import { useEffect, useState } from "react";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { useState } from "react";
-import type { APIQuery } from "@/lib/api-query";
-import { QueryView, UnauthenticatedError, describeQueryError } from "@/lib/api-query";
+import { QueryView, toAPIQuery } from "@/lib/api-query";
+import { tenantKeys } from "@/query-client";
import {
createCredential,
createProvider,
@@ -42,41 +43,39 @@ import {
} from "./credentials-api";
import { SETTINGS_STRINGS } from "./strings";
+type CredentialsData = {
+ readonly credentials: readonly Credential[];
+ readonly providers: readonly Provider[];
+};
+
export function CredentialsSection({ tenantId }: { readonly tenantId: string | null }) {
- const [query, setQuery] = useState>({ kind: "loading" });
- const [reloadKey, setReloadKey] = useState(0);
+ const queryClient = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState(null);
const [rowError, setRowError] = useState(null);
- const [providers, setProviders] = useState([]);
- function reload() {
- setReloadKey((value) => value + 1);
- }
+ // Credentials and their providers load together: creating one needs the
+ // provider row for the typed name, so a split read would race the form.
+ const result = useQuery({
+ queryKey: tenantKeys.credentials(tenantId ?? "none"),
+ queryFn: async (): Promise => {
+ if (tenantId === null) return { credentials: [], providers: [] };
+ const [credentials, providers] = await Promise.all([
+ listCredentials(tenantId),
+ listProviders(tenantId),
+ ]);
+ return { credentials, providers };
+ },
+ enabled: tenantId !== null,
+ });
+ const query = toAPIQuery(result);
+ const providers = result.data?.providers ?? [];
- useEffect(() => {
+ function reload() {
if (tenantId === null) return;
- let cancelled = false;
- setQuery({ kind: "loading" });
- Promise.all([listCredentials(tenantId), listProviders(tenantId)])
- .then(([credentials, providerRows]) => {
- if (cancelled) return;
- setProviders(providerRows);
- setQuery({ kind: "ready", data: credentials });
- })
- .catch((cause: unknown) => {
- if (cancelled) return;
- if (cause instanceof UnauthenticatedError) {
- setQuery({ kind: "unauthenticated" });
- return;
- }
- setQuery({ kind: "error", message: describeQueryError(cause), retry: reload });
- });
- return () => {
- cancelled = true;
- };
- }, [tenantId, reloadKey]);
+ void queryClient.invalidateQueries({ queryKey: tenantKeys.credentials(tenantId) });
+ }
if (tenantId === null) {
return (
@@ -114,7 +113,7 @@ export function CredentialsSection({ tenantId }: { readonly tenantId: string | n
return (
- {(credentials) => (
+ {({ credentials }) => (
({});
- const [query, setQuery] = useState>({
- kind: "loading",
- });
- const [reloadKey, setReloadKey] = useState(0);
+ const queryClient = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState(null);
const [rowError, setRowError] = useState(null);
+ // Filters are part of the key, so narrowing refetches instead of the list
+ // silently keeping the previous filter's rows.
const filtersKey = JSON.stringify(filters);
- function reload() {
- setReloadKey((value) => value + 1);
- }
+ const query = toAPIQuery(
+ useQuery({
+ queryKey: [...tenantKeys.grants(tenantId ?? "none"), filtersKey],
+ queryFn: async (): Promise => {
+ if (tenantId === null) return { grants: [], roles: [], principals: [] };
+ const [grants, roles, principals] = await Promise.all([
+ listGrants(tenantId, filters),
+ listRoles(tenantId),
+ listPrincipals(tenantId),
+ ]);
+ return { grants, roles, principals };
+ },
+ enabled: tenantId !== null,
+ }),
+ );
- useEffect(() => {
+ function reload() {
if (tenantId === null) return;
- let cancelled = false;
- setQuery({ kind: "loading" });
- Promise.all([listGrants(tenantId, filters), listRoles(tenantId), listPrincipals(tenantId)])
- .then(([grants, roles, principals]) => {
- if (!cancelled) setQuery({ kind: "ready", data: { grants, roles, principals } });
- })
- .catch((cause: unknown) => {
- if (cancelled) return;
- if (cause instanceof UnauthenticatedError) {
- setQuery({ kind: "unauthenticated" });
- return;
- }
- setQuery({
- kind: "error",
- message: describeQueryError(cause),
- retry: reload,
- });
- });
- return () => {
- cancelled = true;
- };
- }, [tenantId, reloadKey, filtersKey]);
+ void queryClient.invalidateQueries({ queryKey: tenantKeys.grants(tenantId) });
+ }
if (tenantId === null) {
return (
diff --git a/apps/web/src/settings/index.ts b/apps/web/src/settings/index.ts
index d60f2d365..fd325b8a8 100644
--- a/apps/web/src/settings/index.ts
+++ b/apps/web/src/settings/index.ts
@@ -28,7 +28,7 @@ export type { PrincipalLabel } from "./identity";
export { GRANT_RESOURCES, GRANT_ACTIONS } from "./resource-vocabulary";
export type { GrantResource, GrantAction } from "./resource-vocabulary";
-export { useTenancyAccess, probeSectionAccess, coalesceSectionAccess } from "./access";
+export { probeSectionAccess, coalesceSectionAccess } from "./access";
export type { SectionAccess, TenancyAccess } from "./access";
export {
diff --git a/apps/web/src/settings/people-section.tsx b/apps/web/src/settings/people-section.tsx
index e47f27fc3..1b05c869e 100644
--- a/apps/web/src/settings/people-section.tsx
+++ b/apps/web/src/settings/people-section.tsx
@@ -20,10 +20,11 @@ import {
TableHeader,
TableRow,
} from "@corbits/react-ui";
-import { useEffect, useState } from "react";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { useState } from "react";
-import type { APIQuery } from "@/lib/api-query";
-import { QueryView, UnauthenticatedError, describeQueryError } from "@/lib/api-query";
+import { QueryView, toAPIQuery } from "@/lib/api-query";
+import { tenantKeys } from "@/query-client";
import { reportError } from "@corbits/error-sink";
import { PRINCIPAL_KIND_LABEL, principalLabel } from "./identity";
@@ -59,47 +60,30 @@ type PeopleData = {
};
export function PeopleSection({ tenantId }: { readonly tenantId: string | null }) {
- const [query, setQuery] = useState>({
- kind: "loading",
- });
- const [reloadKey, setReloadKey] = useState(0);
+ const queryClient = useQueryClient();
const [rowError, setRowError] = useState(null);
- function reload() {
- setReloadKey((value) => value + 1);
- }
+ // People are the user-kind principals; the role picker needs this tenant's
+ // roles alongside them, so both read under one key every write invalidates.
+ const query = toAPIQuery(
+ useQuery({
+ queryKey: tenantKeys.principals(tenantId ?? "none"),
+ queryFn: async (): Promise => {
+ if (tenantId === null) return { people: [], roles: [] };
+ const [principals, roles] = await Promise.all([
+ listPrincipals(tenantId),
+ listRoles(tenantId),
+ ]);
+ return { people: principals.filter((p) => p.kind === "user"), roles };
+ },
+ enabled: tenantId !== null,
+ }),
+ );
- useEffect(() => {
+ function reload() {
if (tenantId === null) return;
- let cancelled = false;
- setQuery({ kind: "loading" });
- Promise.all([listPrincipals(tenantId), listRoles(tenantId)])
- .then(([principals, roles]) => {
- if (!cancelled)
- setQuery({
- kind: "ready",
- data: {
- people: principals.filter((p) => p.kind === "user"),
- roles,
- },
- });
- })
- .catch((cause: unknown) => {
- if (cancelled) return;
- if (cause instanceof UnauthenticatedError) {
- setQuery({ kind: "unauthenticated" });
- return;
- }
- setQuery({
- kind: "error",
- message: describeQueryError(cause),
- retry: reload,
- });
- });
- return () => {
- cancelled = true;
- };
- }, [tenantId, reloadKey]);
+ void queryClient.invalidateQueries({ queryKey: tenantKeys.principals(tenantId) });
+ }
if (tenantId === null) {
return (
diff --git a/apps/web/src/settings/roles-section.tsx b/apps/web/src/settings/roles-section.tsx
index 67a7b0126..d9b24a453 100644
--- a/apps/web/src/settings/roles-section.tsx
+++ b/apps/web/src/settings/roles-section.tsx
@@ -24,10 +24,11 @@ import {
TableHeader,
TableRow,
} from "@corbits/react-ui";
-import { useEffect, useState } from "react";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { useState } from "react";
-import type { APIQuery } from "@/lib/api-query";
-import { QueryView, UnauthenticatedError, describeQueryError } from "@/lib/api-query";
+import { QueryView, toAPIQuery } from "@/lib/api-query";
+import { tenantKeys } from "@/query-client";
import { principalLabel } from "./identity";
import { SETTINGS_STRINGS } from "./strings";
import {
@@ -48,41 +49,33 @@ type RolesData = {
};
export function RolesSection({ tenantId }: { readonly tenantId: string | null }) {
- const [query, setQuery] = useState>({ kind: "loading" });
- const [reloadKey, setReloadKey] = useState(0);
+ const queryClient = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState(null);
const [rowError, setRowError] = useState(null);
- function reload() {
- setReloadKey((value) => value + 1);
- }
+ // Roles and principals read together: the assignment table needs both, and
+ // every write below invalidates this one key.
+ const query = toAPIQuery(
+ useQuery({
+ queryKey: tenantKeys.roles(tenantId ?? "none"),
+ queryFn: async (): Promise => {
+ if (tenantId === null) return { roles: [], principals: [] };
+ const [roles, principals] = await Promise.all([
+ listRoles(tenantId),
+ listPrincipals(tenantId),
+ ]);
+ return { roles, principals };
+ },
+ enabled: tenantId !== null,
+ }),
+ );
- useEffect(() => {
+ function reload() {
if (tenantId === null) return;
- let cancelled = false;
- setQuery({ kind: "loading" });
- Promise.all([listRoles(tenantId), listPrincipals(tenantId)])
- .then(([roles, principals]) => {
- if (!cancelled) setQuery({ kind: "ready", data: { roles, principals } });
- })
- .catch((cause: unknown) => {
- if (cancelled) return;
- if (cause instanceof UnauthenticatedError) {
- setQuery({ kind: "unauthenticated" });
- return;
- }
- setQuery({
- kind: "error",
- message: describeQueryError(cause),
- retry: reload,
- });
- });
- return () => {
- cancelled = true;
- };
- }, [tenantId, reloadKey]);
+ void queryClient.invalidateQueries({ queryKey: tenantKeys.roles(tenantId) });
+ }
if (tenantId === null) {
return (
diff --git a/apps/web/src/shell/app-shell.tsx b/apps/web/src/shell/app-shell.tsx
index eb3309ab8..0ee4d5251 100644
--- a/apps/web/src/shell/app-shell.tsx
+++ b/apps/web/src/shell/app-shell.tsx
@@ -11,7 +11,7 @@
// and this component only reads it through the same hooks page code
// already uses.
-import { lazy, Suspense, useEffect, useRef, useState, type ReactNode } from "react";
+import { lazy, Suspense, useRef, useState, type ReactNode, type RefObject } from "react";
import type { ArtifactSaveState } from "@/library";
import { WorkbenchLoadingState } from "@/chat";
@@ -23,7 +23,6 @@ import { saveArtifactContent } from "./library-artifacts";
import { APP_ROUTES, matchesRoute } from "../routes";
import type { SessionUser } from "../session";
import { StageTopBar } from "./stage-top-bar";
-import { useScrollReset } from "@/shell/layout";
import {
useCanvasColumnArtifact,
useCanvasColumnAvailable,
@@ -55,6 +54,24 @@ function routeLabel(path: string): string {
return route?.label ?? "Workbench";
}
+/** Route changes must not inherit the previous page's scroll position. Keyed
+ * on the path, so it remounts per route and resets the scroll container as
+ * its ref attaches. */
+function ScrollToTop({
+ containerRef,
+}: {
+ readonly containerRef: RefObject;
+}) {
+ return (
+ {
+ if (containerRef.current !== null) containerRef.current.scrollTop = 0;
+ }}
+ />
+ );
+}
+
export function AppShell({
path,
user,
@@ -75,51 +92,46 @@ export function AppShell({
const canvasFocus = useCanvasColumnFocus();
const { selectedTenantId: tenantId } = useBench();
- // A text-kind artifact's save state (: single-user editing, no
- // co-edit presence). Resets to a fresh state the moment the open
- // artifact changes so a stale "Saved · v3" from a previous artifact can
- // never leak into a newly opened one.
- const [artifactSaveState, setArtifactSaveState] = useState({
- kind: "read-only",
- });
- const artifactSaveStateForId = useRef(null);
- useEffect(() => {
- if (canvasArtifact === null || canvasArtifact.rendererKind !== "doc") {
- artifactSaveStateForId.current = null;
- setArtifactSaveState({ kind: "read-only" });
- return;
- }
- if (artifactSaveStateForId.current === canvasArtifact.id) return;
- artifactSaveStateForId.current = canvasArtifact.id;
- setArtifactSaveState(
- canvasArtifact.canEdit === true ? { kind: "unsaved" } : { kind: "read-only" },
- );
- }, [canvasArtifact]);
+ // A text-kind artifact's save state (single-user editing, no co-edit
+ // presence), carried with the id it belongs to: state for any other id is
+ // ignored during render, so a stale "Saved · v3" can never leak into a
+ // newly opened artifact and nothing has to be reset when one changes.
+ const [savedFor, setSavedFor] = useState<{
+ readonly id: string;
+ readonly state: ArtifactSaveState;
+ } | null>(null);
+ const editableArtifact =
+ canvasArtifact !== null && canvasArtifact.rendererKind === "doc" ? canvasArtifact : null;
+ const artifactSaveState: ArtifactSaveState =
+ editableArtifact === null
+ ? { kind: "read-only" }
+ : savedFor?.id === editableArtifact.id
+ ? savedFor.state
+ : editableArtifact.canEdit === true
+ ? { kind: "unsaved" }
+ : { kind: "read-only" };
const saveArtifact = (content: string) => {
- if (tenantId === null || canvasArtifact === null) return;
- const artifactId = canvasArtifact.id;
- setArtifactSaveState({ kind: "saving" });
+ if (tenantId === null || editableArtifact === null) return;
+ const artifactId = editableArtifact.id;
+ const keepIfCurrent = (state: ArtifactSaveState) => {
+ setSavedFor((previous) =>
+ previous === null || previous.id === artifactId ? { id: artifactId, state } : previous,
+ );
+ };
+ setSavedFor({ id: artifactId, state: { kind: "saving" } });
void saveArtifactContent(tenantId, artifactId, content).then(
(saved) => {
- if (artifactSaveStateForId.current !== artifactId) return;
- setArtifactSaveState({
- kind: "saved",
- version: saved.version,
- savedAt: Date.now(),
- });
+ keepIfCurrent({ kind: "saved", version: saved.version, savedAt: Date.now() });
},
() => {
- if (artifactSaveStateForId.current !== artifactId) return;
- setArtifactSaveState({ kind: "unsaved" });
+ keepIfCurrent({ kind: "unsaved" });
},
);
};
const closeCanvas = useCloseCanvas();
const toggleCanvasFocus = useToggleCanvasFocus();
const mainRef = useRef(null);
- // Route changes must not inherit the previous page's scroll position.
- useScrollReset(mainRef, path);
const pendingCount = usePendingApprovalCount(tenantId);
const pendingChip =
pendingCount === null
@@ -135,6 +147,7 @@ export function AppShell({
+
{routeHasNoStageTopBar(path) ? (
(ref: RefObject, dep: unknown): void {
- useEffect(() => {
- if (ref.current !== null) ref.current.scrollTop = 0;
- // `ref` is a stable identity; `dep` is what actually triggers a reset.
- }, [dep]);
-}
diff --git a/apps/web/src/shell/layout/use-shell-layout.ts b/apps/web/src/shell/layout/use-shell-layout.ts
index e64bce673..2223b1c7a 100644
--- a/apps/web/src/shell/layout/use-shell-layout.ts
+++ b/apps/web/src/shell/layout/use-shell-layout.ts
@@ -7,7 +7,7 @@
// `window`, so it sees the initial "expanded" assumption — which is what a
// server-rendered shell should assume before it has a viewport to measure.
-import { useEffect, useState } from "react";
+import { useSyncExternalStore } from "react";
import {
NARROW_MAX_WIDTH,
@@ -19,21 +19,32 @@ import {
const NARROW_QUERY = `(max-width: ${NARROW_MAX_WIDTH - 1}px)`;
const COMPACT_QUERY = `(max-width: ${COMPACT_MAX_WIDTH - 1}px)`;
-export function useShellLayoutMode(): ShellLayoutMode {
- const [mode, setMode] = useState("expanded");
+function subscribe(onChange: () => void): () => void {
+ const narrow = window.matchMedia(NARROW_QUERY);
+ const compact = window.matchMedia(COMPACT_QUERY);
+ narrow.addEventListener("change", onChange);
+ compact.addEventListener("change", onChange);
+ return () => {
+ narrow.removeEventListener("change", onChange);
+ compact.removeEventListener("change", onChange);
+ };
+}
+
+// The snapshot is the mode string itself, so repeated reads compare equal
+// and never loop — a fresh object here would re-render forever.
+function getSnapshot(): ShellLayoutMode {
+ return shellLayoutModeFromMatches(
+ window.matchMedia(NARROW_QUERY).matches,
+ window.matchMedia(COMPACT_QUERY).matches,
+ );
+}
- useEffect(() => {
- const narrow = window.matchMedia(NARROW_QUERY);
- const compact = window.matchMedia(COMPACT_QUERY);
- const sync = () => setMode(shellLayoutModeFromMatches(narrow.matches, compact.matches));
- sync();
- narrow.addEventListener("change", sync);
- compact.addEventListener("change", sync);
- return () => {
- narrow.removeEventListener("change", sync);
- compact.removeEventListener("change", sync);
- };
- }, []);
+/** A viewport-less render (the route tests) gets the same "expanded"
+ * assumption a server-rendered shell should make before it can measure. */
+function getServerSnapshot(): ShellLayoutMode {
+ return "expanded";
+}
- return mode;
+export function useShellLayoutMode(): ShellLayoutMode {
+ return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
diff --git a/apps/web/src/shell/shell-chrome-provider.tsx b/apps/web/src/shell/shell-chrome-provider.tsx
index feaefba6c..e53660245 100644
--- a/apps/web/src/shell/shell-chrome-provider.tsx
+++ b/apps/web/src/shell/shell-chrome-provider.tsx
@@ -8,7 +8,7 @@
// plus the shell-only read (`useCanvasColumnOpen`) it needs for its own
// render.
-import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
+import { useCallback, useState, type ReactNode } from "react";
import type { ProfileSubject } from "@/chat";
import {
@@ -59,42 +59,33 @@ export function ShellChromeProvider({
initialCanvasColumnState,
);
- // Tracks the last workbench scope we applied so a real switch (A→B) can
- // drop canvas state without treating the initial null→ready resolve as a
- // switch.
- const previousTenantIdRef = useRef(selectedTenantId);
- const previousRoutePrefixRef = useRef(inAppRoutePrefix(path));
+ // The last scope and rail surface we applied, adjusted during render
+ // rather than from an effect — the canvas must never paint a frame
+ // holding another workbench's content. The initial null→ready tenant
+ // resolve is not a switch.
+ const [appliedTenantId, setAppliedTenantId] = useState(selectedTenantId);
+ const routePrefix = inAppRoutePrefix(path);
+ const [appliedRoutePrefix, setAppliedRoutePrefix] = useState(routePrefix);
- // A switch clears auxiliary canvas content and leaves any conversation
- // deep link so the stage does not keep a foreign conversation under the
- // new scope.
- useEffect(() => {
- const previousTenantId = previousTenantIdRef.current;
- if (
- previousTenantId !== null &&
- selectedTenantId !== null &&
- previousTenantId !== selectedTenantId
- ) {
- previousTenantIdRef.current = selectedTenantId;
+ if (appliedTenantId !== selectedTenantId) {
+ setAppliedTenantId(selectedTenantId);
+ setAppliedRoutePrefix(routePrefix);
+ if (appliedTenantId !== null && selectedTenantId !== null) {
+ // A switch drops auxiliary canvas content and leaves any conversation
+ // deep link, so the stage never keeps a foreign conversation.
setCanvasState(clearCanvasForTenantSwitch());
if (isWorkbenchPath(path) && workbenchIdFromPath(path) !== null) {
- navigate(workbenchPath(null));
+ // On a microtask so the hop never updates the router mid-render.
+ queueMicrotask(() => navigate(workbenchPath(null)));
}
- return;
}
- previousTenantIdRef.current = selectedTenantId;
- }, [path, selectedTenantId, navigate]);
-
- // Leaving a rail surface dismisses auxiliary canvas content so a compact
- // viewport that hid the column cannot resurrect it when the shell expands
- // again. Nested detail and query-only changes share a prefix and keep the
- // pane.
- useEffect(() => {
- const nextPrefix = inAppRoutePrefix(path);
- if (previousRoutePrefixRef.current === nextPrefix) return;
- previousRoutePrefixRef.current = nextPrefix;
+ } else if (appliedRoutePrefix !== routePrefix) {
+ // Leaving a rail surface dismisses auxiliary canvas content so a compact
+ // viewport that hid the column cannot resurrect it when the shell
+ // expands again. Nested detail and query-only changes share a prefix.
+ setAppliedRoutePrefix(routePrefix);
setCanvasState((state) => closeCanvasContent(state));
- }, [path]);
+ }
const openProfile = useCallback((subject: ProfileSubject) => {
setCanvasState((state) => openProfileInCanvas(state, subject));
diff --git a/apps/web/src/shell/stage-search.tsx b/apps/web/src/shell/stage-search.tsx
index f3790d393..b733d5684 100644
--- a/apps/web/src/shell/stage-search.tsx
+++ b/apps/web/src/shell/stage-search.tsx
@@ -16,7 +16,7 @@
// transition duration under `prefers-reduced-motion`.
import { MagnifyingGlass } from "@/lib/icons";
-import { useEffect, useRef, useState } from "react";
+import { useRef, useState } from "react";
export type StageSearchProps = {
/** Accessible name for both the button and the input, and the default
@@ -30,18 +30,18 @@ export type StageSearchProps = {
export function StageSearch({ label, value, onChange, placeholder }: StageSearchProps) {
const [open, setOpen] = useState(value.length > 0);
- const wasOpen = useRef(open);
const buttonRef = useRef(null);
- const inputRef = useRef(null);
+ // Set only by the button, so a prefilled filter expands the bar without
+ // stealing focus from whatever the page opened with.
+ const openedByClick = useRef(false);
// A query the page already carries in (a prefilled filter) keeps the bar
// expanded even before anyone has focused it.
const expanded = open || value.length > 0;
- useEffect(() => {
- if (open) inputRef.current?.focus();
- if (wasOpen.current && !open) buttonRef.current?.focus();
- wasOpen.current = open;
- }, [open]);
+ function collapse() {
+ setOpen(false);
+ buttonRef.current?.focus();
+ }
return (
@@ -51,13 +51,20 @@ export function StageSearch({ label, value, onChange, placeholder }: StageSearch
className="stage-search-button"
aria-label={label}
aria-expanded={expanded}
- onClick={() => setOpen(true)}
+ onClick={() => {
+ openedByClick.current = true;
+ setOpen(true);
+ }}
>
{expanded ? (
{
+ if (node === null || !openedByClick.current) return;
+ openedByClick.current = false;
+ node.focus();
+ }}
type="search"
className="stage-search-input"
aria-label={label}
@@ -71,7 +78,7 @@ export function StageSearch({ label, value, onChange, placeholder }: StageSearch
if (event.key !== "Escape") return;
event.stopPropagation();
if (value.length > 0) onChange("");
- else setOpen(false);
+ else collapse();
}}
/>
) : null}