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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/studio-entry-headroom.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 8 additions & 1 deletion apps/studio/bundle-budget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -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.
Expand Down
132 changes: 4 additions & 128 deletions apps/studio/src/components/Elevation.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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<AgentKeySummary[]>(["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<AgentKeySummary, "id">[]) =>
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();
Expand Down
31 changes: 3 additions & 28 deletions apps/studio/src/components/ElevationRequest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading