From f9062ce97f2f1b4a3baf135817186f147753a562 Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:30:04 +0300 Subject: [PATCH] perf(studio): 32 kB gzip of initial-load headroom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The initial load was at 419.9 of 420 kB gzip. Rolldown keeps a module whole in its shared chunk, so a shell import of one small hook pulled in the whole module and its imports: SiteShell → useTaxonomyData, useNotifications → Elevation / ElevationRequest → AgentModes, and the bell panel's item components. - The bell's item list (NotificationItems.tsx) loads lazily, fetched as soon as a notification exists. - Small hooks move to their own modules: elevation-actions.ts, useShortDuration.ts, taxonomy-keys.ts (re-exported where they were). - bundle-budget.ts: LAZY_ONLY guards so they cannot creep back. Initial load 419.9 → 387.6 kB gzip (72 chunks instead of 94); the entry stays under its budget. Studio 1204/1204; screenshots of the shell, the ⌘K palette and the bell at 1440 and 390 px pass. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com> --- .changeset/studio-entry-headroom.md | 5 + apps/studio/bundle-budget.ts | 9 +- apps/studio/src/components/Elevation.tsx | 132 +---- .../src/components/ElevationRequest.tsx | 31 +- .../src/components/NotificationItems.tsx | 501 ++++++++++++++++++ .../src/components/NotificationsBell.tsx | 492 +---------------- apps/studio/src/components/Publish.tsx | 2 +- apps/studio/src/components/Taxonomy.tsx | 8 +- .../src/components/elevation-actions.ts | 144 +++++ apps/studio/src/components/taxonomy-keys.ts | 30 ++ .../studio/src/components/useNotifications.ts | 4 +- .../studio/src/components/useShortDuration.ts | 26 + apps/studio/src/routes/SiteShell.tsx | 5 +- apps/studio/test/design-rules.test.ts | 39 +- docs/design/DESIGN.md | 6 +- scripts/size-baseline.json | 2 +- 16 files changed, 774 insertions(+), 662 deletions(-) create mode 100644 .changeset/studio-entry-headroom.md create mode 100644 apps/studio/src/components/NotificationItems.tsx create mode 100644 apps/studio/src/components/elevation-actions.ts create mode 100644 apps/studio/src/components/taxonomy-keys.ts create mode 100644 apps/studio/src/components/useShortDuration.ts diff --git a/.changeset/studio-entry-headroom.md b/.changeset/studio-entry-headroom.md new file mode 100644 index 00000000..d74a760e --- /dev/null +++ b/.changeset/studio-entry-headroom.md @@ -0,0 +1,5 @@ +--- +"@cloudhal/cli": patch +--- + +Studio loads about 32 kB (gzip) less before the first screen: the notification panel's items, the agent-mode and elevation views and the taxonomy helpers now load when they are needed. diff --git a/apps/studio/bundle-budget.ts b/apps/studio/bundle-budget.ts index 318644d6..eafbf896 100644 --- a/apps/studio/bundle-budget.ts +++ b/apps/studio/bundle-budget.ts @@ -32,7 +32,9 @@ export const BUNDLE_BUDGET = { * 443.7 kB on 2026-10-17 with three Phosphor weights instead of six. 401.3 kB on 2026-10-17 * once the core catalogs load one language (Turkish, 39 kB gzip, arrives only for Turkish via * `loadLocale`) and the `menus` words moved to a lazy namespace, so the budget came down from - * 455 kB. + * 455 kB. 387.6 kB on 2026-10-22 (was 419.9 kB) once the bell's items, the elevation and + * agent-mode UI and the taxonomy model left the shell (`LAZY_ONLY`); the budget stays at + * 420 kB as headroom for shell features. */ initialGzipBytes: 420_000, }; @@ -70,6 +72,11 @@ export const LAZY_ONLY: readonly RegExp[] = [ /[\\/]packages[\\/]core-ts[\\/]src[\\/]tokens\.ts$/, /[\\/]packages[\\/]ui[\\/]src[\\/]blocks[\\/]ColorRolePreview\.tsx$/, /[\\/]apps[\\/]studio[\\/]src[\\/]components[\\/]AppearanceColors\.tsx$/, + // The bell panel's items and the agent-mode, elevation and taxonomy UI behind them load with + // their screens (and the first bell with something in it), never with the shell: the shell + // keeps the hooks (`elevation-actions.ts`, `useShortDuration.ts`, `taxonomy-keys.ts`). + /[\\/]apps[\\/]studio[\\/]src[\\/]components[\\/](NotificationItems|AgentModes|Elevation|ElevationRequest|Taxonomy|RelativeTime)\.tsx$/, + /[\\/]apps[\\/]studio[\\/]src[\\/]lib[\\/](taxonomy|seo|activity)\.ts$/, // Lazy message namespaces: their words load with the screens that show them. /[\\/]apps[\\/]studio[\\/]src[\\/]i18n[\\/]lazy[\\/]/, // Core catalogs other than English load with their language (`loadLocale`), never with English. diff --git a/apps/studio/src/components/Elevation.tsx b/apps/studio/src/components/Elevation.tsx index 4bac7bcf..874d7d43 100644 --- a/apps/studio/src/components/Elevation.tsx +++ b/apps/studio/src/components/Elevation.tsx @@ -1,11 +1,10 @@ import { Badge, Banner, Text, Tooltip } from "@cloudhal/ui"; import { ClockClockwise, ClockCountdown, Stop } from "@phosphor-icons/react"; -import { type QueryClient, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect } from "react"; import { useI18n } from "../i18n/index.tsx"; import { ownModeOf } from "../lib/agents.ts"; import { type AgentKeyElevation, type AgentKeySummary, api } from "../lib/api.ts"; -import { apiErrorText } from "../lib/api-errors.ts"; import type { PendingElevation } from "../lib/approvals.ts"; import { activeElevation, @@ -16,18 +15,18 @@ import { remainingMinutes, } from "../lib/elevation.ts"; import { - canExtend, DEFAULT_EXTEND_MINUTES, type ExtendTarget, endsWithin, extendTargetOfKey, - extensionInput, } from "../lib/notifications.ts"; import { ModeBadge } from "./AgentModes.tsx"; import { AsksForBadge } from "./ElevationRequest.tsx"; -import { useToast } from "./Toast.ts"; +import { ElevationGoneError, useEndElevation, useExtendElevation } from "./elevation-actions.ts"; import { useAgentModeRules } from "./useAgentModeRules.ts"; +export { ElevationGoneError, useEndElevation, useExtendElevation }; + /** Keys with an elevation in force at `now`, with that elevation. */ export function elevatedKeys( keys: readonly AgentKeySummary[] | undefined, @@ -133,129 +132,6 @@ function agentAsked(reason: string): string | undefined { return m ? (m[1] ?? "").trim() || "…" : undefined; } -/** - * After any change to an elevation: the key list, the bell and the approvals (a request the key - * can no longer take reads differently) are read again. - */ -async function refreshAfterElevationChange(qc: QueryClient) { - await Promise.all([ - qc.invalidateQueries({ queryKey: ["agent-keys"] }), - qc.invalidateQueries({ queryKey: ["notifications"] }), - qc.invalidateQueries({ queryKey: ["approvals"] }), - ]); -} - -/** Puts the summaries the server returned into the cached key list. */ -function mergeKeys(qc: QueryClient, updated: readonly AgentKeySummary[]) { - qc.setQueryData(["agent-keys"], (keys) => - keys?.map((k) => updated.find((u) => u.id === k.id) ?? k), - ); -} - -/** - * Ends elevations early (`DELETE …/elevation`, audited). No confirmation: it only takes autonomy - * away, and elevating again is one dialog away (DESIGN.md §2g). Success and failure are toasts. - * The one way Studio ends an elevation: the key menu, the Agents banner and the notification bell - * all call this. - */ -export function useEndElevation() { - const { t } = useI18n(); - const qc = useQueryClient(); - const toast = useToast(); - return useMutation({ - mutationFn: (keys: readonly Pick[]) => - Promise.all(keys.map((k) => api.agentKeys.endElevation(k.id))), - onSuccess: async (updated) => { - mergeKeys(qc, updated); - toast.success( - t("agents.elevation.ended"), - updated - .map((u) => - t("agents.elevation.endedDescription", { - name: u.name ?? t("agents.unnamed"), - mode: t(`agents.modes.names.${ownModeOf(u)}`), - }), - ) - .join(" "), - ); - await refreshAfterElevationChange(qc); - }, - onError: (error) => toast.error(t("agents.elevation.endFailed"), apiErrorText(t, error)), - }); -} - -/** The elevation ended or changed before "Extend" went out: extending it now would be a new grant. */ -export class ElevationGoneError extends Error { - override readonly name = "ElevationGoneError"; -} - -/** - * Extends elevations in force (ADR-0016 addendum): a new elevation in **the same mode** for - * `minutes` from now, which replaces the one in force. The keys are read again first, and a key - * whose elevation has ended or changed mode is refused: that would be a new grant, and new grants - * (with the Auto consent) only go through the elevate dialog. The one way Studio extends: the bell, - * its toast and the Agents banner all call this. - */ -export function useExtendElevation() { - const { t, locale } = useI18n(); - const qc = useQueryClient(); - const toast = useToast(); - const rules = useAgentModeRules(); - return useMutation({ - mutationFn: async ({ - targets, - minutes = DEFAULT_EXTEND_MINUTES, - }: { - targets: readonly ExtendTarget[]; - minutes?: number; - }) => { - const keys = await api.agentKeys.list(); - const now = Date.now(); - for (const target of targets) { - const key = keys.find((k) => k.id === target.keyId); - const inForce = key ? extendTargetOfKey(key, now) : undefined; - if (!canExtend(target, now) || inForce?.mode !== target.mode) { - throw new ElevationGoneError(target.name ?? target.keyId); - } - } - return Promise.all( - targets.map((target) => - api.agentKeys.elevate(target.keyId, extensionInput(target, minutes, rules.elevation)), - ), - ); - }, - onSuccess: async (updated) => { - mergeKeys(qc, updated); - toast.success( - t("notifications.extend.done"), - updated - .flatMap((u) => - u.elevation - ? [ - t("agents.elevate.doneDescription", { - name: u.name ?? t("agents.unnamed"), - mode: t(`agents.modes.names.${u.elevation.mode}`), - time: formatUntil(u.elevation.until, locale), - from: t(`agents.modes.names.${ownModeOf(u)}`), - }), - ] - : [], - ) - .join(" "), - ); - await refreshAfterElevationChange(qc); - }, - onError: async (error) => { - if (error instanceof ElevationGoneError) { - toast.error(t("notifications.extend.gone"), t("notifications.extend.goneDescription")); - await refreshAfterElevationChange(qc); - } else { - toast.error(t("notifications.extend.failed"), apiErrorText(t, error)); - } - }, - }); -} - /** Refetches the key list the moment the first elevation runs out, so the list flips back. */ export function useElevationExpiry(keys: readonly AgentKeySummary[] | undefined) { const qc = useQueryClient(); diff --git a/apps/studio/src/components/ElevationRequest.tsx b/apps/studio/src/components/ElevationRequest.tsx index d657ae8b..e2f9ddb4 100644 --- a/apps/studio/src/components/ElevationRequest.tsx +++ b/apps/studio/src/components/ElevationRequest.tsx @@ -8,36 +8,11 @@ import { type PendingElevation, REQUEST_ELEVATION, } from "../lib/approvals.ts"; -import { - ELEVATION_PRESETS, - formatDuration, - formatUntil, - isMoreAutonomous, -} from "../lib/elevation.ts"; +import { formatDuration, formatUntil, isMoreAutonomous } from "../lib/elevation.ts"; import { ModeBadge } from "./AgentModes.tsx"; +import { useShortDuration } from "./useShortDuration.ts"; -/** - * A duration in the same short words as the elevation presets: "15 min", "1 hour", "1 day", - * and "1 hr 30 min" / "1 sa 30 dk" for anything else (Intl's short Turkish adds full stops). - */ -export function useShortDuration() { - const t = useT(); - return useCallback( - (minutes: number) => { - const standard = ELEVATION_PRESETS.find((p) => p === minutes); - if (standard !== undefined) return t(`agents.elevate.presets.${standard}`); - const hours = Math.floor(minutes / 60); - const rest = minutes % 60; - return [ - hours > 0 ? t("approvals.elevation.presetHours", { count: hours }) : "", - rest > 0 ? t("approvals.elevation.presetMinutes", { count: rest }) : "", - ] - .filter(Boolean) - .join(" "); - }, - [t], - ); -} +export { useShortDuration }; /** * Words for an agent's elevation request (ADR-0016 addendum) in the person's language. The diff --git a/apps/studio/src/components/NotificationItems.tsx b/apps/studio/src/components/NotificationItems.tsx new file mode 100644 index 00000000..7b7b3909 --- /dev/null +++ b/apps/studio/src/components/NotificationItems.tsx @@ -0,0 +1,501 @@ +import { + Badge, + Button, + ButtonGroup, + DropdownMenu, + directional, + LinkButton, + NotificationItem, + SkeletonLine, + Text, + TransitionArrow, +} from "@cloudhal/ui"; +import { + ArrowRight, + CalendarDots, + CaretDown, + ChatsCircle, + ClockClockwise, + ClockCountdown, + Flag, + PauseCircle, + Robot, + ShieldStar, + Stop, + UserPlus, + WarningCircle, +} from "@phosphor-icons/react"; +import { useMutation } from "@tanstack/react-query"; +import { useParams } from "@tanstack/react-router"; +import { Suspense, use } from "react"; +import { useI18n } from "../i18n/index.tsx"; +import { + api, + type CommentNotification, + type ElevationEndingNotification, + type ElevationRequestNotification, + type MemberJoinedNotification, + type RoleChangedNotification, + type ScheduleNotification, + type StudioNotification, +} from "../lib/api.ts"; +import { formatUntil } from "../lib/elevation.ts"; +import { + commentNotice, + DEFAULT_EXTEND_MINUTES, + EXTEND_PRESETS, + type ExtendTarget, + endingParams, + extendTargetOf, + isCommentNotification, + isScheduleNotification, + notificationHref, + requestParams, +} from "../lib/notifications.ts"; +import { ModeBadge } from "./AgentModes.tsx"; +import { useEndElevation, useExtendElevation } from "./Elevation.tsx"; +import { AgentReason, useShortDuration } from "./ElevationRequest.tsx"; +import { RelativeTime } from "./RelativeTime.tsx"; +import { RoleBadge } from "./Roles.tsx"; +import { useToast } from "./Toast.ts"; +import { useAgentModeRules } from "./useAgentModeRules.ts"; + +/* + * The bell panel's items (DESIGN.md §2n). They load with the first opening of a bell that has + * something in it (`NotificationsBell.tsx`), never with the shell: their mode, role and comment + * badges and the extend / end controls are only needed then. + */ + +/** + * "Extend 1 hour" with a ▾ for the other presets (30 min, 1 hour, 4 hours), each showing when the + * elevation would then end. An extension counts from now and keeps the mode in force. + */ +function ExtendButton({ + target, + now, + pending, + disabled, + onExtend, +}: { + target: ExtendTarget; + now: number; + pending: boolean; + disabled: boolean; + onExtend: (minutes: number) => void; +}) { + const { t, locale } = useI18n(); + const { elevation } = useAgentModeRules(); + const presets = EXTEND_PRESETS.filter( + (m) => m >= elevation.minMinutes && m <= elevation.maxMinutes, + ); + const label = (m: number) => t(`notifications.extend.presets.${m as 30 | 60 | 240}`); + return ( + + + + } + disabled={pending || disabled} + aria-label={t("notifications.extend.more")} + /> + } + /> + + + {t("notifications.extend.fromNow")} + {presets.map((m) => ( + onExtend(m)} data-extend-preset={m}> + {t("notifications.extend.presetUntil", { + duration: label(m), + time: formatUntil(new Date(now + m * 60_000).toISOString(), locale, { now }), + })} + + ))} + + + + + ); +} + +/** "Elevation ending soon": the mode with its timer, the sentence, Extend / End now / Agents. */ +function EndingItem({ + item, + now, + onNavigate, +}: { + item: ElevationEndingNotification; + now: number; + onNavigate: () => void; +}) { + const { t, locale } = useI18n(); + const extend = useExtendElevation(); + const end = useEndElevation(); + const target = extendTargetOf(item); + const p = endingParams(t, locale, item, now); + return ( + } + tone="warning" + title={t("notifications.ending.title")} + actions={ + <> + {target && ( + extend.mutate({ targets: [target], minutes })} + /> + )} + + + {t("notifications.openAgents")} + + + } + > +
+
+ + }> + {t("agents.elevation.left", { time: p.left })} + +
+ {t("notifications.ending.sentence", p)} +
+
+ ); +} + +/** "Planner asks for Edits · 30 min", the agent's reason quoted as plain text, Review. */ +function RequestItem({ + item, + onNavigate, +}: { + item: ElevationRequestNotification; + onNavigate: () => void; +}) { + const { t } = useI18n(); + const shortDuration = useShortDuration(); + return ( + } + tone="info" + title={t("notifications.request.title", requestParams(t, item, shortDuration))} + meta={} + actions={ + + {t("notifications.request.review")} + + } + > +
+ +
+
+ ); +} + +/** "Ada joined as Editor" — someone the person invited accepted (RFC-0005 §7). */ +function MemberJoinedItem({ + item, + onNavigate, +}: { + item: MemberJoinedNotification; + onNavigate: () => void; +}) { + const { t } = useI18n(); + return ( + } + tone="success" + title={t("notifications.memberJoined.title", { name: item.name ?? t("members.someone") })} + meta={} + actions={ + + {t("notifications.openTeam")} + + } + > +
+ + + {t("notifications.memberJoined.body")} + +
+
+ ); +} + +/** "Your role is now Author" — someone changed the person's role (RFC-0005 open question 4). */ +function RoleChangedItem({ + item, + onNavigate, +}: { + item: RoleChangedNotification; + onNavigate: () => void; +}) { + const { t } = useI18n(); + return ( + } + tone="info" + title={t("notifications.roleChanged.title", { role: t(`roles.names.${item.to}`) })} + meta={} + actions={ + + {t("notifications.openTeam")} + + } + > +
+ + + + + + + {t("notifications.roleChanged.body", { name: item.by.name ?? item.by.id })} + +
+
+ ); +} + +const COMMENT_ICON = { + "comments-pending": ChatsCircle, + "comments-reported": Flag, + "comments-paused": PauseCircle, + "comments-purge-waiting": ClockCountdown, + "comments-approval": Robot, +} as const; + +/** + * Reader comments (RFC-0007 §13, DESIGN.md §2w): "12 comments wait for moderation", reports, + * a paused day, a takedown still waiting for the live page, an agent's comment request — each + * with where to act on it. The words are counts and names, never reader text. + */ +function CommentItem({ + item, + now, + onNavigate, +}: { + item: CommentNotification; + now: number; + onNavigate: () => void; +}) { + const { t, locale } = useI18n(); + const toast = useToast(); + const purge = useMutation({ + mutationFn: () => api.comments.purgeSiteCache(item.siteId), + onSuccess: () => toast.success(t("notifications.comments.purgeWaiting.purged")), + onError: (e) => toast.error(t("notifications.comments.purgeWaiting.failed"), e.message), + }); + const { title, body } = commentNotice(t, locale, item, now); + const Icon = COMMENT_ICON[item.kind]; + const tone = + item.kind === "comments-reported" || + item.kind === "comments-paused" || + item.kind === "comments-purge-waiting" + ? "warning" + : "info"; + const action = + item.kind === "comments-paused" + ? t("notifications.comments.settings") + : item.kind === "comments-purge-waiting" + ? t("notifications.comments.open") + : t("notifications.comments.review"); + return ( + } + tone={tone} + title={title} + meta={ + + } + actions={ + <> + {item.kind === "comments-purge-waiting" && ( + + )} + + {action} + + + } + > +
+ + {body} + +
+
+ ); +} + +/** The schedule notices' words load with the first one (`lib/schedule-notices.ts`), never with the shell. */ +let noticesModule: Promise | undefined; +const loadNotices = () => { + noticesModule ??= import("../lib/schedule-notices.ts"); + return noticesModule; +}; + +const SCHEDULE_ICON = { + "schedule-upcoming": CalendarDots, + "schedule-published": CalendarDots, + "automation-digest": CalendarDots, + "automation-paused": PauseCircle, +} as const; + +/** + * A release or automation notice (RFC-0008 §14, DESIGN.md §2n): what happened in the person's + * words and the one place to act on it. Suspends until the `schedules` words are there. + */ +function ScheduleNoticeItem({ + item, + onNavigate, +}: { + item: ScheduleNotification; + onNavigate: () => void; +}) { + const { t, locale } = useI18n("schedules"); + const { scheduleNotice } = use(loadNotices()); + const notice = scheduleNotice(t, locale, item); + const Icon = SCHEDULE_ICON[item.kind as keyof typeof SCHEDULE_ICON] ?? WarningCircle; + // The scheduler is the deployment's: from a site, its settings say how it runs. + const params = useParams({ strict: false }) as { siteId?: string }; + const href = + item.kind === "scheduler-stalled" && params.siteId + ? `/sites/${params.siteId}/settings#schedules` + : notificationHref(item); + return ( + } + tone={notice.tone} + title={notice.title} + meta={} + actions={ + + {notice.action} + + } + > +
+ + {notice.body} + +
+
+ ); +} + +export function NoticeLoading() { + return ( +
+ + +
+ ); +} + +/** The bell panel's items, one per notification, each with where to act on it. */ +export function NotificationList({ + items, + now, + onNavigate, +}: { + items: readonly StudioNotification[]; + now: number; + onNavigate: () => void; +}) { + return items.map((item) => + isScheduleNotification(item) ? ( + }> + + + ) : isCommentNotification(item) ? ( + + ) : item.kind === "elevation-ending" ? ( + + ) : item.kind === "elevation-request" ? ( + + ) : item.kind === "member-joined" ? ( + + ) : ( + + ), + ); +} diff --git a/apps/studio/src/components/NotificationsBell.tsx b/apps/studio/src/components/NotificationsBell.tsx index ef1fd2ab..f256195f 100644 --- a/apps/studio/src/components/NotificationsBell.tsx +++ b/apps/studio/src/components/NotificationsBell.tsx @@ -1,468 +1,20 @@ -import { - Badge, - Button, - ButtonGroup, - DropdownMenu, - directional, - LinkButton, - NotificationBell, - NotificationItem, - SectionEmpty, - SkeletonLine, - Text, - TransitionArrow, -} from "@cloudhal/ui"; -import { - ArrowRight, - BellSimple, - CalendarDots, - CaretDown, - ChatsCircle, - ClockClockwise, - ClockCountdown, - Flag, - GearSix, - PauseCircle, - Robot, - ShieldStar, - Stop, - UserPlus, - WarningCircle, -} from "@phosphor-icons/react"; -import { useMutation } from "@tanstack/react-query"; -import { useNavigate, useParams } from "@tanstack/react-router"; -import { Suspense, use, useState } from "react"; +import { Badge, Button, NotificationBell, SectionEmpty, SkeletonLine } from "@cloudhal/ui"; +import { BellSimple, GearSix } from "@phosphor-icons/react"; +import { useNavigate } from "@tanstack/react-router"; +import { lazy, Suspense, useEffect, useState } from "react"; import { useI18n } from "../i18n/index.tsx"; -import { - api, - type CommentNotification, - type ElevationEndingNotification, - type ElevationRequestNotification, - type MemberJoinedNotification, - type RoleChangedNotification, - type ScheduleNotification, -} from "../lib/api.ts"; -import { formatUntil } from "../lib/elevation.ts"; -import { - commentNotice, - DEFAULT_EXTEND_MINUTES, - EXTEND_PRESETS, - type ExtendTarget, - endingParams, - extendTargetOf, - isCommentNotification, - isScheduleNotification, - liveNotifications, - notificationHref, - requestParams, -} from "../lib/notifications.ts"; -import { ModeBadge } from "./AgentModes.tsx"; -import { useEndElevation, useExtendElevation } from "./Elevation.tsx"; -import { AgentReason, useShortDuration } from "./ElevationRequest.tsx"; -import { RelativeTime } from "./RelativeTime.tsx"; -import { RoleBadge } from "./Roles.tsx"; -import { useToast } from "./Toast.ts"; -import { useAgentModeRules } from "./useAgentModeRules.ts"; +import { liveNotifications } from "../lib/notifications.ts"; import { useNotificationAlerts, useNotifications } from "./useNotifications.ts"; import { useNow } from "./useNow.ts"; /** - * "Extend 1 hour" with a ▾ for the other presets (30 min, 1 hour, 4 hours), each showing when the - * elevation would then end. An extension counts from now and keeps the mode in force. + * The panel's items load with the first bell that has something in it (`NotificationItems.tsx`), + * fetched as soon as there is something, so the panel opens without waiting. */ -function ExtendButton({ - target, - now, - pending, - disabled, - onExtend, -}: { - target: ExtendTarget; - now: number; - pending: boolean; - disabled: boolean; - onExtend: (minutes: number) => void; -}) { - const { t, locale } = useI18n(); - const { elevation } = useAgentModeRules(); - const presets = EXTEND_PRESETS.filter( - (m) => m >= elevation.minMinutes && m <= elevation.maxMinutes, - ); - const label = (m: number) => t(`notifications.extend.presets.${m as 30 | 60 | 240}`); - return ( - - - - } - disabled={pending || disabled} - aria-label={t("notifications.extend.more")} - /> - } - /> - - - {t("notifications.extend.fromNow")} - {presets.map((m) => ( - onExtend(m)} data-extend-preset={m}> - {t("notifications.extend.presetUntil", { - duration: label(m), - time: formatUntil(new Date(now + m * 60_000).toISOString(), locale, { now }), - })} - - ))} - - - - - ); -} - -/** "Elevation ending soon": the mode with its timer, the sentence, Extend / End now / Agents. */ -function EndingItem({ - item, - now, - onNavigate, -}: { - item: ElevationEndingNotification; - now: number; - onNavigate: () => void; -}) { - const { t, locale } = useI18n(); - const extend = useExtendElevation(); - const end = useEndElevation(); - const target = extendTargetOf(item); - const p = endingParams(t, locale, item, now); - return ( - } - tone="warning" - title={t("notifications.ending.title")} - actions={ - <> - {target && ( - extend.mutate({ targets: [target], minutes })} - /> - )} - - - {t("notifications.openAgents")} - - - } - > -
-
- - }> - {t("agents.elevation.left", { time: p.left })} - -
- {t("notifications.ending.sentence", p)} -
-
- ); -} - -/** "Planner asks for Edits · 30 min", the agent's reason quoted as plain text, Review. */ -function RequestItem({ - item, - onNavigate, -}: { - item: ElevationRequestNotification; - onNavigate: () => void; -}) { - const { t } = useI18n(); - const shortDuration = useShortDuration(); - return ( - } - tone="info" - title={t("notifications.request.title", requestParams(t, item, shortDuration))} - meta={} - actions={ - - {t("notifications.request.review")} - - } - > -
- -
-
- ); -} - -/** "Ada joined as Editor" — someone the person invited accepted (RFC-0005 §7). */ -function MemberJoinedItem({ - item, - onNavigate, -}: { - item: MemberJoinedNotification; - onNavigate: () => void; -}) { - const { t } = useI18n(); - return ( - } - tone="success" - title={t("notifications.memberJoined.title", { name: item.name ?? t("members.someone") })} - meta={} - actions={ - - {t("notifications.openTeam")} - - } - > -
- - - {t("notifications.memberJoined.body")} - -
-
- ); -} - -/** "Your role is now Author" — someone changed the person's role (RFC-0005 open question 4). */ -function RoleChangedItem({ - item, - onNavigate, -}: { - item: RoleChangedNotification; - onNavigate: () => void; -}) { - const { t } = useI18n(); - return ( - } - tone="info" - title={t("notifications.roleChanged.title", { role: t(`roles.names.${item.to}`) })} - meta={} - actions={ - - {t("notifications.openTeam")} - - } - > -
- - - - - - - {t("notifications.roleChanged.body", { name: item.by.name ?? item.by.id })} - -
-
- ); -} - -const COMMENT_ICON = { - "comments-pending": ChatsCircle, - "comments-reported": Flag, - "comments-paused": PauseCircle, - "comments-purge-waiting": ClockCountdown, - "comments-approval": Robot, -} as const; - -/** - * Reader comments (RFC-0007 §13, DESIGN.md §2w): "12 comments wait for moderation", reports, - * a paused day, a takedown still waiting for the live page, an agent's comment request — each - * with where to act on it. The words are counts and names, never reader text. - */ -function CommentItem({ - item, - now, - onNavigate, -}: { - item: CommentNotification; - now: number; - onNavigate: () => void; -}) { - const { t, locale } = useI18n(); - const toast = useToast(); - const purge = useMutation({ - mutationFn: () => api.comments.purgeSiteCache(item.siteId), - onSuccess: () => toast.success(t("notifications.comments.purgeWaiting.purged")), - onError: (e) => toast.error(t("notifications.comments.purgeWaiting.failed"), e.message), - }); - const { title, body } = commentNotice(t, locale, item, now); - const Icon = COMMENT_ICON[item.kind]; - const tone = - item.kind === "comments-reported" || - item.kind === "comments-paused" || - item.kind === "comments-purge-waiting" - ? "warning" - : "info"; - const action = - item.kind === "comments-paused" - ? t("notifications.comments.settings") - : item.kind === "comments-purge-waiting" - ? t("notifications.comments.open") - : t("notifications.comments.review"); - return ( - } - tone={tone} - title={title} - meta={ - - } - actions={ - <> - {item.kind === "comments-purge-waiting" && ( - - )} - - {action} - - - } - > -
- - {body} - -
-
- ); -} - -/** The schedule notices' words load with the first one (`lib/schedule-notices.ts`), never with the shell. */ -let noticesModule: Promise | undefined; -const loadNotices = () => { - noticesModule ??= import("../lib/schedule-notices.ts"); - return noticesModule; -}; - -const SCHEDULE_ICON = { - "schedule-upcoming": CalendarDots, - "schedule-published": CalendarDots, - "automation-digest": CalendarDots, - "automation-paused": PauseCircle, -} as const; - -/** - * A release or automation notice (RFC-0008 §14, DESIGN.md §2n): what happened in the person's - * words and the one place to act on it. Suspends until the `schedules` words are there. - */ -function ScheduleNoticeItem({ - item, - onNavigate, -}: { - item: ScheduleNotification; - onNavigate: () => void; -}) { - const { t, locale } = useI18n("schedules"); - const { scheduleNotice } = use(loadNotices()); - const notice = scheduleNotice(t, locale, item); - const Icon = SCHEDULE_ICON[item.kind as keyof typeof SCHEDULE_ICON] ?? WarningCircle; - // The scheduler is the deployment's: from a site, its settings say how it runs. - const params = useParams({ strict: false }) as { siteId?: string }; - const href = - item.kind === "scheduler-stalled" && params.siteId - ? `/sites/${params.siteId}/settings#schedules` - : notificationHref(item); - return ( - } - tone={notice.tone} - title={notice.title} - meta={} - actions={ - - {notice.action} - - } - > -
- - {notice.body} - -
-
- ); -} +const loadItems = () => import("./NotificationItems.tsx"); +const NotificationList = lazy(() => loadItems().then((m) => ({ default: m.NotificationList }))); -function NoticeLoading() { +function ListLoading() { return (
@@ -486,6 +38,10 @@ export function NotificationsBell() { const now = useNow(15_000); const items = liveNotifications(notifications.data?.items, now); const close = () => setOpen(false); + const waiting = items.length > 0; + useEffect(() => { + if (waiting) void loadItems(); + }, [waiting]); return ( ) : ( - items.map((item) => - isScheduleNotification(item) ? ( - }> - - - ) : isCommentNotification(item) ? ( - - ) : item.kind === "elevation-ending" ? ( - - ) : item.kind === "elevation-request" ? ( - - ) : item.kind === "member-joined" ? ( - - ) : ( - - ), - ) + }> + + )} ); diff --git a/apps/studio/src/components/Publish.tsx b/apps/studio/src/components/Publish.tsx index 7aa89732..2e2e3194 100644 --- a/apps/studio/src/components/Publish.tsx +++ b/apps/studio/src/components/Publish.tsx @@ -4,8 +4,8 @@ import { useT } from "../i18n/index.tsx"; import { ApiRequestError, api, type PublishResult, publishResultOf } from "../lib/api.ts"; import { apiErrorText } from "../lib/api-errors.ts"; import { ownOnlyGuess } from "../lib/roles.ts"; -import { taxonomyKeys } from "./Taxonomy.tsx"; import { useToast } from "./Toast.ts"; +import { taxonomyKeys } from "./taxonomy-keys.ts"; import { useLiveSiteActions } from "./useLiveSite.ts"; import { useGate, useMe, useSelf } from "./useMe.ts"; import { approvalsKey } from "./usePendingApprovals.ts"; diff --git a/apps/studio/src/components/Taxonomy.tsx b/apps/studio/src/components/Taxonomy.tsx index 3ce1b4c9..cb337c84 100644 --- a/apps/studio/src/components/Taxonomy.tsx +++ b/apps/studio/src/components/Taxonomy.tsx @@ -19,13 +19,9 @@ import { taxonomyLabel, termLabel, } from "../lib/taxonomy.ts"; +import { taxonomyKeys } from "./taxonomy-keys.ts"; -/** Query keys of the taxonomy data, so every screen shares (and invalidates) the same cache. */ -export const taxonomyKeys = { - taxonomies: (siteId: string) => ["taxonomies", siteId] as const, - terms: (siteId: string) => ["terms", siteId] as const, - redirects: (siteId: string) => ["redirects", siteId] as const, -}; +export { taxonomyKeys }; /** * The site's taxonomies and all its terms with usage (every status), plus naming helpers in the diff --git a/apps/studio/src/components/elevation-actions.ts b/apps/studio/src/components/elevation-actions.ts new file mode 100644 index 00000000..8304409a --- /dev/null +++ b/apps/studio/src/components/elevation-actions.ts @@ -0,0 +1,144 @@ +import { type QueryClient, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useI18n } from "../i18n/index.tsx"; +import { ownModeOf } from "../lib/agents.ts"; +import { type AgentKeySummary, api } from "../lib/api.ts"; +import { apiErrorText } from "../lib/api-errors.ts"; +import { formatUntil } from "../lib/elevation.ts"; +import { + canExtend, + DEFAULT_EXTEND_MINUTES, + type ExtendTarget, + extendTargetOfKey, + extensionInput, +} from "../lib/notifications.ts"; +import { useToast } from "./Toast.ts"; +import { useAgentModeRules } from "./useAgentModeRules.ts"; + +/* + * Ending and extending elevations: the one implementation of each, shared by the key menu, the + * Agents banner, the bell and its toasts. Hooks only, so the shell's notification alerts load them + * without the Agents screen's badges and banners (`Elevation.tsx`). + */ + +/** + * After any change to an elevation: the key list, the bell and the approvals (a request the key + * can no longer take reads differently) are read again. + */ +async function refreshAfterElevationChange(qc: QueryClient) { + await Promise.all([ + qc.invalidateQueries({ queryKey: ["agent-keys"] }), + qc.invalidateQueries({ queryKey: ["notifications"] }), + qc.invalidateQueries({ queryKey: ["approvals"] }), + ]); +} + +/** Puts the summaries the server returned into the cached key list. */ +function mergeKeys(qc: QueryClient, updated: readonly AgentKeySummary[]) { + qc.setQueryData(["agent-keys"], (keys) => + keys?.map((k) => updated.find((u) => u.id === k.id) ?? k), + ); +} + +/** + * Ends elevations early (`DELETE …/elevation`, audited). No confirmation: it only takes autonomy + * away, and elevating again is one dialog away (DESIGN.md §2g). Success and failure are toasts. + * The one way Studio ends an elevation: the key menu, the Agents banner and the notification bell + * all call this. + */ +export function useEndElevation() { + const { t } = useI18n(); + const qc = useQueryClient(); + const toast = useToast(); + return useMutation({ + mutationFn: (keys: readonly Pick[]) => + Promise.all(keys.map((k) => api.agentKeys.endElevation(k.id))), + onSuccess: async (updated) => { + mergeKeys(qc, updated); + toast.success( + t("agents.elevation.ended"), + updated + .map((u) => + t("agents.elevation.endedDescription", { + name: u.name ?? t("agents.unnamed"), + mode: t(`agents.modes.names.${ownModeOf(u)}`), + }), + ) + .join(" "), + ); + await refreshAfterElevationChange(qc); + }, + onError: (error) => toast.error(t("agents.elevation.endFailed"), apiErrorText(t, error)), + }); +} + +/** The elevation ended or changed before "Extend" went out: extending it now would be a new grant. */ +export class ElevationGoneError extends Error { + override readonly name = "ElevationGoneError"; +} + +/** + * Extends elevations in force (ADR-0016 addendum): a new elevation in **the same mode** for + * `minutes` from now, which replaces the one in force. The keys are read again first, and a key + * whose elevation has ended or changed mode is refused: that would be a new grant, and new grants + * (with the Auto consent) only go through the elevate dialog. The one way Studio extends: the bell, + * its toast and the Agents banner all call this. + */ +export function useExtendElevation() { + const { t, locale } = useI18n(); + const qc = useQueryClient(); + const toast = useToast(); + const rules = useAgentModeRules(); + return useMutation({ + mutationFn: async ({ + targets, + minutes = DEFAULT_EXTEND_MINUTES, + }: { + targets: readonly ExtendTarget[]; + minutes?: number; + }) => { + const keys = await api.agentKeys.list(); + const now = Date.now(); + for (const target of targets) { + const key = keys.find((k) => k.id === target.keyId); + const inForce = key ? extendTargetOfKey(key, now) : undefined; + if (!canExtend(target, now) || inForce?.mode !== target.mode) { + throw new ElevationGoneError(target.name ?? target.keyId); + } + } + return Promise.all( + targets.map((target) => + api.agentKeys.elevate(target.keyId, extensionInput(target, minutes, rules.elevation)), + ), + ); + }, + onSuccess: async (updated) => { + mergeKeys(qc, updated); + toast.success( + t("notifications.extend.done"), + updated + .flatMap((u) => + u.elevation + ? [ + t("agents.elevate.doneDescription", { + name: u.name ?? t("agents.unnamed"), + mode: t(`agents.modes.names.${u.elevation.mode}`), + time: formatUntil(u.elevation.until, locale), + from: t(`agents.modes.names.${ownModeOf(u)}`), + }), + ] + : [], + ) + .join(" "), + ); + await refreshAfterElevationChange(qc); + }, + onError: async (error) => { + if (error instanceof ElevationGoneError) { + toast.error(t("notifications.extend.gone"), t("notifications.extend.goneDescription")); + await refreshAfterElevationChange(qc); + } else { + toast.error(t("notifications.extend.failed"), apiErrorText(t, error)); + } + }, + }); +} diff --git a/apps/studio/src/components/taxonomy-keys.ts b/apps/studio/src/components/taxonomy-keys.ts new file mode 100644 index 00000000..0c76f8aa --- /dev/null +++ b/apps/studio/src/components/taxonomy-keys.ts @@ -0,0 +1,30 @@ +import { useQuery } from "@tanstack/react-query"; +import { api } from "../lib/api.ts"; + +/** + * Query keys of the taxonomy data, so every screen shares (and invalidates) the same cache. They + * live apart from `Taxonomy.tsx` so the shell (the sidebar's count, Publish's invalidation) does + * not load the taxonomy model and its badges. + */ +export const taxonomyKeys = { + taxonomies: (siteId: string) => ["taxonomies", siteId] as const, + terms: (siteId: string) => ["terms", siteId] as const, + redirects: (siteId: string) => ["redirects", siteId] as const, +}; + +/** + * Proposed terms waiting for a person (RFC-0002 §8), for the sidebar's badge: the same two queries + * `useTaxonomyData` reads, so they share its cache. + */ +export function useProposedTermsCount(siteId: string): number { + const taxonomies = useQuery({ + queryKey: taxonomyKeys.taxonomies(siteId), + queryFn: () => api.taxonomies.list(siteId), + }); + const terms = useQuery({ + queryKey: taxonomyKeys.terms(siteId), + queryFn: () => api.terms.list(siteId), + enabled: (taxonomies.data?.length ?? 0) > 0, + }); + return (terms.data ?? []).filter((i) => i.term.status === "proposed").length; +} diff --git a/apps/studio/src/components/useNotifications.ts b/apps/studio/src/components/useNotifications.ts index 15b04a18..0cde42ec 100644 --- a/apps/studio/src/components/useNotifications.ts +++ b/apps/studio/src/components/useNotifications.ts @@ -22,9 +22,9 @@ import { shouldRaiseBrowserNotification, } from "../lib/notifications.ts"; import { splitHref } from "./AppLink.tsx"; -import { useExtendElevation } from "./Elevation.tsx"; -import { useShortDuration } from "./ElevationRequest.tsx"; +import { useExtendElevation } from "./elevation-actions.ts"; import { type ToastAction, useToast } from "./Toast.ts"; +import { useShortDuration } from "./useShortDuration.ts"; export const NOTIFICATIONS_KEY = ["notifications"] as const; diff --git a/apps/studio/src/components/useShortDuration.ts b/apps/studio/src/components/useShortDuration.ts new file mode 100644 index 00000000..a05dc82c --- /dev/null +++ b/apps/studio/src/components/useShortDuration.ts @@ -0,0 +1,26 @@ +import { useCallback } from "react"; +import { useT } from "../i18n/index.tsx"; +import { ELEVATION_PRESETS } from "../lib/elevation.ts"; + +/** + * A duration in the same short words as the elevation presets: "15 min", "1 hour", "1 day", + * and "1 hr 30 min" / "1 sa 30 dk" for anything else (Intl's short Turkish adds full stops). + */ +export function useShortDuration() { + const t = useT(); + return useCallback( + (minutes: number) => { + const standard = ELEVATION_PRESETS.find((p) => p === minutes); + if (standard !== undefined) return t(`agents.elevate.presets.${standard}`); + const hours = Math.floor(minutes / 60); + const rest = minutes % 60; + return [ + hours > 0 ? t("approvals.elevation.presetHours", { count: hours }) : "", + rest > 0 ? t("approvals.elevation.presetMinutes", { count: rest }) : "", + ] + .filter(Boolean) + .join(" "); + }, + [t], + ); +} diff --git a/apps/studio/src/routes/SiteShell.tsx b/apps/studio/src/routes/SiteShell.tsx index ac69f05f..47bbe4ea 100644 --- a/apps/studio/src/routes/SiteShell.tsx +++ b/apps/studio/src/routes/SiteShell.tsx @@ -26,7 +26,7 @@ import { ViewSiteButton } from "../components/LiveLinks.tsx"; import { SitePublish } from "../components/SitePublish.tsx"; import { SiteSwitcherExtra } from "../components/SiteSwitcher.tsx"; import { SitePublishOpener } from "../components/sitePublishOpener.ts"; -import { useTaxonomyData } from "../components/Taxonomy.tsx"; +import { useProposedTermsCount } from "../components/taxonomy-keys.ts"; import { useCan } from "../components/useMe.ts"; import { useNotifications } from "../components/useNotifications.ts"; import { usePendingApprovals } from "../components/usePendingApprovals.ts"; @@ -55,8 +55,7 @@ export function SiteShell() { const approvals = usePendingApprovals(siteId, full); const pendingApprovals = approvals.data?.length ?? 0; // Proposed terms wait for a person (RFC-0002 §8): their count rides on the sidebar item. - const taxonomy = useTaxonomyData(siteId); - const proposedTerms = taxonomy.items.filter((i) => i.term.status === "proposed").length; + const proposedTerms = useProposedTermsCount(siteId); // Reader comments (RFC-0007 §14): on deployments that have them, for people who may read them; // the pending count rides on the bell's digest (own articles' count for Authors). const instance = useQuery({ queryKey: ["instance"], queryFn: api.instance, retry: false }); diff --git a/apps/studio/test/design-rules.test.ts b/apps/studio/test/design-rules.test.ts index c16e4573..b03f6a6b 100644 --- a/apps/studio/test/design-rules.test.ts +++ b/apps/studio/test/design-rules.test.ts @@ -55,6 +55,10 @@ function screenText(file: string): string { const uiFiles = files.filter( (f) => f.file.startsWith("routes/") || f.file.startsWith("components/"), ); +/** The screens' and components' sources with their helpers (`.ts` too), for one-implementation checks. */ +const uiSources = walkAll(SRC) + .map((f) => ({ file: path.relative(SRC, f), text: readFileSync(f, "utf8") })) + .filter((f) => f.file.startsWith("routes/") || f.file.startsWith("components/")); /** Every screen must be composed from these; the matrix in DESIGN.md maps the rest. */ const REQUIRED_IMPORTS: Record = { @@ -412,9 +416,17 @@ const REQUIRED_IMPORTS: Record = { "components/AppFrame.tsx#notifications": ["NotificationsBell"], "components/NotificationsBell.tsx": [ "NotificationBell", - "NotificationItem", "SectionEmpty", "Badge", + "useNotifications", + "useNotificationAlerts", + "liveNotifications", + "NotificationList", + ], + // The panel's items load with the first bell that has something in it (bundle-budget.ts). + "components/NotificationItems.tsx": [ + "NotificationItem", + "Badge", "ButtonGroup", "DropdownMenu", "LinkButton", @@ -423,9 +435,6 @@ const REQUIRED_IMPORTS: Record = { "RelativeTime", "useExtendElevation", "useEndElevation", - "useNotifications", - "useNotificationAlerts", - "liveNotifications", ], "components/NotificationSettings.tsx": [ "SettingsSection", @@ -598,7 +607,7 @@ const REQUIRED_IMPORTS: Record = { "facetFilter", "TagsMigrationBanner", ], - "routes/SiteShell.tsx#taxonomy": ["useTaxonomyData", "Signpost", "Tag"], + "routes/SiteShell.tsx#taxonomy": ["useProposedTermsCount", "Signpost", "Tag"], "components/SnapshotDiffDialog.tsx#taxonomy": ["AssignmentDeltas", "useTaxonomyData"], // Staff roles and invites (§2q–§2s, RFC-0005): Team page, invite flow, token pages, gating. "routes/MembersPage.tsx": [ @@ -700,7 +709,7 @@ const REQUIRED_IMPORTS: Record = { "routes/TermEditorPage.tsx#roles": ["useGate", "ReadOnly", "ViewOnlyBanner"], "routes/RedirectsPage.tsx#roles": ["useGate", "ReadOnly", "ViewOnlyBanner"], "routes/SiteSettingsPage.tsx#roles": ["useGate", "ReadOnly", "ViewOnlyBanner"], - "components/NotificationsBell.tsx#roles": ["MemberJoinedItem", "RoleChangedItem"], + "components/NotificationItems.tsx#roles": ["MemberJoinedItem", "RoleChangedItem"], // Media library (§2u, RFC-0004 §8): library, file page, upload queue, picker, takedown. "routes/MediaPage.tsx": [ "PageLayout", @@ -962,11 +971,11 @@ describe("design rules (DESIGN.md)", () => { }); it("every screen that can elevate a key asks for consent before Auto", () => { - const elevating = uiFiles.filter((f) => /api\.agentKeys\.elevate\b/.test(f.text)); - // The dialog grants; Elevation.tsx only extends an elevation in force (next test). - expect(elevating.map((f) => f.file)).toEqual([ + const elevating = uiSources.filter((f) => /api\.agentKeys\.elevate\b/.test(f.text)); + // The dialog grants; elevation-actions.ts only extends an elevation in force (next test). + expect(elevating.map((f) => f.file).sort()).toEqual([ "components/ElevateAgentKeyDialog.tsx", - "components/Elevation.tsx", + "components/elevation-actions.ts", ]); const dialog = elevating[0] as { file: string; text: string }; expect(dialog.text).toMatch(/ { }); it("extending re-posts the mode in force, never a new grant (ADR-0016 addendum)", () => { - const elevation = files.find((f) => f.file === "components/Elevation.tsx") as { text: string }; + const elevation = uiSources.find((f) => f.file === "components/elevation-actions.ts") as { + text: string; + }; const extend = elevation.text.slice( elevation.text.indexOf("export function useExtendElevation"), ); @@ -990,7 +1001,7 @@ describe("design rules (DESIGN.md)", () => { it("every notification action routes through the shared extend/end helpers", () => { const alerts = readFileSync(path.join(SRC, "components/useNotifications.ts"), "utf8"); const notificationFiles = [ - ...["components/NotificationsBell.tsx", "routes/AgentsPage.tsx"].map( + ...["components/NotificationItems.tsx", "routes/AgentsPage.tsx"].map( (file) => files.find((f) => f.file === file) as { file: string; text: string }, ), { file: "components/useNotifications.ts", text: alerts }, @@ -1003,8 +1014,8 @@ describe("design rules (DESIGN.md)", () => { const bell = notificationFiles[0] as { text: string }; expect(bell.text).toMatch(/\buseEndElevation\(\)/); // Ending early has one implementation too. - const ending = uiFiles.filter((f) => /api\.agentKeys\.endElevation\b/.test(f.text)); - expect(ending.map((f) => f.file)).toEqual(["components/Elevation.tsx"]); + const ending = uiSources.filter((f) => /api\.agentKeys\.endElevation\b/.test(f.text)); + expect(ending.map((f) => f.file)).toEqual(["components/elevation-actions.ts"]); // The toast's "Extend" is the same helper with the same default. expect(alerts).toMatch(/extend\(\{ targets: \[target\], minutes: DEFAULT_EXTEND_MINUTES \}\)/); }); diff --git a/docs/design/DESIGN.md b/docs/design/DESIGN.md index 463c5761..754d82ea 100644 --- a/docs/design/DESIGN.md +++ b/docs/design/DESIGN.md @@ -14,7 +14,7 @@ Studio mirrors this one-to-one: |---|---| | App shell | `Sidebar.Provider defaultOpen collapsible="icon" peekable mobileBreakpoint={768} className="h-dvh"` → `` + a content column `flex h-full min-h-0 flex-1 flex-col overflow-y-auto overscroll-y-contain bg-kumo-overlay`. The provider must have a definite height because Kumo's `